Skip to main content

fastmcp_server/providers/
filesystem.rs

1//! Filesystem resource provider.
2//!
3//! Exposes files from a directory as MCP resources with configurable
4//! patterns, security controls, and MIME type detection.
5//!
6//! # Security
7//!
8//! The provider includes path traversal protection to prevent accessing
9//! files outside the configured root directory.
10//!
11//! # Example
12//!
13//! ```ignore
14//! use fastmcp_server::providers::FilesystemProvider;
15//!
16//! let provider = FilesystemProvider::new("/data/docs")
17//!     .with_prefix("docs")
18//!     .with_patterns(&["**/*.md", "**/*.txt"])
19//!     .with_exclude(&["**/secret/**", "**/.*"])
20//!     .with_recursive(true)
21//!     .with_max_size(10 * 1024 * 1024); // 10MB limit
22//! ```
23
24use 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
31/// Default maximum file size (10 MB).
32const DEFAULT_MAX_SIZE: usize = 10 * 1024 * 1024;
33
34/// Errors that can occur when using the filesystem provider.
35#[derive(Debug, Clone)]
36pub enum FilesystemProviderError {
37    /// The requested path would escape the root directory.
38    PathTraversal { requested: String },
39    /// The file exceeds the maximum allowed size.
40    TooLarge { path: String, size: u64, max: usize },
41    /// Symlink access was denied.
42    SymlinkDenied { path: String },
43    /// Symlink target escapes the root directory.
44    SymlinkEscapesRoot { path: String },
45    /// IO error occurred.
46    Io { message: String },
47    /// File not found.
48    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                // Security violation - path traversal attempt
79                McpError::invalid_request(err.to_string())
80            }
81            FilesystemProviderError::TooLarge { .. } => McpError::invalid_request(err.to_string()),
82            FilesystemProviderError::SymlinkDenied { .. }
83            | FilesystemProviderError::SymlinkEscapesRoot { .. } => {
84                // Security violation - symlink escape attempt
85                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/// A resource provider that exposes filesystem directories.
94///
95/// Files under the configured root directory are exposed as MCP resources
96/// with URIs like `file://{prefix}/{relative_path}`.
97///
98/// # Security
99///
100/// - Path traversal attempts (e.g., `../../../etc/passwd`) are blocked
101/// - Symlinks can be optionally followed or blocked
102/// - Maximum file size limits prevent memory exhaustion
103/// - Hidden files (starting with `.`) can be excluded
104///
105/// # Example
106///
107/// ```ignore
108/// use fastmcp_server::providers::FilesystemProvider;
109///
110/// let provider = FilesystemProvider::new("/app/data")
111///     .with_prefix("data")
112///     .with_patterns(&["*.json", "*.yaml"])
113///     .with_recursive(true);
114/// ```
115#[derive(Debug, Clone)]
116pub struct FilesystemProvider {
117    /// Root directory for file access.
118    root: PathBuf,
119    /// URI prefix (e.g., "docs" -> "file://docs/...").
120    prefix: Option<String>,
121    /// Glob patterns to include (empty = all files).
122    include_patterns: Vec<String>,
123    /// Glob patterns to exclude.
124    exclude_patterns: Vec<String>,
125    /// Whether to traverse subdirectories.
126    recursive: bool,
127    /// Maximum file size in bytes.
128    max_file_size: usize,
129    /// Whether to follow symlinks.
130    follow_symlinks: bool,
131    /// Description for the resource template.
132    description: Option<String>,
133}
134
135impl FilesystemProvider {
136    /// Creates a new filesystem provider for the given root directory.
137    ///
138    /// # Arguments
139    ///
140    /// * `root` - The root directory to expose
141    ///
142    /// # Example
143    ///
144    /// ```ignore
145    /// let provider = FilesystemProvider::new("/data/docs");
146    /// ```
147    #[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()], // Exclude hidden files by default
154            recursive: false,
155            max_file_size: DEFAULT_MAX_SIZE,
156            follow_symlinks: false,
157            description: None,
158        }
159    }
160
161    /// Sets the URI prefix for resources.
162    ///
163    /// Files will have URIs like `file://{prefix}/{path}`.
164    ///
165    /// # Example
166    ///
167    /// ```ignore
168    /// let provider = FilesystemProvider::new("/data")
169    ///     .with_prefix("mydata");
170    /// // Results in URIs like file://mydata/readme.md
171    /// ```
172    #[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    /// Sets glob patterns to include.
179    ///
180    /// Only files matching at least one of these patterns will be exposed.
181    /// Empty patterns means all files are included.
182    ///
183    /// # Example
184    ///
185    /// ```ignore
186    /// let provider = FilesystemProvider::new("/data")
187    ///     .with_patterns(&["*.md", "*.txt", "**/*.json"]);
188    /// ```
189    #[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    /// Sets glob patterns to exclude.
196    ///
197    /// Files matching any of these patterns will be excluded.
198    /// By default, hidden files (starting with `.`) are excluded.
199    ///
200    /// # Example
201    ///
202    /// ```ignore
203    /// let provider = FilesystemProvider::new("/data")
204    ///     .with_exclude(&["**/secret/**", "*.bak"]);
205    /// ```
206    #[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    /// Enables or disables recursive directory traversal.
213    ///
214    /// When enabled, files in subdirectories are also exposed.
215    ///
216    /// # Example
217    ///
218    /// ```ignore
219    /// let provider = FilesystemProvider::new("/data")
220    ///     .with_recursive(true);
221    /// ```
222    #[must_use]
223    pub fn with_recursive(mut self, enabled: bool) -> Self {
224        self.recursive = enabled;
225        self
226    }
227
228    /// Sets the maximum file size in bytes.
229    ///
230    /// Files larger than this limit will return an error when read.
231    /// Default is 10 MB.
232    ///
233    /// # Example
234    ///
235    /// ```ignore
236    /// let provider = FilesystemProvider::new("/data")
237    ///     .with_max_size(5 * 1024 * 1024); // 5 MB
238    /// ```
239    #[must_use]
240    pub fn with_max_size(mut self, bytes: usize) -> Self {
241        self.max_file_size = bytes;
242        self
243    }
244
245    /// Enables or disables following symlinks.
246    ///
247    /// When disabled (default), symlinks are not followed.
248    /// When enabled, symlinks are followed but must still point within the root directory.
249    ///
250    /// # Example
251    ///
252    /// ```ignore
253    /// let provider = FilesystemProvider::new("/data")
254    ///     .with_follow_symlinks(true);
255    /// ```
256    #[must_use]
257    pub fn with_follow_symlinks(mut self, enabled: bool) -> Self {
258        self.follow_symlinks = enabled;
259        self
260    }
261
262    /// Sets the description for the resource template.
263    ///
264    /// # Example
265    ///
266    /// ```ignore
267    /// let provider = FilesystemProvider::new("/data")
268    ///     .with_description("Documentation files");
269    /// ```
270    #[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    /// Builds a resource handler from this provider.
277    ///
278    /// The returned handler can be registered with a server.
279    ///
280    /// # Example
281    ///
282    /// ```ignore
283    /// let handler = FilesystemProvider::new("/data")
284    ///     .with_prefix("docs")
285    ///     .build();
286    ///
287    /// let server = Server::new("demo", "1.0")
288    ///     .resource(handler);
289    /// ```
290    #[must_use]
291    pub fn build(self) -> FilesystemResourceHandler {
292        FilesystemResourceHandler::new(self)
293    }
294
295    /// Validates a path and returns the canonical path if valid.
296    ///
297    /// Returns an error if:
298    /// - The path is absolute
299    /// - The path escapes the root directory
300    /// - The path is a symlink and symlinks are disabled
301    fn validate_path(&self, requested: &str) -> Result<PathBuf, FilesystemProviderError> {
302        let requested_path = Path::new(requested);
303
304        // Reject absolute paths in request
305        if requested_path.is_absolute() {
306            return Err(FilesystemProviderError::PathTraversal {
307                requested: requested.to_string(),
308            });
309        }
310
311        // Build full path
312        let full_path = self.root.join(requested_path);
313
314        // Canonicalize to resolve ../ etc.
315        // Note: canonicalize requires the path to exist
316        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        // Get canonical root for comparison
329        let canonical_root = self
330            .root
331            .canonicalize()
332            .map_err(|e| FilesystemProviderError::Io {
333                message: format!("Cannot canonicalize root: {e}"),
334            })?;
335
336        // Verify still under root
337        if !canonical.starts_with(&canonical_root) {
338            return Err(FilesystemProviderError::PathTraversal {
339                requested: requested.to_string(),
340            });
341        }
342
343        // Check symlink
344        if full_path.is_symlink() {
345            self.check_symlink(&full_path, &canonical_root)?;
346        }
347
348        Ok(canonical)
349    }
350
351    /// Checks if a symlink is allowed.
352    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        // Verify symlink target is still under root
364        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    /// Checks if a filename matches the include/exclude patterns.
391    fn matches_patterns(&self, relative_path: &str) -> bool {
392        // Check exclude patterns first
393        for pattern in &self.exclude_patterns {
394            if glob_match(pattern, relative_path) {
395                return false;
396            }
397        }
398
399        // If no include patterns, include everything
400        if self.include_patterns.is_empty() {
401            return true;
402        }
403
404        // Check include patterns
405        for pattern in &self.include_patterns {
406            if glob_match(pattern, relative_path) {
407                return true;
408            }
409        }
410
411        false
412    }
413
414    /// Lists files in the directory that match patterns.
415    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    /// Recursively walks a directory collecting file entries.
429    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            // Handle symlinks
450            if file_type.is_symlink() && !self.follow_symlinks {
451                continue;
452            }
453
454            // Calculate relative path
455            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                // Check patterns
468                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    /// Returns the URI for a file.
484    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    /// Returns the URI template for this provider.
492    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    /// Extracts the relative path from a URI.
500    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    /// Reads a file and returns its content.
514    fn read_file(&self, relative_path: &str) -> Result<FileContent, FilesystemProviderError> {
515        // Validate and get canonical path
516        let path = self.validate_path(relative_path)?;
517
518        // Check file size
519        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        // Detect MIME type
540        let mime_type = detect_mime_type(&path);
541
542        // Read content
543        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/// A file entry from directory listing.
560#[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
570/// File content (text or binary).
571enum FileContent {
572    Text(String),
573    Binary(Vec<u8>),
574}
575
576/// Resource handler implementation for the filesystem provider.
577pub struct FilesystemResourceHandler {
578    provider: FilesystemProvider,
579    /// Cached file list for static resources.
580    cached_resources: Vec<Resource>,
581}
582
583impl FilesystemResourceHandler {
584    /// Creates a new handler from a provider.
585    fn new(provider: FilesystemProvider) -> Self {
586        // Pre-compute the list of resources
587        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        // Return a synthetic "root" resource for the provider
613        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        // For template resources, read() without params returns the file list
646        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        // Extract path from URI or params
669        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
725/// Detects the MIME type for a file based on its extension.
726fn 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        // Text formats
734        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        // Programming languages
742        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        // Images
762        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        // Binary/Documents
771        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        // Default
782        _ => "application/octet-stream",
783    }
784    .to_string()
785}
786
787/// Checks if a MIME type represents binary content.
788fn 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
800/// Standard base64 alphabet.
801const BASE64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
802
803/// Encodes bytes to standard base64.
804fn 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
833/// Simple glob pattern matching.
834///
835/// Supports:
836/// - `*` - matches any sequence of characters (except `/`)
837/// - `**` - matches any sequence of characters (including `/`)
838/// - `?` - matches any single character
839fn glob_match(pattern: &str, path: &str) -> bool {
840    glob_match_recursive(pattern, path)
841}
842
843/// Recursive glob matching implementation.
844fn 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                // Check for **
852                if pattern_chars.peek() == Some(&'*') {
853                    pattern_chars.next(); // consume second *
854
855                    // Skip optional / after **
856                    if pattern_chars.peek() == Some(&'/') {
857                        pattern_chars.next();
858                    }
859
860                    let remaining_pattern: String = pattern_chars.collect();
861
862                    // ** matches zero or more path segments
863                    // Try matching from current position and all subsequent positions
864                    let remaining_path: String = path_chars.collect();
865
866                    // Try matching with empty ** (zero segments)
867                    if glob_match_recursive(&remaining_pattern, &remaining_path) {
868                        return true;
869                    }
870
871                    // Try matching with ** consuming characters one at a time
872                    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                // Single * - match anything except /
881                let remaining_pattern: String = pattern_chars.collect();
882                let remaining_path: String = path_chars.collect();
883
884                // Try matching with * consuming 0, 1, 2, ... characters (but not /)
885                for i in 0..=remaining_path.len() {
886                    // Check if the portion we're consuming contains /
887                    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                // Match any single character
898                if path_chars.next().is_none() {
899                    return false;
900                }
901            }
902            c => {
903                // Literal character
904                if path_chars.next() != Some(c) {
905                    return false;
906                }
907            }
908        }
909    }
910
911    // Pattern exhausted - path should also be exhausted
912    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")); // * doesn't match /
980    }
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 = provider.validate_path("/tmp/absolute.txt");
1082        assert!(matches!(
1083            absolute,
1084            Err(FilesystemProviderError::PathTraversal { .. })
1085        ));
1086
1087        let escape = provider.validate_path("../outside-fastmcp-provider-test.txt");
1088        assert!(matches!(
1089            escape,
1090            Err(FilesystemProviderError::PathTraversal { .. })
1091        ));
1092
1093        let ok = provider.validate_path("safe.txt").expect("safe path");
1094        assert!(ok.ends_with("safe.txt"));
1095    }
1096
1097    #[test]
1098    fn test_read_file_text_binary_and_size_limit() {
1099        let root = TestDir::new("read-file");
1100        write_text(&root.join("doc.txt"), "hello world");
1101        write_bytes(&root.join("blob.bin"), &[0x00, 0x7F, 0xAA, 0x55]);
1102        write_bytes(&root.join("large.bin"), &[0u8; 8]);
1103
1104        let provider = FilesystemProvider::new(root.path()).with_max_size(32);
1105
1106        let text = provider.read_file("doc.txt").expect("read text");
1107        assert!(matches!(text, FileContent::Text(ref t) if t == "hello world"));
1108
1109        let binary = provider.read_file("blob.bin").expect("read binary");
1110        assert!(matches!(binary, FileContent::Binary(ref b) if b == &[0x00, 0x7F, 0xAA, 0x55]));
1111
1112        let size_limited = FilesystemProvider::new(root.path()).with_max_size(4);
1113        let too_large = size_limited.read_file("large.bin");
1114        assert!(matches!(
1115            too_large,
1116            Err(FilesystemProviderError::TooLarge { path, size: 8, max: 4 })
1117                if path == "large.bin"
1118        ));
1119    }
1120
1121    #[test]
1122    fn test_handler_read_listing_and_read_with_uri() {
1123        let root = TestDir::new("handler-read");
1124        write_text(&root.join("docs/readme.md"), "# docs");
1125
1126        let handler = FilesystemProvider::new(root.path())
1127            .with_prefix("docs")
1128            .with_patterns(&["**/*.md"])
1129            .with_recursive(true)
1130            .with_description("Documentation")
1131            .build();
1132
1133        let ctx = McpContext::new(asupersync::Cx::for_testing(), 1);
1134
1135        let definition = handler.definition();
1136        assert_eq!(definition.uri, "file://docs/{path}");
1137        assert_eq!(definition.name, "docs");
1138        assert_eq!(definition.description.as_deref(), Some("Documentation"));
1139
1140        let template = handler.template().expect("resource template");
1141        assert_eq!(template.uri_template, "file://docs/{path}");
1142
1143        let listing = handler.read(&ctx).expect("read listing");
1144        let listing_text = listing[0].text.as_deref().expect("listing text");
1145        assert!(listing_text.contains("docs/readme.md: text/markdown"));
1146
1147        let mut params = HashMap::new();
1148        params.insert("path".to_string(), "docs/readme.md".to_string());
1149        let content = handler
1150            .read_with_uri(&ctx, "file://docs/docs/readme.md", &params)
1151            .expect("read with params");
1152        assert_eq!(content[0].text.as_deref(), Some("# docs"));
1153
1154        let empty_params = HashMap::new();
1155        let content_from_uri = handler
1156            .read_with_uri(&ctx, "file://docs/docs/readme.md", &empty_params)
1157            .expect("read using uri path");
1158        assert_eq!(content_from_uri[0].text.as_deref(), Some("# docs"));
1159
1160        let invalid = handler.read_with_uri(&ctx, "file://wrong-prefix/readme.md", &empty_params);
1161        assert!(invalid.is_err());
1162    }
1163
1164    #[test]
1165    fn test_handler_read_async_with_uri() {
1166        let root = TestDir::new("handler-async");
1167        write_text(&root.join("notes.md"), "async content");
1168
1169        let handler = FilesystemProvider::new(root.path())
1170            .with_patterns(&["*.md"])
1171            .build();
1172        let ctx = McpContext::new(asupersync::Cx::for_testing(), 9);
1173
1174        let mut params = HashMap::new();
1175        params.insert("path".to_string(), "notes.md".to_string());
1176        let outcome =
1177            fastmcp_core::block_on(handler.read_async_with_uri(&ctx, "file://notes.md", &params));
1178        match outcome {
1179            Outcome::Ok(content) => {
1180                assert_eq!(content.len(), 1);
1181                assert_eq!(content[0].text.as_deref(), Some("async content"));
1182            }
1183            other => panic!("unexpected async outcome: {other:?}"),
1184        }
1185    }
1186
1187    #[test]
1188    fn test_base64_encode_padding_variants() {
1189        assert_eq!(base64_encode(b""), "");
1190        assert_eq!(base64_encode(b"f"), "Zg==");
1191        assert_eq!(base64_encode(b"fo"), "Zm8=");
1192        assert_eq!(base64_encode(b"foo"), "Zm9v");
1193    }
1194
1195    #[cfg(unix)]
1196    #[test]
1197    fn test_symlink_validation_denied_and_escape() {
1198        use std::os::unix::fs::symlink;
1199
1200        let root = TestDir::new("symlink-root");
1201        let outside = TestDir::new("symlink-outside");
1202
1203        write_text(&root.join("inside.txt"), "inside");
1204        write_text(&outside.join("outside.txt"), "outside");
1205
1206        let inside_link = root.join("inside-link.txt");
1207        let escape_link = root.join("escape-link.txt");
1208        symlink(root.join("inside.txt"), &inside_link).expect("create inside symlink");
1209        symlink(outside.join("outside.txt"), &escape_link).expect("create escape symlink");
1210
1211        let deny_provider = FilesystemProvider::new(root.path()).with_follow_symlinks(false);
1212        let denied = deny_provider.validate_path("inside-link.txt");
1213        assert!(matches!(
1214            denied,
1215            Err(FilesystemProviderError::SymlinkDenied { .. })
1216        ));
1217
1218        let allow_provider = FilesystemProvider::new(root.path()).with_follow_symlinks(true);
1219        let canonical_root = root.path().canonicalize().expect("canonical root");
1220        let escaped = allow_provider.check_symlink(&escape_link, &canonical_root);
1221        assert!(matches!(
1222            escaped,
1223            Err(FilesystemProviderError::SymlinkEscapesRoot { .. })
1224        ));
1225    }
1226
1227    // ── FilesystemProviderError ────────────────────────────────────
1228
1229    #[test]
1230    fn error_path_traversal_display() {
1231        let err = FilesystemProviderError::PathTraversal {
1232            requested: "../etc/passwd".to_string(),
1233        };
1234        let msg = err.to_string();
1235        assert!(msg.contains("Path traversal attempt blocked"));
1236        assert!(msg.contains("../etc/passwd"));
1237    }
1238
1239    #[test]
1240    fn error_too_large_display() {
1241        let err = FilesystemProviderError::TooLarge {
1242            path: "big.bin".to_string(),
1243            size: 50_000_000,
1244            max: 10_000_000,
1245        };
1246        let msg = err.to_string();
1247        assert!(msg.contains("File too large"));
1248        assert!(msg.contains("big.bin"));
1249        assert!(msg.contains("50000000"));
1250        assert!(msg.contains("10000000"));
1251    }
1252
1253    #[test]
1254    fn error_symlink_denied_display() {
1255        let err = FilesystemProviderError::SymlinkDenied {
1256            path: "link.txt".to_string(),
1257        };
1258        assert!(err.to_string().contains("Symlink access denied"));
1259    }
1260
1261    #[test]
1262    fn error_symlink_escapes_root_display() {
1263        let err = FilesystemProviderError::SymlinkEscapesRoot {
1264            path: "evil-link".to_string(),
1265        };
1266        assert!(err.to_string().contains("Symlink target escapes root"));
1267    }
1268
1269    #[test]
1270    fn error_io_display() {
1271        let err = FilesystemProviderError::Io {
1272            message: "permission denied".to_string(),
1273        };
1274        assert!(err.to_string().contains("IO error"));
1275        assert!(err.to_string().contains("permission denied"));
1276    }
1277
1278    #[test]
1279    fn error_not_found_display() {
1280        let err = FilesystemProviderError::NotFound {
1281            path: "missing.txt".to_string(),
1282        };
1283        assert!(err.to_string().contains("File not found"));
1284        assert!(err.to_string().contains("missing.txt"));
1285    }
1286
1287    #[test]
1288    fn error_debug() {
1289        let err = FilesystemProviderError::PathTraversal {
1290            requested: "x".to_string(),
1291        };
1292        let debug = format!("{:?}", err);
1293        assert!(debug.contains("PathTraversal"));
1294    }
1295
1296    #[test]
1297    fn error_clone() {
1298        let err = FilesystemProviderError::NotFound {
1299            path: "a.txt".to_string(),
1300        };
1301        let cloned = err.clone();
1302        assert!(cloned.to_string().contains("a.txt"));
1303    }
1304
1305    #[test]
1306    fn error_std_error() {
1307        let err = FilesystemProviderError::Io {
1308            message: "oops".to_string(),
1309        };
1310        let std_err: &dyn std::error::Error = &err;
1311        assert!(std_err.to_string().contains("oops"));
1312    }
1313
1314    // ── From<FilesystemProviderError> for McpError ─────────────────
1315
1316    #[test]
1317    fn error_into_mcp_error_path_traversal() {
1318        let err = FilesystemProviderError::PathTraversal {
1319            requested: "x".to_string(),
1320        };
1321        let mcp: McpError = err.into();
1322        assert!(mcp.message.contains("Path traversal"));
1323    }
1324
1325    #[test]
1326    fn error_into_mcp_error_too_large() {
1327        let err = FilesystemProviderError::TooLarge {
1328            path: "x".to_string(),
1329            size: 100,
1330            max: 10,
1331        };
1332        let mcp: McpError = err.into();
1333        assert!(mcp.message.contains("File too large"));
1334    }
1335
1336    #[test]
1337    fn error_into_mcp_error_symlink_denied() {
1338        let err = FilesystemProviderError::SymlinkDenied {
1339            path: "x".to_string(),
1340        };
1341        let mcp: McpError = err.into();
1342        assert!(mcp.message.contains("Symlink access denied"));
1343    }
1344
1345    #[test]
1346    fn error_into_mcp_error_symlink_escapes() {
1347        let err = FilesystemProviderError::SymlinkEscapesRoot {
1348            path: "x".to_string(),
1349        };
1350        let mcp: McpError = err.into();
1351        assert!(mcp.message.contains("Symlink target escapes"));
1352    }
1353
1354    #[test]
1355    fn error_into_mcp_error_io() {
1356        let err = FilesystemProviderError::Io {
1357            message: "disk fail".to_string(),
1358        };
1359        let mcp: McpError = err.into();
1360        assert!(mcp.message.contains("IO error"));
1361    }
1362
1363    #[test]
1364    fn error_into_mcp_error_not_found() {
1365        let err = FilesystemProviderError::NotFound {
1366            path: "gone.txt".to_string(),
1367        };
1368        let mcp: McpError = err.into();
1369        assert!(mcp.message.contains("gone.txt"));
1370    }
1371
1372    // ── FilesystemProvider construction and builders ───────────────
1373
1374    #[test]
1375    fn provider_new_defaults() {
1376        let root = TestDir::new("defaults");
1377        let provider = FilesystemProvider::new(root.path());
1378        assert_eq!(provider.root, root.path().to_path_buf());
1379        assert!(provider.prefix.is_none());
1380        assert!(provider.include_patterns.is_empty());
1381        assert_eq!(provider.exclude_patterns, vec![".*".to_string()]);
1382        assert!(!provider.recursive);
1383        assert_eq!(provider.max_file_size, DEFAULT_MAX_SIZE);
1384        assert!(!provider.follow_symlinks);
1385        assert!(provider.description.is_none());
1386    }
1387
1388    #[test]
1389    fn provider_with_prefix() {
1390        let provider = FilesystemProvider::new("/tmp").with_prefix("myprefix");
1391        assert_eq!(provider.prefix, Some("myprefix".to_string()));
1392    }
1393
1394    #[test]
1395    fn provider_with_patterns() {
1396        let provider = FilesystemProvider::new("/tmp").with_patterns(&["*.md", "*.txt"]);
1397        assert_eq!(provider.include_patterns, vec!["*.md", "*.txt"]);
1398    }
1399
1400    #[test]
1401    fn provider_with_exclude() {
1402        let provider = FilesystemProvider::new("/tmp").with_exclude(&["*.bak", "*.tmp"]);
1403        // Default hidden file pattern should be replaced
1404        assert_eq!(provider.exclude_patterns, vec!["*.bak", "*.tmp"]);
1405    }
1406
1407    #[test]
1408    fn provider_with_recursive() {
1409        let provider = FilesystemProvider::new("/tmp").with_recursive(true);
1410        assert!(provider.recursive);
1411    }
1412
1413    #[test]
1414    fn provider_with_max_size() {
1415        let provider = FilesystemProvider::new("/tmp").with_max_size(1024);
1416        assert_eq!(provider.max_file_size, 1024);
1417    }
1418
1419    #[test]
1420    fn provider_with_follow_symlinks() {
1421        let provider = FilesystemProvider::new("/tmp").with_follow_symlinks(true);
1422        assert!(provider.follow_symlinks);
1423    }
1424
1425    #[test]
1426    fn provider_with_description() {
1427        let provider = FilesystemProvider::new("/tmp").with_description("My files");
1428        assert_eq!(provider.description, Some("My files".to_string()));
1429    }
1430
1431    #[test]
1432    fn provider_debug() {
1433        let provider = FilesystemProvider::new("/tmp").with_prefix("dbg");
1434        let debug = format!("{:?}", provider);
1435        assert!(debug.contains("FilesystemProvider"));
1436        assert!(debug.contains("dbg"));
1437    }
1438
1439    #[test]
1440    fn provider_clone() {
1441        let provider = FilesystemProvider::new("/tmp")
1442            .with_prefix("cloned")
1443            .with_recursive(true)
1444            .with_max_size(5000);
1445        let cloned = provider.clone();
1446        assert_eq!(cloned.prefix, Some("cloned".to_string()));
1447        assert!(cloned.recursive);
1448        assert_eq!(cloned.max_file_size, 5000);
1449    }
1450
1451    // ── URI methods ───────────────────────────────────────────────
1452
1453    #[test]
1454    fn file_uri_with_prefix() {
1455        let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
1456        assert_eq!(provider.file_uri("readme.md"), "file://docs/readme.md");
1457    }
1458
1459    #[test]
1460    fn file_uri_without_prefix() {
1461        let provider = FilesystemProvider::new("/tmp");
1462        assert_eq!(provider.file_uri("readme.md"), "file://readme.md");
1463    }
1464
1465    #[test]
1466    fn uri_template_with_prefix() {
1467        let provider = FilesystemProvider::new("/tmp").with_prefix("data");
1468        assert_eq!(provider.uri_template(), "file://data/{path}");
1469    }
1470
1471    #[test]
1472    fn uri_template_without_prefix() {
1473        let provider = FilesystemProvider::new("/tmp");
1474        assert_eq!(provider.uri_template(), "file://{path}");
1475    }
1476
1477    #[test]
1478    fn path_from_uri_with_prefix() {
1479        let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
1480        assert_eq!(
1481            provider.path_from_uri("file://docs/readme.md"),
1482            Some("readme.md".to_string())
1483        );
1484    }
1485
1486    #[test]
1487    fn path_from_uri_without_prefix() {
1488        let provider = FilesystemProvider::new("/tmp");
1489        assert_eq!(
1490            provider.path_from_uri("file://readme.md"),
1491            Some("readme.md".to_string())
1492        );
1493    }
1494
1495    #[test]
1496    fn path_from_uri_wrong_prefix() {
1497        let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
1498        assert_eq!(provider.path_from_uri("file://other/readme.md"), None);
1499    }
1500
1501    #[test]
1502    fn path_from_uri_completely_wrong() {
1503        let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
1504        assert_eq!(provider.path_from_uri("http://example.com"), None);
1505    }
1506
1507    // ── matches_patterns ──────────────────────────────────────────
1508
1509    #[test]
1510    fn matches_patterns_no_includes_no_excludes() {
1511        let provider = FilesystemProvider::new("/tmp").with_exclude(&[]);
1512        assert!(provider.matches_patterns("anything.txt"));
1513        assert!(provider.matches_patterns(".hidden"));
1514    }
1515
1516    #[test]
1517    fn matches_patterns_excludes_only() {
1518        let provider = FilesystemProvider::new("/tmp"); // default excludes .*
1519        assert!(provider.matches_patterns("visible.txt"));
1520        assert!(!provider.matches_patterns(".hidden"));
1521    }
1522
1523    #[test]
1524    fn matches_patterns_includes_only() {
1525        let provider = FilesystemProvider::new("/tmp")
1526            .with_exclude(&[])
1527            .with_patterns(&["*.md"]);
1528        assert!(provider.matches_patterns("readme.md"));
1529        assert!(!provider.matches_patterns("readme.txt"));
1530    }
1531
1532    #[test]
1533    fn matches_patterns_exclude_takes_priority() {
1534        let provider = FilesystemProvider::new("/tmp")
1535            .with_patterns(&["*.md"])
1536            .with_exclude(&["secret.md"]);
1537        assert!(provider.matches_patterns("readme.md"));
1538        assert!(!provider.matches_patterns("secret.md"));
1539    }
1540
1541    // ── validate_path edge cases ──────────────────────────────────
1542
1543    #[test]
1544    fn validate_path_not_found() {
1545        let root = TestDir::new("validate-notfound");
1546        let provider = FilesystemProvider::new(root.path());
1547        let result = provider.validate_path("nonexistent.txt");
1548        assert!(matches!(
1549            result,
1550            Err(FilesystemProviderError::NotFound { .. })
1551        ));
1552    }
1553
1554    // ── read_file edge cases ──────────────────────────────────────
1555
1556    #[test]
1557    fn read_file_not_found() {
1558        let root = TestDir::new("read-notfound");
1559        let provider = FilesystemProvider::new(root.path());
1560        let result = provider.read_file("missing.txt");
1561        assert!(matches!(
1562            result,
1563            Err(FilesystemProviderError::NotFound { .. })
1564        ));
1565    }
1566
1567    // ── detect_mime_type extended ──────────────────────────────────
1568
1569    #[test]
1570    fn detect_mime_type_text_formats() {
1571        assert_eq!(detect_mime_type(Path::new("f.txt")), "text/plain");
1572        assert_eq!(detect_mime_type(Path::new("f.html")), "text/html");
1573        assert_eq!(detect_mime_type(Path::new("f.htm")), "text/html");
1574        assert_eq!(detect_mime_type(Path::new("f.css")), "text/css");
1575        assert_eq!(detect_mime_type(Path::new("f.csv")), "text/csv");
1576        assert_eq!(detect_mime_type(Path::new("f.xml")), "application/xml");
1577        assert_eq!(detect_mime_type(Path::new("f.markdown")), "text/markdown");
1578    }
1579
1580    #[test]
1581    fn detect_mime_type_programming_languages() {
1582        assert_eq!(detect_mime_type(Path::new("f.py")), "text/x-python");
1583        assert_eq!(detect_mime_type(Path::new("f.js")), "text/javascript");
1584        assert_eq!(detect_mime_type(Path::new("f.mjs")), "text/javascript");
1585        assert_eq!(detect_mime_type(Path::new("f.ts")), "text/typescript");
1586        assert_eq!(detect_mime_type(Path::new("f.mts")), "text/typescript");
1587        assert_eq!(detect_mime_type(Path::new("f.yaml")), "application/yaml");
1588        assert_eq!(detect_mime_type(Path::new("f.yml")), "application/yaml");
1589        assert_eq!(detect_mime_type(Path::new("f.toml")), "application/toml");
1590        assert_eq!(detect_mime_type(Path::new("f.sh")), "text/x-shellscript");
1591        assert_eq!(detect_mime_type(Path::new("f.bash")), "text/x-shellscript");
1592        assert_eq!(detect_mime_type(Path::new("f.c")), "text/x-c");
1593        assert_eq!(detect_mime_type(Path::new("f.cpp")), "text/x-c++");
1594        assert_eq!(detect_mime_type(Path::new("f.cc")), "text/x-c++");
1595        assert_eq!(detect_mime_type(Path::new("f.cxx")), "text/x-c++");
1596        assert_eq!(detect_mime_type(Path::new("f.h")), "text/x-c-header");
1597        assert_eq!(detect_mime_type(Path::new("f.hpp")), "text/x-c-header");
1598        assert_eq!(detect_mime_type(Path::new("f.java")), "text/x-java");
1599        assert_eq!(detect_mime_type(Path::new("f.go")), "text/x-go");
1600        assert_eq!(detect_mime_type(Path::new("f.rb")), "text/x-ruby");
1601        assert_eq!(detect_mime_type(Path::new("f.php")), "text/x-php");
1602        assert_eq!(detect_mime_type(Path::new("f.swift")), "text/x-swift");
1603        assert_eq!(detect_mime_type(Path::new("f.kt")), "text/x-kotlin");
1604        assert_eq!(detect_mime_type(Path::new("f.kts")), "text/x-kotlin");
1605        assert_eq!(detect_mime_type(Path::new("f.sql")), "text/x-sql");
1606    }
1607
1608    #[test]
1609    fn detect_mime_type_images() {
1610        assert_eq!(detect_mime_type(Path::new("f.jpg")), "image/jpeg");
1611        assert_eq!(detect_mime_type(Path::new("f.jpeg")), "image/jpeg");
1612        assert_eq!(detect_mime_type(Path::new("f.gif")), "image/gif");
1613        assert_eq!(detect_mime_type(Path::new("f.svg")), "image/svg+xml");
1614        assert_eq!(detect_mime_type(Path::new("f.webp")), "image/webp");
1615        assert_eq!(detect_mime_type(Path::new("f.ico")), "image/x-icon");
1616        assert_eq!(detect_mime_type(Path::new("f.bmp")), "image/bmp");
1617    }
1618
1619    #[test]
1620    fn detect_mime_type_binary() {
1621        assert_eq!(detect_mime_type(Path::new("f.pdf")), "application/pdf");
1622        assert_eq!(detect_mime_type(Path::new("f.zip")), "application/zip");
1623        assert_eq!(detect_mime_type(Path::new("f.gz")), "application/gzip");
1624        assert_eq!(detect_mime_type(Path::new("f.gzip")), "application/gzip");
1625        assert_eq!(detect_mime_type(Path::new("f.tar")), "application/x-tar");
1626        assert_eq!(detect_mime_type(Path::new("f.wasm")), "application/wasm");
1627        assert_eq!(
1628            detect_mime_type(Path::new("f.exe")),
1629            "application/octet-stream"
1630        );
1631        assert_eq!(
1632            detect_mime_type(Path::new("f.dll")),
1633            "application/octet-stream"
1634        );
1635        assert_eq!(
1636            detect_mime_type(Path::new("f.so")),
1637            "application/octet-stream"
1638        );
1639        assert_eq!(
1640            detect_mime_type(Path::new("f.bin")),
1641            "application/octet-stream"
1642        );
1643    }
1644
1645    #[test]
1646    fn detect_mime_type_no_extension() {
1647        assert_eq!(
1648            detect_mime_type(Path::new("Makefile")),
1649            "application/octet-stream"
1650        );
1651    }
1652
1653    // ── is_binary_mime_type extended ───────────────────────────────
1654
1655    #[test]
1656    fn is_binary_mime_type_audio_video() {
1657        assert!(is_binary_mime_type("audio/mpeg"));
1658        assert!(is_binary_mime_type("video/mp4"));
1659    }
1660
1661    #[test]
1662    fn is_binary_mime_type_archives() {
1663        assert!(is_binary_mime_type("application/zip"));
1664        assert!(is_binary_mime_type("application/gzip"));
1665        assert!(is_binary_mime_type("application/x-tar"));
1666        assert!(is_binary_mime_type("application/wasm"));
1667        assert!(is_binary_mime_type("application/octet-stream"));
1668    }
1669
1670    #[test]
1671    fn is_binary_mime_type_text_types_false() {
1672        assert!(!is_binary_mime_type("text/html"));
1673        assert!(!is_binary_mime_type("text/markdown"));
1674        assert!(!is_binary_mime_type("application/yaml"));
1675        assert!(!is_binary_mime_type("application/toml"));
1676    }
1677
1678    // ── base64_encode extended ────────────────────────────────────
1679
1680    #[test]
1681    fn base64_encode_hello_world() {
1682        assert_eq!(base64_encode(b"Hello, World!"), "SGVsbG8sIFdvcmxkIQ==");
1683    }
1684
1685    #[test]
1686    fn base64_encode_binary_sequence() {
1687        // Known value: bytes [0, 1, 2] → AAEC
1688        assert_eq!(base64_encode(&[0, 1, 2]), "AAEC");
1689    }
1690
1691    // ── glob_match edge cases ─────────────────────────────────────
1692
1693    #[test]
1694    fn glob_match_exact() {
1695        assert!(glob_match("readme.md", "readme.md"));
1696        assert!(!glob_match("readme.md", "other.md"));
1697    }
1698
1699    #[test]
1700    fn glob_match_empty_pattern_empty_path() {
1701        assert!(glob_match("", ""));
1702    }
1703
1704    #[test]
1705    fn glob_match_star_empty() {
1706        assert!(glob_match("*", ""));
1707        assert!(glob_match("*", "anything"));
1708    }
1709
1710    #[test]
1711    fn glob_match_double_star_alone() {
1712        assert!(glob_match("**", ""));
1713        assert!(glob_match("**", "a/b/c"));
1714    }
1715
1716    #[test]
1717    fn glob_match_mixed_pattern() {
1718        assert!(glob_match("src/*.rs", "src/main.rs"));
1719        assert!(!glob_match("src/*.rs", "src/sub/main.rs"));
1720        assert!(glob_match("src/**/*.rs", "src/sub/main.rs"));
1721    }
1722
1723    // ── FilesystemResourceHandler ─────────────────────────────────
1724
1725    #[test]
1726    fn handler_debug() {
1727        let root = TestDir::new("handler-debug");
1728        write_text(&root.join("a.txt"), "hello");
1729        let handler = FilesystemProvider::new(root.path()).build();
1730        let debug = format!("{:?}", handler);
1731        assert!(debug.contains("FilesystemResourceHandler"));
1732        assert!(debug.contains("provider"));
1733    }
1734
1735    #[test]
1736    fn handler_definition_without_prefix() {
1737        let root = TestDir::new("handler-no-prefix");
1738        let handler = FilesystemProvider::new(root.path()).build();
1739        let def = handler.definition();
1740        assert_eq!(def.name, "files");
1741        assert_eq!(def.uri, "file://{path}");
1742        assert!(def.description.is_none());
1743    }
1744
1745    #[test]
1746    fn handler_template_without_prefix() {
1747        let root = TestDir::new("handler-tmpl-no-prefix");
1748        let handler = FilesystemProvider::new(root.path()).build();
1749        let tmpl = handler.template().unwrap();
1750        assert_eq!(tmpl.uri_template, "file://{path}");
1751        assert_eq!(tmpl.name, "files");
1752    }
1753
1754    #[test]
1755    fn handler_cached_resources_populated() {
1756        let root = TestDir::new("handler-cached");
1757        write_text(&root.join("one.txt"), "1");
1758        write_text(&root.join("two.md"), "2");
1759        let handler = FilesystemProvider::new(root.path())
1760            .with_exclude(&[])
1761            .build();
1762        // cached_resources should have entries for the files
1763        assert!(handler.cached_resources.len() >= 2);
1764    }
1765
1766    #[test]
1767    fn handler_read_with_uri_missing_path_param() {
1768        let root = TestDir::new("handler-missing-param");
1769        let handler = FilesystemProvider::new(root.path())
1770            .with_prefix("p")
1771            .build();
1772        let ctx = McpContext::new(asupersync::Cx::for_testing(), 1);
1773        let empty_params = HashMap::new();
1774        // URI doesn't match prefix either
1775        let result = handler.read_with_uri(&ctx, "file://wrong/x", &empty_params);
1776        assert!(result.is_err());
1777    }
1778
1779    #[test]
1780    fn handler_read_binary_file_returns_blob() {
1781        let root = TestDir::new("handler-binary");
1782        write_bytes(&root.join("data.bin"), &[0xDE, 0xAD, 0xBE, 0xEF]);
1783
1784        let handler = FilesystemProvider::new(root.path())
1785            .with_exclude(&[])
1786            .build();
1787        let ctx = McpContext::new(asupersync::Cx::for_testing(), 1);
1788        let mut params = HashMap::new();
1789        params.insert("path".to_string(), "data.bin".to_string());
1790        let result = handler
1791            .read_with_uri(&ctx, "file://data.bin", &params)
1792            .unwrap();
1793        assert!(result[0].text.is_none());
1794        assert!(result[0].blob.is_some());
1795    }
1796
1797    // ── list_files with exclude ───────────────────────────────────
1798
1799    #[test]
1800    fn list_files_excludes_hidden_by_default() {
1801        let root = TestDir::new("list-hidden");
1802        write_text(&root.join("visible.txt"), "v");
1803        write_text(&root.join(".hidden"), "h");
1804
1805        let provider = FilesystemProvider::new(root.path());
1806        let files = provider.list_files().unwrap();
1807        let paths: Vec<&str> = files.iter().map(|e| e.relative_path.as_str()).collect();
1808        assert!(paths.contains(&"visible.txt"));
1809        assert!(!paths.contains(&".hidden"));
1810    }
1811
1812    #[test]
1813    fn list_files_no_patterns_includes_all() {
1814        let root = TestDir::new("list-all");
1815        write_text(&root.join("a.txt"), "a");
1816        write_text(&root.join("b.rs"), "b");
1817
1818        let provider = FilesystemProvider::new(root.path()).with_exclude(&[]);
1819        let files = provider.list_files().unwrap();
1820        assert!(files.len() >= 2);
1821    }
1822
1823    // ── DEFAULT_MAX_SIZE ──────────────────────────────────────────
1824
1825    #[test]
1826    fn default_max_size_is_10mb() {
1827        assert_eq!(DEFAULT_MAX_SIZE, 10 * 1024 * 1024);
1828    }
1829
1830    // ── FileEntry ─────────────────────────────────────────────────
1831
1832    #[test]
1833    fn file_entry_debug() {
1834        let entry = FileEntry {
1835            path: PathBuf::from("/tmp/test.txt"),
1836            relative_path: "test.txt".to_string(),
1837            size: Some(42),
1838            mime_type: "text/plain".to_string(),
1839        };
1840        let debug = format!("{:?}", entry);
1841        assert!(debug.contains("test.txt"));
1842        assert!(debug.contains("42"));
1843    }
1844
1845    // ── Provider builder chaining ─────────────────────────────────
1846
1847    #[test]
1848    fn provider_builder_chaining() {
1849        let root = TestDir::new("builder-chain");
1850        let provider = FilesystemProvider::new(root.path())
1851            .with_prefix("chain")
1852            .with_patterns(&["*.md"])
1853            .with_exclude(&["*.bak"])
1854            .with_recursive(true)
1855            .with_max_size(2048)
1856            .with_follow_symlinks(true)
1857            .with_description("Chain test");
1858
1859        assert_eq!(provider.prefix, Some("chain".to_string()));
1860        assert_eq!(provider.include_patterns, vec!["*.md"]);
1861        assert_eq!(provider.exclude_patterns, vec!["*.bak"]);
1862        assert!(provider.recursive);
1863        assert_eq!(provider.max_file_size, 2048);
1864        assert!(provider.follow_symlinks);
1865        assert_eq!(provider.description, Some("Chain test".to_string()));
1866    }
1867
1868    // ── Additional coverage ─────────────────────────────────────────
1869
1870    #[test]
1871    fn detect_mime_type_case_insensitive() {
1872        assert_eq!(detect_mime_type(Path::new("README.MD")), "text/markdown");
1873        assert_eq!(detect_mime_type(Path::new("photo.JPG")), "image/jpeg");
1874        assert_eq!(detect_mime_type(Path::new("data.JSON")), "application/json");
1875    }
1876
1877    #[test]
1878    fn glob_match_question_mark_at_end_fails_when_no_char() {
1879        assert!(!glob_match("file?", "file"));
1880        assert!(glob_match("file?", "fileA"));
1881    }
1882
1883    #[test]
1884    fn base64_encode_round_trips_with_std_decoder() {
1885        use base64::Engine as _;
1886        let data = b"The quick brown fox jumps over the lazy dog";
1887        let encoded = base64_encode(data);
1888        let decoded = base64::engine::general_purpose::STANDARD
1889            .decode(&encoded)
1890            .expect("valid base64");
1891        assert_eq!(decoded, data);
1892    }
1893
1894    #[test]
1895    fn handler_empty_root_has_no_cached_resources() {
1896        let root = TestDir::new("handler-empty");
1897        let handler = FilesystemProvider::new(root.path()).build();
1898        assert!(handler.cached_resources.is_empty());
1899    }
1900
1901    #[test]
1902    fn list_files_nonexistent_root_returns_error() {
1903        let provider = FilesystemProvider::new("/nonexistent-fastmcp-test-dir-xyz");
1904        let result = provider.list_files();
1905        assert!(result.is_err());
1906    }
1907
1908    #[test]
1909    fn read_file_path_traversal_blocked() {
1910        let root = TestDir::new("read-traversal");
1911        write_text(&root.join("safe.txt"), "ok");
1912        let provider = FilesystemProvider::new(root.path());
1913        let result = provider.read_file("../../../etc/passwd");
1914        assert!(matches!(
1915            result,
1916            Err(FilesystemProviderError::PathTraversal { .. }
1917                | FilesystemProviderError::NotFound { .. })
1918        ));
1919    }
1920}