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 implementation acquires one root directory capability, traverses each
9//! requested component relative to retained directory handles, refuses
10//! symlinks, and reads bytes only from the already-opened file handle.
11//! Those handle-relative no-follow semantics are qualified on Linux and
12//! macOS. Public [`FilesystemProvider::build`] constructs a handler there
13//! and routes listing/read I/O through the caller-owned asupersync blocking
14//! pool when one is installed. Other targets remain fail-closed.
15//!
16//! # Example
17//!
18//! ```ignore
19//! use fastmcp_server::providers::{FilesystemProvider, FilesystemProviderError};
20//!
21//! let result = FilesystemProvider::new("/data/docs")
22//!     .with_prefix("docs")
23//!     .with_patterns(&["**/*.md", "**/*.txt"])
24//!     .with_exclude(&["**/secret/**", "**/.*"])
25//!     .with_recursive(true)
26//!     .with_max_size(10 * 1024 * 1024)
27//!     .build(); // succeeds on Linux/macOS; FeatureUnavailable elsewhere
28//! ```
29
30use std::{
31    ffi::OsString,
32    io::Read,
33    path::{Component, Path, PathBuf},
34    sync::Arc,
35};
36
37use cap_fs_ext::{DirExt, FollowSymlinks, MetadataExt, OpenOptionsFollowExt, OpenOptionsSyncExt};
38#[cfg(any(target_os = "linux", target_os = "macos"))]
39use cap_std::ambient_authority;
40use cap_std::fs::{Dir, File, OpenOptions};
41
42use fastmcp_core::{McpContext, McpError, McpOutcome, McpResult, Outcome};
43use fastmcp_protocol::{FinalResourceTemplate, Resource, ResourceContent, ResourceTemplate};
44
45use crate::handler::{BoxFuture, ResourceHandler, UriParams};
46
47/// Default maximum file size (10 MB).
48const DEFAULT_MAX_SIZE: usize = 10 * 1024 * 1024;
49/// Default maximum number of directory entries inspected per listing.
50const DEFAULT_MAX_ENTRIES: usize = 10_000;
51/// Default maximum recursive directory depth beneath the provider root.
52const DEFAULT_MAX_DEPTH: usize = 64;
53/// Default maximum encoded text size of a generated directory listing (1 MB).
54const DEFAULT_MAX_LISTING_BYTES: usize = 1024 * 1024;
55/// Hard ceiling for one decoded file payload retained by the provider.
56const MAX_CONFIGURED_FILE_SIZE: usize = 10 * 1024 * 1024;
57/// Hard ceiling for one encoded binary resource payload.
58const MAX_ENCODED_BINARY_BYTES: usize = ((MAX_CONFIGURED_FILE_SIZE + 2) / 3) * 4;
59/// Maximum admitted relative-path bytes.
60const MAX_RELATIVE_PATH_BYTES: usize = 4096;
61/// Percent-encoded UTF-8 can expand every admitted byte to `%HH`.
62const MAX_ENCODED_RELATIVE_PATH_BYTES: usize = MAX_RELATIVE_PATH_BYTES * 3;
63/// Maximum admitted URI-prefix bytes.
64const MAX_URI_PREFIX_BYTES: usize = 256;
65/// Hard bounds for user-supplied glob policy and resource descriptions.
66const MAX_GLOB_PATTERNS: usize = 16;
67const MAX_GLOB_PATTERN_BYTES: usize = 128;
68const MAX_TOTAL_GLOB_PATTERN_BYTES: usize = 512;
69const MAX_GLOB_WILDCARDS_PER_PATTERN: usize = 32;
70const MAX_DESCRIPTION_BYTES: usize = 4096;
71/// Hard configuration ceilings for directory traversal and listing output.
72const MAX_CONFIGURED_ENTRIES: usize = 100_000;
73const MAX_CONFIGURED_DEPTH: usize = 256;
74const MAX_CONFIGURED_LISTING_BYTES: usize = 10 * 1024 * 1024;
75const REDACTED_RESOURCE_PATH: &str = "<resource-path>";
76/// Stable label returned by the public production-promotion gate.
77const FILESYSTEM_PROVIDER_PROMOTION_GATE: &str =
78    "non-unix targets (handle-relative no-follow filesystem I/O is unqualified)";
79const LISTING_ENTRY_PREFIX: &str = "{\"uri\":\"";
80const LISTING_ENTRY_MIME: &str = "\",\"mimeType\":\"";
81const LISTING_ENTRY_SUFFIX: &str = "\"}";
82
83const fn is_directional_format_character(character: char) -> bool {
84    matches!(
85        character,
86        '\u{061c}'
87            | '\u{200e}'
88            | '\u{200f}'
89            | '\u{202a}'..='\u{202e}'
90            | '\u{2066}'..='\u{2069}'
91    )
92}
93
94fn has_unsafe_display_characters(value: &str) -> bool {
95    value
96        .chars()
97        .any(|character| character.is_control() || is_directional_format_character(character))
98}
99
100fn path_traversal_error() -> FilesystemProviderError {
101    FilesystemProviderError::PathTraversal {
102        requested: REDACTED_RESOURCE_PATH.to_string(),
103    }
104}
105
106fn from_uri_hex(byte: u8) -> Option<u8> {
107    match byte {
108        b'0'..=b'9' => Some(byte - b'0'),
109        b'a'..=b'f' => Some(byte - b'a' + 10),
110        b'A'..=b'F' => Some(byte - b'A' + 10),
111        _ => None,
112    }
113}
114
115fn decode_resource_path(encoded: &str) -> Result<String, FilesystemProviderError> {
116    let bytes = encoded.as_bytes();
117    let mut decoded = Vec::new();
118    decoded
119        .try_reserve_exact(bytes.len().min(MAX_RELATIVE_PATH_BYTES))
120        .map_err(|error| FilesystemProviderError::Io {
121            message: format!("Cannot allocate decoded resource path: {error}"),
122        })?;
123    let mut index = 0_usize;
124    while index < bytes.len() {
125        if bytes[index] == b'%' {
126            if index.saturating_add(2) >= bytes.len() {
127                return Err(path_traversal_error());
128            }
129            let high = from_uri_hex(bytes[index + 1]).ok_or_else(path_traversal_error)?;
130            let low = from_uri_hex(bytes[index + 2]).ok_or_else(path_traversal_error)?;
131            decoded.push((high << 4) | low);
132            index += 3;
133        } else {
134            decoded.push(bytes[index]);
135            index += 1;
136        }
137        if decoded.len() > MAX_RELATIVE_PATH_BYTES {
138            return Err(path_traversal_error());
139        }
140    }
141    String::from_utf8(decoded).map_err(|_| path_traversal_error())
142}
143
144const fn resource_path_byte_may_remain_literal(byte: u8) -> bool {
145    byte.is_ascii_alphanumeric()
146        || matches!(
147            byte,
148            b'-' | b'.'
149                | b'_'
150                | b'~'
151                | b':'
152                | b'/'
153                | b'@'
154                | b'!'
155                | b'$'
156                | b'&'
157                | b'\''
158                | b'('
159                | b')'
160                | b'*'
161                | b'+'
162                | b','
163                | b';'
164                | b'='
165        )
166}
167
168fn encode_resource_path(path: &str) -> Result<String, FilesystemProviderError> {
169    const HEX: &[u8; 16] = b"0123456789ABCDEF";
170    let capacity = path
171        .len()
172        .checked_mul(3)
173        .filter(|capacity| *capacity <= MAX_ENCODED_RELATIVE_PATH_BYTES)
174        .ok_or_else(path_traversal_error)?;
175    let mut encoded = String::new();
176    encoded
177        .try_reserve_exact(capacity)
178        .map_err(|error| FilesystemProviderError::Io {
179            message: format!("Cannot allocate encoded resource path: {error}"),
180        })?;
181    for byte in path.bytes() {
182        if resource_path_byte_may_remain_literal(byte) {
183            encoded.push(char::from(byte));
184        } else {
185            encoded.push('%');
186            encoded.push(char::from(HEX[usize::from(byte >> 4)]));
187            encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
188        }
189    }
190    Ok(encoded)
191}
192
193/// Errors that can occur when using the filesystem provider.
194#[derive(Debug, Clone)]
195pub enum FilesystemProviderError {
196    /// The requested path would escape the root directory.
197    PathTraversal { requested: String },
198    /// The file exceeds the maximum allowed size.
199    TooLarge { path: String, size: u64, max: usize },
200    /// Symlink access was denied.
201    SymlinkDenied { path: String },
202    /// Multi-link files are denied because containment cannot be proven.
203    HardLinkDenied { path: String, links: u64 },
204    /// The target or production-promotion gate has not qualified the required semantics.
205    FeatureUnavailable { platform: String },
206    /// IO error occurred.
207    Io { message: String },
208    /// File not found.
209    NotFound { path: String },
210    /// A directory listing inspected more entries than configured.
211    TooManyEntries { count: usize, max: usize },
212    /// Recursive traversal exceeded the configured depth.
213    TooDeep {
214        path: String,
215        depth: usize,
216        max: usize,
217    },
218    /// A generated listing would exceed its configured byte ceiling.
219    ListingTooLarge { size: usize, max: usize },
220    /// Provider configuration is invalid or exceeds a hard safety ceiling.
221    InvalidConfiguration { field: &'static str },
222    /// The request was cancelled or its budget expired.
223    Cancelled,
224}
225
226impl std::fmt::Display for FilesystemProviderError {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        match self {
229            Self::PathTraversal { requested } => {
230                write!(f, "Path traversal attempt blocked: {requested}")
231            }
232            Self::TooLarge { path, size, max } => {
233                write!(f, "File too large: {path} ({size} bytes, max {max} bytes)")
234            }
235            Self::SymlinkDenied { path } => {
236                write!(f, "Symlink access denied: {path}")
237            }
238            Self::HardLinkDenied { path, links } => {
239                write!(f, "Hard-linked file access denied: {path} ({links} links)")
240            }
241            Self::FeatureUnavailable { platform } => {
242                write!(f, "Filesystem provider unavailable on {platform}")
243            }
244            Self::Io { message } => write!(f, "IO error: {message}"),
245            Self::NotFound { path } => write!(f, "File not found: {path}"),
246            Self::TooManyEntries { count, max } => {
247                write!(
248                    f,
249                    "Directory listing inspected too many entries: {count} > {max}"
250                )
251            }
252            Self::TooDeep { path, depth, max } => {
253                write!(
254                    f,
255                    "Directory listing exceeded depth at {path}: {depth} > {max}"
256                )
257            }
258            Self::ListingTooLarge { size, max } => {
259                write!(f, "Directory listing too large: {size} > {max} bytes")
260            }
261            Self::InvalidConfiguration { field } => {
262                write!(f, "Invalid filesystem provider configuration: {field}")
263            }
264            Self::Cancelled => write!(f, "Filesystem request cancelled"),
265        }
266    }
267}
268
269impl std::error::Error for FilesystemProviderError {}
270
271impl From<FilesystemProviderError> for McpError {
272    fn from(err: FilesystemProviderError) -> Self {
273        match err {
274            FilesystemProviderError::PathTraversal { .. } => {
275                McpError::invalid_request("Filesystem resource path was rejected")
276            }
277            FilesystemProviderError::TooLarge { size, max, .. } => McpError::invalid_request(
278                format!("Filesystem resource exceeds the size limit: {size} > {max} bytes"),
279            ),
280            FilesystemProviderError::SymlinkDenied { .. }
281            | FilesystemProviderError::HardLinkDenied { .. } => {
282                McpError::invalid_request("Filesystem resource link access was rejected")
283            }
284            FilesystemProviderError::FeatureUnavailable { .. } => {
285                McpError::internal_error(err.to_string())
286            }
287            FilesystemProviderError::Io { .. } => {
288                McpError::internal_error("Filesystem resource operation failed")
289            }
290            FilesystemProviderError::NotFound { .. } => {
291                McpError::resource_not_found(REDACTED_RESOURCE_PATH)
292            }
293            FilesystemProviderError::TooManyEntries { .. }
294            | FilesystemProviderError::ListingTooLarge { .. }
295            | FilesystemProviderError::InvalidConfiguration { .. } => {
296                McpError::invalid_request(err.to_string())
297            }
298            FilesystemProviderError::TooDeep { depth, max, .. } => McpError::invalid_request(
299                format!("Filesystem traversal exceeds the depth limit: {depth} > {max}"),
300            ),
301            FilesystemProviderError::Cancelled => McpError::request_cancelled(),
302        }
303    }
304}
305
306/// A resource provider that exposes filesystem directories.
307///
308/// Files under the configured root directory are exposed as MCP resources
309/// with local file URIs like `file:///{prefix}/{relative_path}`.
310///
311/// # Security
312///
313/// - Path traversal attempts (e.g., `../../../etc/passwd`) are blocked
314/// - Final and intermediate symlinks are always blocked
315/// - Multi-link files are blocked because another name may exist outside the root
316/// - Maximum file size limits prevent memory exhaustion
317/// - Hidden files (starting with `.`) can be excluded
318///
319/// # Example
320///
321/// ```ignore
322/// use fastmcp_server::providers::FilesystemProvider;
323///
324/// let provider = FilesystemProvider::new("/app/data")
325///     .with_prefix("data")
326///     .with_patterns(&["*.json", "*.yaml"])
327///     .with_recursive(true);
328/// ```
329#[derive(Clone)]
330pub struct FilesystemProvider {
331    /// Root path retained only for diagnostics after initial acquisition.
332    root: PathBuf,
333    /// Retained directory capability used for every enumeration and read.
334    root_directory: Result<Arc<Dir>, Arc<str>>,
335    /// URI path prefix (e.g., "docs" -> "file:///docs/...").
336    prefix: Option<String>,
337    /// Glob patterns to include (empty = all files).
338    include_patterns: Vec<String>,
339    /// Whether the include-pattern setter admitted its complete input.
340    include_patterns_valid: bool,
341    /// Glob patterns to exclude.
342    exclude_patterns: Vec<String>,
343    /// Whether the exclude-pattern setter admitted its complete input.
344    exclude_patterns_valid: bool,
345    /// Whether to traverse subdirectories.
346    recursive: bool,
347    /// Maximum file size in bytes.
348    max_file_size: usize,
349    /// Maximum directory entries inspected by one live listing.
350    max_entries: usize,
351    /// Maximum recursive depth beneath the root.
352    max_depth: usize,
353    /// Maximum UTF-8 bytes emitted by a directory listing.
354    max_listing_bytes: usize,
355    /// Description for the resource template.
356    description: Option<String>,
357}
358
359impl std::fmt::Debug for FilesystemProvider {
360    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361        formatter
362            .debug_struct("FilesystemProvider")
363            .field("root_capability_acquired", &self.root_directory.is_ok())
364            .field("prefix", &self.prefix)
365            .field("include_pattern_count", &self.include_patterns.len())
366            .field("exclude_pattern_count", &self.exclude_patterns.len())
367            .field("recursive", &self.recursive)
368            .field("max_file_size", &self.max_file_size)
369            .field("max_entries", &self.max_entries)
370            .field("max_depth", &self.max_depth)
371            .field("max_listing_bytes", &self.max_listing_bytes)
372            .field("description_configured", &self.description.is_some())
373            .finish()
374    }
375}
376
377impl FilesystemProvider {
378    /// Creates a new filesystem provider for the given root directory.
379    ///
380    /// # Arguments
381    ///
382    /// * `root` - The root directory to expose
383    ///
384    /// # Example
385    ///
386    /// ```ignore
387    /// let provider = FilesystemProvider::new("/data/docs");
388    /// ```
389    #[must_use]
390    pub fn new(root: impl AsRef<Path>) -> Self {
391        let root = root.as_ref().to_path_buf();
392        #[cfg(any(target_os = "linux", target_os = "macos"))]
393        let root_directory = Dir::open_ambient_dir(&root, ambient_authority())
394            .map(Arc::new)
395            .map_err(|error| Arc::<str>::from(error.to_string()));
396        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
397        let root_directory = Err(Arc::<str>::from(
398            "filesystem capability acquisition is unqualified on this target",
399        ));
400
401        Self {
402            root,
403            root_directory,
404            prefix: None,
405            include_patterns: Vec::new(),
406            include_patterns_valid: true,
407            // Exclude hidden entries at the root and at every recursive level.
408            // Directory exclusions are checked before descent, so a hidden
409            // directory cannot expose non-hidden descendants.
410            exclude_patterns: vec![".*".to_string(), "**/.*".to_string()],
411            exclude_patterns_valid: true,
412            recursive: false,
413            max_file_size: DEFAULT_MAX_SIZE,
414            max_entries: DEFAULT_MAX_ENTRIES,
415            max_depth: DEFAULT_MAX_DEPTH,
416            max_listing_bytes: DEFAULT_MAX_LISTING_BYTES,
417            description: None,
418        }
419    }
420
421    /// Sets the URI prefix for resources.
422    ///
423    /// Files will have URIs like `file:///{prefix}/{path}`.
424    ///
425    /// # Example
426    ///
427    /// ```ignore
428    /// let provider = FilesystemProvider::new("/data")
429    ///     .with_prefix("mydata");
430    /// // Results in URIs like file:///mydata/readme.md
431    /// ```
432    #[must_use]
433    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
434        self.prefix = Some(prefix.into());
435        self
436    }
437
438    /// Sets glob patterns to include.
439    ///
440    /// Only files matching at least one of these patterns will be exposed.
441    /// Empty patterns means all files are included.
442    ///
443    /// # Example
444    ///
445    /// ```ignore
446    /// let provider = FilesystemProvider::new("/data")
447    ///     .with_patterns(&["*.md", "*.txt", "**/*.json"]);
448    /// ```
449    #[must_use]
450    pub fn with_patterns(mut self, patterns: &[&str]) -> Self {
451        match admit_glob_patterns(patterns) {
452            Some(patterns) => {
453                self.include_patterns = patterns;
454                self.include_patterns_valid = true;
455            }
456            None => {
457                self.include_patterns.clear();
458                self.include_patterns_valid = false;
459            }
460        }
461        self
462    }
463
464    /// Sets glob patterns to exclude.
465    ///
466    /// Files matching any of these patterns will be excluded.
467    /// By default, hidden files (starting with `.`) are excluded.
468    ///
469    /// # Example
470    ///
471    /// ```ignore
472    /// let provider = FilesystemProvider::new("/data")
473    ///     .with_exclude(&["**/secret/**", "*.bak"]);
474    /// ```
475    #[must_use]
476    pub fn with_exclude(mut self, patterns: &[&str]) -> Self {
477        match admit_glob_patterns(patterns) {
478            Some(patterns) => {
479                self.exclude_patterns = patterns;
480                self.exclude_patterns_valid = true;
481            }
482            None => {
483                self.exclude_patterns.clear();
484                self.exclude_patterns_valid = false;
485            }
486        }
487        self
488    }
489
490    /// Enables or disables recursive directory traversal.
491    ///
492    /// When enabled, files in subdirectories are also exposed.
493    ///
494    /// # Example
495    ///
496    /// ```ignore
497    /// let provider = FilesystemProvider::new("/data")
498    ///     .with_recursive(true);
499    /// ```
500    #[must_use]
501    pub fn with_recursive(mut self, enabled: bool) -> Self {
502        self.recursive = enabled;
503        self
504    }
505
506    /// Sets the maximum file size in bytes.
507    ///
508    /// Files larger than this limit will return an error when read.
509    /// Default is 10 MB.
510    ///
511    /// # Example
512    ///
513    /// ```ignore
514    /// let provider = FilesystemProvider::new("/data")
515    ///     .with_max_size(5 * 1024 * 1024); // 5 MB
516    /// ```
517    #[must_use]
518    pub fn with_max_size(mut self, bytes: usize) -> Self {
519        self.max_file_size = bytes;
520        self
521    }
522
523    /// Sets the maximum number of directory entries inspected by one listing.
524    #[must_use]
525    pub fn with_max_entries(mut self, entries: usize) -> Self {
526        self.max_entries = entries;
527        self
528    }
529
530    /// Sets the maximum recursive directory depth beneath the provider root.
531    /// The root itself has depth zero.
532    #[must_use]
533    pub fn with_max_depth(mut self, depth: usize) -> Self {
534        self.max_depth = depth;
535        self
536    }
537
538    /// Sets the maximum UTF-8 byte size of a generated directory listing.
539    #[must_use]
540    pub fn with_max_listing_bytes(mut self, bytes: usize) -> Self {
541        self.max_listing_bytes = bytes;
542        self
543    }
544
545    /// Sets the description for the resource template.
546    ///
547    /// # Example
548    ///
549    /// ```ignore
550    /// let provider = FilesystemProvider::new("/data")
551    ///     .with_description("Documentation files");
552    /// ```
553    #[must_use]
554    pub fn with_description(mut self, description: impl Into<String>) -> Self {
555        self.description = Some(description.into());
556        self
557    }
558
559    /// Validates this provider and constructs a production handler.
560    ///
561    /// Linux and macOS acquire a handle-relative directory capability and
562    /// return a handler. Listing and reads run on the caller-owned asupersync
563    /// blocking pool when one is installed. Other targets remain fail-closed.
564    ///
565    /// # Errors
566    ///
567    /// Returns configuration errors, root-acquisition failures, or
568    /// [`FilesystemProviderError::FeatureUnavailable`] on unqualified targets.
569    pub fn build(self) -> Result<FilesystemResourceHandler, FilesystemProviderError> {
570        self.validate_configuration()?;
571        self.root_directory()?;
572        Ok(FilesystemResourceHandler { provider: self })
573    }
574
575    /// Constructs a handler only for inline unit testing of the quarantined
576    /// implementation. This is deliberately private and absent from
577    /// production builds, so it cannot bypass the public promotion gate.
578    #[cfg(test)]
579    fn build_for_test(self) -> Result<FilesystemResourceHandler, FilesystemProviderError> {
580        self.root_directory()?;
581        self.validate_configuration()?;
582        Ok(FilesystemResourceHandler::new(self))
583    }
584
585    fn validate_configuration(&self) -> Result<(), FilesystemProviderError> {
586        if !self.include_patterns_valid || !self.exclude_patterns_valid {
587            return Err(FilesystemProviderError::InvalidConfiguration {
588                field: "glob_patterns",
589            });
590        }
591        if self.max_file_size > MAX_CONFIGURED_FILE_SIZE {
592            return Err(FilesystemProviderError::InvalidConfiguration {
593                field: "max_file_size",
594            });
595        }
596        if self.max_entries == 0 || self.max_entries > MAX_CONFIGURED_ENTRIES {
597            return Err(FilesystemProviderError::InvalidConfiguration {
598                field: "max_entries",
599            });
600        }
601        if self.max_depth > MAX_CONFIGURED_DEPTH {
602            return Err(FilesystemProviderError::InvalidConfiguration { field: "max_depth" });
603        }
604        if self.max_listing_bytes < 2 || self.max_listing_bytes > MAX_CONFIGURED_LISTING_BYTES {
605            return Err(FilesystemProviderError::InvalidConfiguration {
606                field: "max_listing_bytes",
607            });
608        }
609        if let Some(prefix) = self.prefix.as_deref()
610            && (prefix.is_empty()
611                || prefix.len() > MAX_URI_PREFIX_BYTES
612                || prefix.bytes().any(|byte| {
613                    !byte.is_ascii_alphanumeric() && !matches!(byte, b'.' | b'_' | b'-')
614                }))
615        {
616            return Err(FilesystemProviderError::InvalidConfiguration { field: "prefix" });
617        }
618        if self.description.as_ref().is_some_and(|description| {
619            description.len() > MAX_DESCRIPTION_BYTES || has_unsafe_display_characters(description)
620        }) {
621            return Err(FilesystemProviderError::InvalidConfiguration {
622                field: "description",
623            });
624        }
625        Ok(())
626    }
627
628    /// Returns the directory capability acquired when the provider was built.
629    #[cfg(any(target_os = "linux", target_os = "macos"))]
630    fn root_directory(&self) -> Result<&Dir, FilesystemProviderError> {
631        match &self.root_directory {
632            Ok(directory) => Ok(directory),
633            Err(message) => Err(FilesystemProviderError::Io {
634                message: format!(
635                    "Cannot open filesystem provider root {}: {message}",
636                    self.root.display()
637                ),
638            }),
639        }
640    }
641
642    /// Fails closed where handle-relative no-follow semantics are unqualified.
643    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
644    fn root_directory(&self) -> Result<&Dir, FilesystemProviderError> {
645        let _ = (&self.root, &self.root_directory);
646        Err(FilesystemProviderError::FeatureUnavailable {
647            platform: std::env::consts::OS.to_string(),
648        })
649    }
650
651    /// Validates that a request consists only of normal relative components.
652    fn validate_path(&self, requested: &str) -> Result<Vec<OsString>, FilesystemProviderError> {
653        if requested.len() > MAX_RELATIVE_PATH_BYTES
654            || requested
655                .chars()
656                .any(|character| character.is_control() || matches!(character, '?' | '#'))
657        {
658            return Err(path_traversal_error());
659        }
660        let requested_path = Path::new(requested);
661        if requested_path.is_absolute() {
662            return Err(path_traversal_error());
663        }
664
665        let mut components = Vec::new();
666        for component in requested_path.components() {
667            match component {
668                Component::Normal(name) => components.push(name.to_os_string()),
669                Component::Prefix(_)
670                | Component::RootDir
671                | Component::CurDir
672                | Component::ParentDir => {
673                    return Err(path_traversal_error());
674                }
675            }
676        }
677
678        if components.is_empty() {
679            return Err(path_traversal_error());
680        }
681
682        // Policy checks and handle-relative traversal must see the same path
683        // spelling. `Path::components` normalizes repeated and trailing `/`
684        // separators; accepting that alias while glob exclusions inspect the
685        // raw request could let one file acquire two different policy names.
686        let canonical = components
687            .iter()
688            .map(|component| component.to_string_lossy())
689            .collect::<Vec<_>>()
690            .join("/");
691        if canonical != requested {
692            return Err(path_traversal_error());
693        }
694        Ok(components)
695    }
696
697    /// Returns true only for one ordinary path component.
698    fn is_normal_component(path: &Path) -> bool {
699        let mut components = path.components();
700        matches!(
701            (components.next(), components.next()),
702            (Some(Component::Normal(_)), None)
703        )
704    }
705
706    /// Maps a failed handle-relative open without using a path for access.
707    fn map_component_open_error(
708        parent: &Dir,
709        component: &Path,
710        requested: &str,
711        error: std::io::Error,
712    ) -> FilesystemProviderError {
713        if parent
714            .symlink_metadata(component)
715            .is_ok_and(|metadata| metadata.file_type().is_symlink())
716        {
717            return FilesystemProviderError::SymlinkDenied {
718                path: requested.to_string(),
719            };
720        }
721
722        if error.kind() == std::io::ErrorKind::NotFound {
723            FilesystemProviderError::NotFound {
724                path: requested.to_string(),
725            }
726        } else {
727            FilesystemProviderError::Io {
728                message: format!(
729                    "Cannot open {requested} relative to retained directory capability: {error}"
730                ),
731            }
732        }
733    }
734
735    /// Opens one file beneath the retained root, refusing links at every hop.
736    fn open_file_nofollow(&self, requested: &str) -> Result<File, FilesystemProviderError> {
737        let components = self.validate_path(requested)?;
738        let Some((final_component, parent_components)) = components.split_last() else {
739            return Err(path_traversal_error());
740        };
741        let mut current =
742            self.root_directory()?
743                .try_clone()
744                .map_err(|error| FilesystemProviderError::Io {
745                    message: format!("Cannot clone retained root directory capability: {error}"),
746                })?;
747
748        for component in parent_components {
749            let component_path = Path::new(component);
750            current = match current.open_dir_nofollow(component_path) {
751                Ok(next) => next,
752                Err(error) => {
753                    return Err(Self::map_component_open_error(
754                        &current,
755                        component_path,
756                        requested,
757                        error,
758                    ));
759                }
760            };
761        }
762
763        let final_component = Path::new(final_component);
764        let mut options = OpenOptions::new();
765        options.read(true).follow(FollowSymlinks::No).nonblock(true);
766        current
767            .open_with(final_component, &options)
768            .map_err(|error| {
769                Self::map_component_open_error(&current, final_component, requested, error)
770            })
771    }
772
773    /// Checks if a filename matches the include/exclude patterns.
774    fn matches_patterns(&self, relative_path: &str) -> bool {
775        if self.is_excluded(relative_path) {
776            return false;
777        }
778
779        // If no include patterns, include everything
780        if self.include_patterns.is_empty() {
781            return true;
782        }
783
784        // Check include patterns
785        for pattern in &self.include_patterns {
786            if glob_match(pattern, relative_path) {
787                return true;
788            }
789        }
790
791        false
792    }
793
794    /// Returns true when an exclusion pattern matches this relative path.
795    fn is_excluded(&self, relative_path: &str) -> bool {
796        self.exclude_patterns
797            .iter()
798            .any(|pattern| glob_match(pattern, relative_path))
799    }
800
801    /// Returns true when this path or any directory prefix is excluded.
802    ///
803    /// Directory walking checks exclusions before descent. Direct reads must
804    /// apply the same rule to every prefix or a caller could name a visible
805    /// file beneath an excluded directory without ever listing it.
806    fn has_excluded_ancestor(&self, relative_path: &str) -> bool {
807        let mut prefix = String::new();
808        for component in relative_path.split('/') {
809            if !prefix.is_empty() {
810                prefix.push('/');
811            }
812            prefix.push_str(component);
813            if self.is_excluded(&prefix) {
814                return true;
815            }
816        }
817        false
818    }
819
820    /// Lists files in the directory that match patterns.
821    fn list_files(&self, ctx: &McpContext) -> Result<Vec<FileEntry>, FilesystemProviderError> {
822        ctx.checkpoint()
823            .map_err(|_| FilesystemProviderError::Cancelled)?;
824        let mut entries = Vec::new();
825        let mut inspected_entries = 0_usize;
826        // Every listing is a JSON array, including the empty `[]` case.
827        let mut listing_bytes = 2_usize;
828        self.walk_directory(
829            ctx,
830            self.root_directory()?,
831            "",
832            0,
833            &mut inspected_entries,
834            &mut listing_bytes,
835            &mut entries,
836        )?;
837        entries.sort_unstable_by(|left, right| {
838            left.relative_path
839                .as_bytes()
840                .cmp(right.relative_path.as_bytes())
841        });
842        Ok(entries)
843    }
844
845    /// Recursively walks a directory collecting file entries.
846    fn walk_directory(
847        &self,
848        ctx: &McpContext,
849        current: &Dir,
850        relative_parent: &str,
851        depth: usize,
852        inspected_entries: &mut usize,
853        listing_bytes: &mut usize,
854        entries: &mut Vec<FileEntry>,
855    ) -> Result<(), FilesystemProviderError> {
856        ctx.checkpoint()
857            .map_err(|_| FilesystemProviderError::Cancelled)?;
858        let read_dir = current
859            .entries()
860            .map_err(|error| FilesystemProviderError::Io {
861                message: format!("Cannot enumerate retained directory capability: {error}"),
862            })?;
863
864        for entry_result in read_dir {
865            ctx.checkpoint()
866                .map_err(|_| FilesystemProviderError::Cancelled)?;
867            *inspected_entries = (*inspected_entries).saturating_add(1);
868            if *inspected_entries > self.max_entries {
869                return Err(FilesystemProviderError::TooManyEntries {
870                    count: *inspected_entries,
871                    max: self.max_entries,
872                });
873            }
874            let entry = entry_result.map_err(|error| FilesystemProviderError::Io {
875                message: format!("Cannot enumerate directory entry: {error}"),
876            })?;
877
878            let Ok(file_name) = entry.file_name().into_string() else {
879                // Resource URIs are UTF-8. Do not create a lossy alias for an
880                // unrepresentable directory entry.
881                continue;
882            };
883            if file_name.chars().any(char::is_control) {
884                continue;
885            }
886            let component = Path::new(&file_name);
887            if !Self::is_normal_component(component) {
888                continue;
889            }
890
891            let relative_path = if relative_parent.is_empty() {
892                file_name.clone()
893            } else {
894                format!("{relative_parent}/{file_name}")
895            };
896            if self.validate_path(&relative_path).is_err() {
897                // Skip entries that cannot be represented by the provider's
898                // canonical, bounded resource-URI policy. In particular this
899                // prevents advertising overlong descendants.
900                continue;
901            }
902            let metadata = current.symlink_metadata(component).map_err(|error| {
903                Self::map_component_open_error(current, component, &relative_path, error)
904            })?;
905            if metadata.file_type().is_symlink() {
906                continue;
907            }
908            if self.is_excluded(&relative_path) {
909                continue;
910            }
911
912            if metadata.is_dir() {
913                if self.recursive {
914                    let child_depth = depth.saturating_add(1);
915                    if child_depth > self.max_depth {
916                        return Err(FilesystemProviderError::TooDeep {
917                            path: relative_path,
918                            depth: child_depth,
919                            max: self.max_depth,
920                        });
921                    }
922                    let child = current.open_dir_nofollow(component).map_err(|error| {
923                        Self::map_component_open_error(current, component, &relative_path, error)
924                    })?;
925                    self.walk_directory(
926                        ctx,
927                        &child,
928                        &relative_path,
929                        child_depth,
930                        inspected_entries,
931                        listing_bytes,
932                        entries,
933                    )?;
934                }
935            } else if metadata.is_file() && self.matches_patterns(&relative_path) {
936                let mut options = OpenOptions::new();
937                options.read(true).follow(FollowSymlinks::No).nonblock(true);
938                let file = current.open_with(component, &options).map_err(|error| {
939                    Self::map_component_open_error(current, component, &relative_path, error)
940                })?;
941                let opened_metadata =
942                    file.metadata()
943                        .map_err(|error| FilesystemProviderError::Io {
944                            message: format!(
945                                "Cannot inspect opened resource {relative_path}: {error}"
946                            ),
947                        })?;
948                if opened_metadata.is_file() && opened_metadata.nlink() == 1 {
949                    let mime_type = detect_mime_type(Path::new(&relative_path));
950                    let uri = self.file_uri(&relative_path)?;
951                    let separator_bytes = usize::from(!entries.is_empty());
952                    let line_bytes = uri
953                        .len()
954                        .saturating_add(LISTING_ENTRY_PREFIX.len())
955                        .saturating_add(LISTING_ENTRY_MIME.len())
956                        .saturating_add(mime_type.len())
957                        .saturating_add(LISTING_ENTRY_SUFFIX.len())
958                        .saturating_add(separator_bytes);
959                    let projected_listing_bytes = (*listing_bytes).saturating_add(line_bytes);
960                    if projected_listing_bytes > self.max_listing_bytes {
961                        return Err(FilesystemProviderError::ListingTooLarge {
962                            size: projected_listing_bytes,
963                            max: self.max_listing_bytes,
964                        });
965                    }
966                    *listing_bytes = projected_listing_bytes;
967                    entries
968                        .try_reserve(1)
969                        .map_err(|error| FilesystemProviderError::Io {
970                            message: format!("Cannot allocate filesystem entry: {error}"),
971                        })?;
972                    entries.push(FileEntry {
973                        relative_path: relative_path.clone(),
974                        uri,
975                        size: Some(opened_metadata.len()),
976                        mime_type,
977                    });
978                }
979            }
980        }
981
982        Ok(())
983    }
984
985    /// Returns the canonical RFC 6570 reserved-expansion URI for a file.
986    fn file_uri(&self, relative_path: &str) -> Result<String, FilesystemProviderError> {
987        self.validate_path(relative_path)?;
988        let encoded_path = encode_resource_path(relative_path)?;
989        let base_bytes = self.prefix.as_ref().map_or(8, |prefix| {
990            "file:///"
991                .len()
992                .saturating_add(prefix.len())
993                .saturating_add(1)
994        });
995        let capacity = base_bytes
996            .checked_add(encoded_path.len())
997            .ok_or_else(path_traversal_error)?;
998        let mut uri = String::new();
999        uri.try_reserve_exact(capacity)
1000            .map_err(|error| FilesystemProviderError::Io {
1001                message: format!("Cannot allocate filesystem resource URI: {error}"),
1002            })?;
1003        uri.push_str("file:///");
1004        if let Some(prefix) = &self.prefix {
1005            uri.push_str(prefix);
1006            uri.push('/');
1007        }
1008        uri.push_str(&encoded_path);
1009        Ok(uri)
1010    }
1011
1012    /// Returns the URI template for this provider.
1013    fn uri_template(&self) -> String {
1014        match &self.prefix {
1015            Some(prefix) => format!("file:///{prefix}/{{+path}}"),
1016            None => "file:///{+path}".to_string(),
1017        }
1018    }
1019
1020    /// Extracts the relative path from a URI.
1021    fn path_from_uri(&self, uri: &str) -> Result<String, FilesystemProviderError> {
1022        let expected_prefix = match &self.prefix {
1023            Some(p) => format!("file:///{p}/"),
1024            None => "file:///".to_string(),
1025        };
1026        if uri.len()
1027            > expected_prefix
1028                .len()
1029                .saturating_add(MAX_ENCODED_RELATIVE_PATH_BYTES)
1030            || uri
1031                .chars()
1032                .any(|character| character.is_control() || matches!(character, '?' | '#'))
1033        {
1034            return Err(path_traversal_error());
1035        }
1036
1037        let encoded_path = uri
1038            .strip_prefix(&expected_prefix)
1039            .ok_or_else(path_traversal_error)?;
1040        let path = decode_resource_path(encoded_path)?;
1041        self.validate_path(&path)?;
1042        if encode_resource_path(&path)? != encoded_path {
1043            return Err(path_traversal_error());
1044        }
1045        Ok(path)
1046    }
1047
1048    /// Reads a file and returns its content.
1049    fn read_file(
1050        &self,
1051        ctx: &McpContext,
1052        relative_path: &str,
1053    ) -> Result<FileContent, FilesystemProviderError> {
1054        ctx.checkpoint()
1055            .map_err(|_| FilesystemProviderError::Cancelled)?;
1056        let requested_components = self.validate_path(relative_path)?;
1057        let parent_depth = requested_components.len().saturating_sub(1);
1058        if !self.recursive && parent_depth > 0 {
1059            return Err(FilesystemProviderError::NotFound {
1060                path: relative_path.to_string(),
1061            });
1062        }
1063        if self.recursive && parent_depth > self.max_depth {
1064            return Err(FilesystemProviderError::TooDeep {
1065                path: relative_path.to_string(),
1066                depth: parent_depth,
1067                max: self.max_depth,
1068            });
1069        }
1070        if self.has_excluded_ancestor(relative_path) || !self.matches_patterns(relative_path) {
1071            // Treat policy-hidden resources as absent so the read surface does
1072            // not disclose whether an excluded path exists.
1073            return Err(FilesystemProviderError::NotFound {
1074                path: relative_path.to_string(),
1075            });
1076        }
1077        let file = self.open_file_nofollow(relative_path)?;
1078        self.read_open_file(ctx, file, relative_path)
1079    }
1080
1081    /// Reads only from an already-opened capability file handle.
1082    fn read_open_file(
1083        &self,
1084        ctx: &McpContext,
1085        mut file: File,
1086        relative_path: &str,
1087    ) -> Result<FileContent, FilesystemProviderError> {
1088        let metadata = file
1089            .metadata()
1090            .map_err(|error| FilesystemProviderError::Io {
1091                message: format!("Cannot inspect opened resource {relative_path}: {error}"),
1092            })?;
1093
1094        if !metadata.is_file() {
1095            return Err(FilesystemProviderError::Io {
1096                message: format!("Resource is not a regular file: {relative_path}"),
1097            });
1098        }
1099
1100        let links = metadata.nlink();
1101        if links != 1 {
1102            return Err(FilesystemProviderError::HardLinkDenied {
1103                path: relative_path.to_string(),
1104                links,
1105            });
1106        }
1107
1108        if metadata.len() > self.max_file_size as u64 {
1109            return Err(FilesystemProviderError::TooLarge {
1110                path: relative_path.to_string(),
1111                size: metadata.len(),
1112                max: self.max_file_size,
1113            });
1114        }
1115
1116        let mut bytes = Vec::new();
1117        bytes
1118            .try_reserve(self.max_file_size.min(64 * 1024))
1119            .map_err(|error| FilesystemProviderError::Io {
1120                message: format!("Cannot allocate buffer for resource {relative_path}: {error}"),
1121            })?;
1122        let read_limit = self.max_file_size.saturating_add(1);
1123        let mut chunk = Vec::new();
1124        chunk
1125            .try_reserve_exact(64 * 1024)
1126            .map_err(|error| FilesystemProviderError::Io {
1127                message: format!(
1128                    "Cannot allocate read buffer for resource {relative_path}: {error}"
1129                ),
1130            })?;
1131        chunk.resize(64 * 1024, 0);
1132        while bytes.len() < read_limit {
1133            ctx.checkpoint()
1134                .map_err(|_| FilesystemProviderError::Cancelled)?;
1135            let remaining = read_limit.saturating_sub(bytes.len());
1136            let chunk_len = remaining.min(chunk.len());
1137            let read = file.read(&mut chunk[..chunk_len]).map_err(|error| {
1138                FilesystemProviderError::Io {
1139                    message: format!("Cannot read opened resource {relative_path}: {error}"),
1140                }
1141            })?;
1142            if read == 0 {
1143                break;
1144            }
1145            bytes
1146                .try_reserve(read)
1147                .map_err(|error| FilesystemProviderError::Io {
1148                    message: format!(
1149                        "Cannot grow buffer for opened resource {relative_path}: {error}"
1150                    ),
1151                })?;
1152            bytes.extend_from_slice(&chunk[..read]);
1153        }
1154        if bytes.len() > self.max_file_size {
1155            return Err(FilesystemProviderError::TooLarge {
1156                path: relative_path.to_string(),
1157                size: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
1158                max: self.max_file_size,
1159            });
1160        }
1161        ctx.checkpoint()
1162            .map_err(|_| FilesystemProviderError::Cancelled)?;
1163
1164        let mime_type = detect_mime_type(Path::new(relative_path));
1165        let content = if is_binary_mime_type(&mime_type) {
1166            FileContent::Binary(bytes)
1167        } else {
1168            let text = String::from_utf8(bytes).map_err(|error| FilesystemProviderError::Io {
1169                message: format!("Resource {relative_path} is not valid UTF-8: {error}"),
1170            })?;
1171            FileContent::Text(text)
1172        };
1173
1174        Ok(content)
1175    }
1176}
1177
1178/// A file entry from directory listing.
1179#[derive(Debug)]
1180struct FileEntry {
1181    relative_path: String,
1182    uri: String,
1183    #[allow(dead_code)]
1184    size: Option<u64>,
1185    mime_type: String,
1186}
1187
1188/// File content (text or binary).
1189enum FileContent {
1190    Text(String),
1191    Binary(Vec<u8>),
1192}
1193
1194/// Resource handler implementation for the filesystem provider.
1195#[derive(Clone)]
1196pub struct FilesystemResourceHandler {
1197    provider: FilesystemProvider,
1198}
1199
1200impl FilesystemResourceHandler {
1201    /// Creates a new handler from a provider.
1202    #[cfg(test)]
1203    fn new(provider: FilesystemProvider) -> Self {
1204        Self { provider }
1205    }
1206}
1207
1208async fn run_filesystem_blocking<T, F>(ctx: &McpContext, work: F) -> McpOutcome<T>
1209where
1210    T: Send + 'static,
1211    F: FnOnce(&McpContext) -> McpResult<T> + Clone + Send + 'static,
1212{
1213    let request_id = ctx.request_id();
1214    let runtime_cx = ctx.cx();
1215    // Retain a clone for the no-blocking-pool fallback: spawn_blocking consumes
1216    // the closure whether or not it succeeds in scheduling it.
1217    let fallback = work.clone();
1218    match runtime_cx.spawn_blocking(move |child| {
1219        let child_ctx = McpContext::new(child, request_id);
1220        work(&child_ctx)
1221    }) {
1222        Ok(mut handle) => match handle.join(runtime_cx).await {
1223            Ok(Ok(value)) => Outcome::Ok(value),
1224            Ok(Err(error)) => Outcome::Err(error),
1225            Err(asupersync::runtime::JoinError::Cancelled(_)) => {
1226                Outcome::Err(McpError::request_cancelled())
1227            }
1228            Err(error) => Outcome::Err(McpError::internal_error(error.to_string())),
1229        },
1230        Err(_) => match fallback(ctx) {
1231            Ok(value) => Outcome::Ok(value),
1232            Err(error) => Outcome::Err(error),
1233        },
1234    }
1235}
1236
1237impl ResourceHandler for FilesystemResourceHandler {
1238    fn definition(&self) -> Resource {
1239        // Return a synthetic "root" resource for the provider
1240        Resource {
1241            uri: self.provider.uri_template(),
1242            name: self
1243                .provider
1244                .prefix
1245                .clone()
1246                .unwrap_or_else(|| "files".to_string()),
1247            description: self.provider.description.clone(),
1248            mime_type: None,
1249            icon: None,
1250            version: None,
1251            tags: vec![],
1252        }
1253    }
1254
1255    fn template(&self) -> Option<ResourceTemplate> {
1256        Some(ResourceTemplate {
1257            uri_template: self.provider.uri_template(),
1258            name: self
1259                .provider
1260                .prefix
1261                .clone()
1262                .unwrap_or_else(|| "files".to_string()),
1263            description: self.provider.description.clone(),
1264            mime_type: None,
1265            icon: None,
1266            version: None,
1267            tags: vec![],
1268        })
1269    }
1270
1271    fn final_template_definition(&self) -> Option<FinalResourceTemplate> {
1272        Some(FinalResourceTemplate {
1273            uri_template: self.provider.uri_template(),
1274            name: self
1275                .provider
1276                .prefix
1277                .clone()
1278                .unwrap_or_else(|| "files".to_string()),
1279            title: None,
1280            description: self.provider.description.clone(),
1281            icons: None,
1282            mime_type: None,
1283            annotations: None,
1284            meta: None,
1285        })
1286    }
1287
1288    fn read_async<'a>(
1289        &'a self,
1290        ctx: &'a McpContext,
1291    ) -> BoxFuture<'a, McpOutcome<Vec<ResourceContent>>> {
1292        let handler = self.clone();
1293        Box::pin(
1294            async move { run_filesystem_blocking(ctx, move |child| handler.read(child)).await },
1295        )
1296    }
1297
1298    fn read_async_with_uri<'a>(
1299        &'a self,
1300        ctx: &'a McpContext,
1301        uri: &'a str,
1302        params: &'a UriParams,
1303    ) -> BoxFuture<'a, McpOutcome<Vec<ResourceContent>>> {
1304        let handler = self.clone();
1305        let uri = uri.to_owned();
1306        let params = params.clone();
1307        Box::pin(async move {
1308            run_filesystem_blocking(ctx, move |child| {
1309                handler.read_with_uri(child, &uri, &params)
1310            })
1311            .await
1312        })
1313    }
1314
1315    fn read(&self, ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
1316        // For template resources, read() without params returns the file list
1317        let files = self.provider.list_files(ctx)?;
1318        let mut listing = String::new();
1319        listing
1320            .try_reserve(self.provider.max_listing_bytes.min(64 * 1024))
1321            .map_err(|error| {
1322                McpError::internal_error(format!("Cannot allocate filesystem listing: {error}"))
1323            })?;
1324        listing.push('[');
1325        for (index, file) in files.into_iter().enumerate() {
1326            ctx.checkpoint()
1327                .map_err(|_| McpError::request_cancelled())?;
1328            let additional = usize::from(index != 0)
1329                .saturating_add(LISTING_ENTRY_PREFIX.len())
1330                .saturating_add(file.uri.len())
1331                .saturating_add(LISTING_ENTRY_MIME.len())
1332                .saturating_add(file.mime_type.len());
1333            let additional = additional.saturating_add(LISTING_ENTRY_SUFFIX.len());
1334            listing.try_reserve(additional).map_err(|error| {
1335                McpError::internal_error(format!("Cannot grow filesystem listing: {error}"))
1336            })?;
1337            if index != 0 {
1338                listing.push(',');
1339            }
1340            listing.push_str(LISTING_ENTRY_PREFIX);
1341            listing.push_str(&file.uri);
1342            listing.push_str(LISTING_ENTRY_MIME);
1343            listing.push_str(&file.mime_type);
1344            listing.push_str(LISTING_ENTRY_SUFFIX);
1345        }
1346        listing.push(']');
1347        if listing.len() > self.provider.max_listing_bytes {
1348            return Err(FilesystemProviderError::ListingTooLarge {
1349                size: listing.len(),
1350                max: self.provider.max_listing_bytes,
1351            }
1352            .into());
1353        }
1354
1355        Ok(vec![ResourceContent {
1356            uri: self.provider.uri_template(),
1357            mime_type: Some("application/json".to_string()),
1358            text: Some(listing),
1359            blob: None,
1360        }])
1361    }
1362
1363    fn read_with_uri(
1364        &self,
1365        ctx: &McpContext,
1366        uri: &str,
1367        params: &UriParams,
1368    ) -> McpResult<Vec<ResourceContent>> {
1369        // The URI is the resource identity. Template parameters may repeat
1370        // that identity, but they cannot select a different path.
1371        let relative_path = self.provider.path_from_uri(uri)?;
1372        if let Some(path) = params.get("path")
1373            && path != &relative_path
1374        {
1375            return Err(McpError::invalid_params(
1376                "URI path and template path parameter do not match",
1377            ));
1378        }
1379
1380        let content = self.provider.read_file(ctx, &relative_path)?;
1381
1382        let resource_content = match content {
1383            FileContent::Text(text) => ResourceContent {
1384                uri: uri.to_string(),
1385                mime_type: Some(detect_mime_type(Path::new(&relative_path))),
1386                text: Some(text),
1387                blob: None,
1388            },
1389            FileContent::Binary(bytes) => {
1390                let base64_str = base64_encode(&bytes)?;
1391
1392                ResourceContent {
1393                    uri: uri.to_string(),
1394                    mime_type: Some(detect_mime_type(Path::new(&relative_path))),
1395                    text: None,
1396                    blob: Some(base64_str),
1397                }
1398            }
1399        };
1400
1401        Ok(vec![resource_content])
1402    }
1403}
1404
1405impl std::fmt::Debug for FilesystemResourceHandler {
1406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1407        f.debug_struct("FilesystemResourceHandler")
1408            .field("provider", &self.provider)
1409            .finish()
1410    }
1411}
1412
1413/// Detects the MIME type for a file based on its extension.
1414fn detect_mime_type(path: &Path) -> String {
1415    let extension = path
1416        .extension()
1417        .and_then(|e| e.to_str())
1418        .map(str::to_lowercase);
1419
1420    match extension.as_deref() {
1421        // Text formats
1422        Some("txt") => "text/plain",
1423        Some("md" | "markdown") => "text/markdown",
1424        Some("html" | "htm") => "text/html",
1425        Some("css") => "text/css",
1426        Some("csv") => "text/csv",
1427        Some("xml") => "application/xml",
1428
1429        // Programming languages
1430        Some("rs") => "text/x-rust",
1431        Some("py") => "text/x-python",
1432        Some("js" | "mjs") => "text/javascript",
1433        Some("ts" | "mts") => "text/typescript",
1434        Some("json") => "application/json",
1435        Some("yaml" | "yml") => "application/yaml",
1436        Some("toml") => "application/toml",
1437        Some("sh" | "bash") => "text/x-shellscript",
1438        Some("c") => "text/x-c",
1439        Some("cpp" | "cc" | "cxx") => "text/x-c++",
1440        Some("h" | "hpp") => "text/x-c-header",
1441        Some("java") => "text/x-java",
1442        Some("go") => "text/x-go",
1443        Some("rb") => "text/x-ruby",
1444        Some("php") => "text/x-php",
1445        Some("swift") => "text/x-swift",
1446        Some("kt" | "kts") => "text/x-kotlin",
1447        Some("sql") => "text/x-sql",
1448
1449        // Images
1450        Some("png") => "image/png",
1451        Some("jpg" | "jpeg") => "image/jpeg",
1452        Some("gif") => "image/gif",
1453        Some("svg") => "image/svg+xml",
1454        Some("webp") => "image/webp",
1455        Some("ico") => "image/x-icon",
1456        Some("bmp") => "image/bmp",
1457
1458        // Binary/Documents
1459        Some("pdf") => "application/pdf",
1460        Some("zip") => "application/zip",
1461        Some("gz" | "gzip") => "application/gzip",
1462        Some("tar") => "application/x-tar",
1463        Some("wasm") => "application/wasm",
1464        Some("exe") => "application/octet-stream",
1465        Some("dll") => "application/octet-stream",
1466        Some("so") => "application/octet-stream",
1467        Some("bin") => "application/octet-stream",
1468
1469        // Default
1470        _ => "application/octet-stream",
1471    }
1472    .to_string()
1473}
1474
1475/// Checks if a MIME type represents binary content.
1476fn is_binary_mime_type(mime_type: &str) -> bool {
1477    mime_type.starts_with("image/")
1478        || mime_type.starts_with("audio/")
1479        || mime_type.starts_with("video/")
1480        || mime_type == "application/octet-stream"
1481        || mime_type == "application/pdf"
1482        || mime_type == "application/zip"
1483        || mime_type == "application/gzip"
1484        || mime_type == "application/x-tar"
1485        || mime_type == "application/wasm"
1486}
1487
1488/// Standard base64 alphabet.
1489const BASE64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1490
1491/// Encodes bytes to standard base64.
1492fn base64_encode(data: &[u8]) -> Result<String, FilesystemProviderError> {
1493    if data.len() > MAX_CONFIGURED_FILE_SIZE {
1494        return Err(FilesystemProviderError::TooLarge {
1495            path: "<binary-resource>".to_string(),
1496            size: u64::try_from(data.len()).unwrap_or(u64::MAX),
1497            max: MAX_CONFIGURED_FILE_SIZE,
1498        });
1499    }
1500    let encoded_len = data
1501        .len()
1502        .checked_add(2)
1503        .map(|length| length / 3)
1504        .and_then(|length| length.checked_mul(4))
1505        .filter(|length| *length <= MAX_ENCODED_BINARY_BYTES)
1506        .ok_or(FilesystemProviderError::TooLarge {
1507            path: "<binary-resource>".to_string(),
1508            size: u64::try_from(data.len()).unwrap_or(u64::MAX),
1509            max: MAX_CONFIGURED_FILE_SIZE,
1510        })?;
1511    let mut result = String::new();
1512    result
1513        .try_reserve_exact(encoded_len)
1514        .map_err(|error| FilesystemProviderError::Io {
1515            message: format!("Cannot allocate encoded binary resource: {error}"),
1516        })?;
1517
1518    for chunk in data.chunks(3) {
1519        let b0 = chunk[0] as usize;
1520        let b1 = chunk.get(1).copied().unwrap_or(0) as usize;
1521        let b2 = chunk.get(2).copied().unwrap_or(0) as usize;
1522
1523        let combined = (b0 << 16) | (b1 << 8) | b2;
1524
1525        result.push(BASE64_CHARS[(combined >> 18) & 0x3F] as char);
1526        result.push(BASE64_CHARS[(combined >> 12) & 0x3F] as char);
1527
1528        if chunk.len() > 1 {
1529            result.push(BASE64_CHARS[(combined >> 6) & 0x3F] as char);
1530        } else {
1531            result.push('=');
1532        }
1533
1534        if chunk.len() > 2 {
1535            result.push(BASE64_CHARS[combined & 0x3F] as char);
1536        } else {
1537            result.push('=');
1538        }
1539    }
1540
1541    Ok(result)
1542}
1543
1544#[derive(Clone, Copy)]
1545enum GlobToken {
1546    Literal(char),
1547    AnyCharacter,
1548    AnySegmentSequence,
1549    AnyRecursivePrefix,
1550    AnyRecursiveSequence,
1551}
1552
1553fn compile_glob_tokens(pattern: &str) -> Option<Vec<GlobToken>> {
1554    if pattern.len() > MAX_GLOB_PATTERN_BYTES || pattern.chars().any(char::is_control) {
1555        return None;
1556    }
1557    let character_count = pattern.chars().count();
1558    let mut characters = Vec::new();
1559    characters.try_reserve_exact(character_count).ok()?;
1560    characters.extend(pattern.chars());
1561
1562    let mut tokens = Vec::new();
1563    tokens.try_reserve_exact(character_count).ok()?;
1564    let mut index = 0_usize;
1565    let mut wildcard_count = 0_usize;
1566    while index < characters.len() {
1567        match characters[index] {
1568            '?' => {
1569                wildcard_count = wildcard_count.checked_add(1)?;
1570                tokens.push(GlobToken::AnyCharacter);
1571                index += 1;
1572            }
1573            '*' if characters.get(index + 1) == Some(&'*') => {
1574                wildcard_count = wildcard_count.checked_add(1)?;
1575                if (index != 0 && characters[index - 1] != '/')
1576                    || characters
1577                        .get(index + 2)
1578                        .is_some_and(|character| *character != '/')
1579                {
1580                    return None;
1581                }
1582                let has_separator = characters.get(index + 2) == Some(&'/');
1583                tokens.push(if has_separator {
1584                    GlobToken::AnyRecursivePrefix
1585                } else {
1586                    GlobToken::AnyRecursiveSequence
1587                });
1588                index += 2;
1589                if has_separator {
1590                    index += 1;
1591                }
1592            }
1593            '*' => {
1594                wildcard_count = wildcard_count.checked_add(1)?;
1595                tokens.push(GlobToken::AnySegmentSequence);
1596                index += 1;
1597            }
1598            literal => {
1599                tokens.push(GlobToken::Literal(literal));
1600                index += 1;
1601            }
1602        }
1603        if wildcard_count > MAX_GLOB_WILDCARDS_PER_PATTERN {
1604            return None;
1605        }
1606    }
1607    Some(tokens)
1608}
1609
1610fn admit_glob_patterns(patterns: &[&str]) -> Option<Vec<String>> {
1611    if patterns.len() > MAX_GLOB_PATTERNS {
1612        return None;
1613    }
1614    let mut admitted = Vec::new();
1615    admitted.try_reserve_exact(patterns.len()).ok()?;
1616    let mut total_bytes = 0_usize;
1617    for pattern in patterns {
1618        total_bytes = total_bytes.checked_add(pattern.len())?;
1619        if total_bytes > MAX_TOTAL_GLOB_PATTERN_BYTES || compile_glob_tokens(pattern).is_none() {
1620            return None;
1621        }
1622        let mut owned = String::new();
1623        owned.try_reserve_exact(pattern.len()).ok()?;
1624        owned.push_str(pattern);
1625        admitted.push(owned);
1626    }
1627    Some(admitted)
1628}
1629
1630/// Bounded, polynomial-time glob matching.
1631///
1632/// `*` and `?` do not cross `/`; a whole-component `**` does. The dynamic
1633/// program replaces the prior recursive backtracking implementation so a
1634/// hostile pattern/path pair cannot trigger exponential work.
1635fn glob_match(pattern: &str, path: &str) -> bool {
1636    let Some(tokens) = compile_glob_tokens(pattern) else {
1637        return false;
1638    };
1639    let path_character_count = path.chars().count();
1640    let mut path_characters = Vec::new();
1641    if path_characters
1642        .try_reserve_exact(path_character_count)
1643        .is_err()
1644    {
1645        return false;
1646    }
1647    path_characters.extend(path.chars());
1648    let row_len = match path_characters.len().checked_add(1) {
1649        Some(row_len) => row_len,
1650        None => return false,
1651    };
1652    let mut previous = Vec::new();
1653    let mut current = Vec::new();
1654    if previous.try_reserve_exact(row_len).is_err() || current.try_reserve_exact(row_len).is_err() {
1655        return false;
1656    }
1657    previous.resize(row_len, false);
1658    current.resize(row_len, false);
1659    previous[0] = true;
1660
1661    for token in tokens {
1662        match token {
1663            GlobToken::Literal(literal) => {
1664                for index in 1..row_len {
1665                    current[index] = previous[index - 1] && path_characters[index - 1] == literal;
1666                }
1667            }
1668            GlobToken::AnyCharacter => {
1669                for index in 1..row_len {
1670                    current[index] = previous[index - 1] && path_characters[index - 1] != '/';
1671                }
1672            }
1673            GlobToken::AnySegmentSequence => {
1674                current[0] = previous[0];
1675                for index in 1..row_len {
1676                    current[index] = previous[index]
1677                        || (current[index - 1] && path_characters[index - 1] != '/');
1678                }
1679            }
1680            GlobToken::AnyRecursiveSequence => {
1681                current[0] = previous[0];
1682                for index in 1..row_len {
1683                    current[index] = previous[index] || current[index - 1];
1684                }
1685            }
1686            GlobToken::AnyRecursivePrefix => {
1687                current[0] = previous[0];
1688                let mut reachable = previous[0];
1689                for index in 1..row_len {
1690                    reachable |= previous[index];
1691                    current[index] =
1692                        previous[index] || (reachable && path_characters[index - 1] == '/');
1693                }
1694            }
1695        }
1696        std::mem::swap(&mut previous, &mut current);
1697        current.fill(false);
1698    }
1699    previous[path_characters.len()]
1700}
1701
1702#[cfg(test)]
1703mod tests {
1704    use super::*;
1705    use std::collections::HashMap;
1706    use std::path::Path;
1707    use std::sync::atomic::{AtomicU64, Ordering};
1708    use std::time::{SystemTime, UNIX_EPOCH};
1709
1710    static TEST_DIR_SEQ: AtomicU64 = AtomicU64::new(1);
1711
1712    struct TestDir {
1713        path: PathBuf,
1714    }
1715
1716    impl TestDir {
1717        fn new(label: &str) -> Self {
1718            let mut path = std::env::temp_dir();
1719            let seq = TEST_DIR_SEQ.fetch_add(1, Ordering::SeqCst);
1720            let nanos = SystemTime::now()
1721                .duration_since(UNIX_EPOCH)
1722                .expect("system clock before epoch")
1723                .as_nanos();
1724            path.push(format!(
1725                "fastmcp-fs-tests-{label}-{}-{seq}-{nanos}",
1726                std::process::id()
1727            ));
1728            std::fs::create_dir_all(&path).expect("create temp test dir");
1729            Self { path }
1730        }
1731
1732        fn join(&self, relative: &str) -> PathBuf {
1733            self.path.join(relative)
1734        }
1735
1736        fn path(&self) -> &Path {
1737            &self.path
1738        }
1739    }
1740
1741    impl Drop for TestDir {
1742        fn drop(&mut self) {
1743            let _ = std::fs::remove_dir_all(&self.path);
1744        }
1745    }
1746
1747    fn write_text(path: &Path, content: &str) {
1748        if let Some(parent) = path.parent() {
1749            std::fs::create_dir_all(parent).expect("create parent dir");
1750        }
1751        std::fs::write(path, content).expect("write text file");
1752    }
1753
1754    fn write_bytes(path: &Path, bytes: &[u8]) {
1755        if let Some(parent) = path.parent() {
1756            std::fs::create_dir_all(parent).expect("create parent dir");
1757        }
1758        std::fs::write(path, bytes).expect("write binary file");
1759    }
1760
1761    fn test_context() -> McpContext {
1762        McpContext::new(asupersync::Cx::for_testing(), 1)
1763    }
1764
1765    #[test]
1766    #[cfg(any(target_os = "linux", target_os = "macos"))]
1767    #[test]
1768    fn public_build_constructs_a_handler_on_qualified_targets() {
1769        let root = TestDir::new("public-promotion-gate");
1770        write_text(&root.join("ordinary.txt"), "ordinary");
1771
1772        let handler = FilesystemProvider::new(root.path())
1773            .build()
1774            .expect("Linux and macOS construct a production filesystem handler");
1775        let listing = handler
1776            .read(&test_context())
1777            .expect("constructed handler can list the root");
1778        assert_eq!(listing.len(), 1);
1779    }
1780
1781    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1782    #[test]
1783    fn public_build_fails_closed_on_unqualified_targets() {
1784        let root = TestDir::new("public-promotion-gate");
1785        write_text(&root.join("ordinary.txt"), "ordinary");
1786
1787        let error = FilesystemProvider::new(root.path())
1788            .build()
1789            .expect_err("unqualified targets remain fail-closed");
1790
1791        assert!(matches!(
1792            error,
1793            FilesystemProviderError::FeatureUnavailable { platform }
1794                if platform == FILESYSTEM_PROVIDER_PROMOTION_GATE
1795                || platform == std::env::consts::OS
1796        ));
1797    }
1798
1799    #[cfg(any(target_os = "linux", target_os = "macos"))]
1800    #[test]
1801    fn public_build_does_not_probe_an_unusable_root() {
1802        let root = TestDir::new("missing-root");
1803        let missing = root.join("does-not-exist");
1804
1805        let error = FilesystemProvider::new(missing)
1806            .build()
1807            .expect_err("a missing root cannot construct a handler");
1808
1809        assert!(matches!(error, FilesystemProviderError::Io { .. }));
1810    }
1811
1812    #[cfg(any(target_os = "linux", target_os = "macos"))]
1813    #[test]
1814    fn build_rejects_configuration_outside_hard_safety_bounds() {
1815        let root = TestDir::new("invalid-config");
1816
1817        for provider in [
1818            FilesystemProvider::new(root.path()).with_max_size(MAX_CONFIGURED_FILE_SIZE + 1),
1819            FilesystemProvider::new(root.path()).with_max_entries(0),
1820            FilesystemProvider::new(root.path()).with_max_entries(MAX_CONFIGURED_ENTRIES + 1),
1821            FilesystemProvider::new(root.path()).with_max_depth(MAX_CONFIGURED_DEPTH + 1),
1822            FilesystemProvider::new(root.path()).with_max_listing_bytes(0),
1823            FilesystemProvider::new(root.path()).with_max_listing_bytes(1),
1824            FilesystemProvider::new(root.path())
1825                .with_max_listing_bytes(MAX_CONFIGURED_LISTING_BYTES + 1),
1826        ] {
1827            assert!(matches!(
1828                provider.build(),
1829                Err(FilesystemProviderError::InvalidConfiguration { .. })
1830            ));
1831        }
1832
1833        for prefix in ["", "contains/slash", "contains?query", "contains#fragment"] {
1834            assert!(matches!(
1835                FilesystemProvider::new(root.path())
1836                    .with_prefix(prefix)
1837                    .build(),
1838                Err(FilesystemProviderError::InvalidConfiguration { field: "prefix" })
1839            ));
1840        }
1841
1842        let long_pattern = "x".repeat(MAX_GLOB_PATTERN_BYTES + 1);
1843        let too_many_patterns = vec!["*.txt"; MAX_GLOB_PATTERNS + 1];
1844        for provider in [
1845            FilesystemProvider::new(root.path()).with_patterns(&[long_pattern.as_str()]),
1846            FilesystemProvider::new(root.path()).with_patterns(&too_many_patterns),
1847            FilesystemProvider::new(root.path()).with_patterns(&["prefix**suffix"]),
1848            FilesystemProvider::new(root.path())
1849                .with_description("x".repeat(MAX_DESCRIPTION_BYTES + 1)),
1850            FilesystemProvider::new(root.path()).with_description("forged\nlabel"),
1851            FilesystemProvider::new(root.path()).with_description("directional\u{202e}label"),
1852        ] {
1853            assert!(matches!(
1854                provider.build(),
1855                Err(FilesystemProviderError::InvalidConfiguration { .. })
1856            ));
1857        }
1858    }
1859
1860    #[test]
1861    fn test_glob_match_star() {
1862        assert!(glob_match("*.md", "readme.md"));
1863        assert!(glob_match("*.md", "CHANGELOG.md"));
1864        assert!(!glob_match("*.md", "readme.txt"));
1865        assert!(!glob_match("*.md", "dir/readme.md")); // * doesn't match /
1866    }
1867
1868    #[test]
1869    fn test_glob_match_double_star() {
1870        assert!(glob_match("**/*.md", "readme.md"));
1871        assert!(glob_match("**/*.md", "docs/readme.md"));
1872        assert!(glob_match("**/*.md", "docs/api/readme.md"));
1873        assert!(!glob_match("**/*.md", "readme.txt"));
1874    }
1875
1876    #[test]
1877    fn test_glob_match_question() {
1878        assert!(glob_match("file?.txt", "file1.txt"));
1879        assert!(glob_match("file?.txt", "fileA.txt"));
1880        assert!(!glob_match("file?.txt", "file12.txt"));
1881    }
1882
1883    #[test]
1884    fn test_glob_match_hidden() {
1885        assert!(glob_match(".*", ".hidden"));
1886        assert!(glob_match(".*", ".gitignore"));
1887        assert!(!glob_match(".*", "visible"));
1888        assert!(glob_match("**/.*", "nested/.hidden"));
1889        assert!(!glob_match("**/.*", "nested/readme.md"));
1890    }
1891
1892    #[test]
1893    fn glob_match_rejects_ambiguous_recursive_wildcards() {
1894        assert!(!glob_match("prefix**suffix", "prefix-any-suffix"));
1895        assert!(!glob_match("***", "anything"));
1896    }
1897
1898    #[test]
1899    fn test_glob_match_uses_utf8_character_boundaries() {
1900        assert!(glob_match("*.md", "résumé.md"));
1901        assert!(glob_match("**/*.md", "資料/概要.md"));
1902        assert!(glob_match("file?.txt", "file界.txt"));
1903        assert!(!glob_match("*.txt", "資料/概要.txt"));
1904    }
1905
1906    #[test]
1907    fn test_detect_mime_type() {
1908        assert_eq!(detect_mime_type(Path::new("file.md")), "text/markdown");
1909        assert_eq!(detect_mime_type(Path::new("file.json")), "application/json");
1910        assert_eq!(detect_mime_type(Path::new("file.rs")), "text/x-rust");
1911        assert_eq!(detect_mime_type(Path::new("file.png")), "image/png");
1912        assert_eq!(
1913            detect_mime_type(Path::new("file.unknown")),
1914            "application/octet-stream"
1915        );
1916    }
1917
1918    #[test]
1919    fn test_is_binary_mime_type() {
1920        assert!(is_binary_mime_type("image/png"));
1921        assert!(is_binary_mime_type("application/pdf"));
1922        assert!(!is_binary_mime_type("text/plain"));
1923        assert!(!is_binary_mime_type("application/json"));
1924    }
1925
1926    #[cfg(any(target_os = "linux", target_os = "macos"))]
1927    #[test]
1928    fn test_provider_list_files_respects_patterns_and_recursion() {
1929        let root = TestDir::new("list-recursive");
1930        write_text(&root.join("README.md"), "# readme");
1931        write_text(&root.join("notes.txt"), "notes");
1932        write_text(&root.join("nested/info.md"), "# nested");
1933        write_text(&root.join("nested/code.rs"), "fn main() {}");
1934
1935        let provider = FilesystemProvider::new(root.path())
1936            .with_patterns(&["**/*.md", "**/*.txt"])
1937            .with_recursive(true);
1938
1939        let files = provider.list_files(&test_context()).expect("list files");
1940        let mut relative_paths = files
1941            .iter()
1942            .map(|entry| entry.relative_path.as_str())
1943            .collect::<Vec<_>>();
1944        relative_paths.sort_unstable();
1945
1946        assert_eq!(
1947            relative_paths,
1948            vec!["README.md", "nested/info.md", "notes.txt"]
1949        );
1950    }
1951
1952    #[cfg(any(target_os = "linux", target_os = "macos"))]
1953    #[test]
1954    fn test_provider_list_files_non_recursive_skips_subdirectories() {
1955        let root = TestDir::new("list-flat");
1956        write_text(&root.join("root.md"), "root");
1957        write_text(&root.join("nested/child.md"), "child");
1958
1959        let provider = FilesystemProvider::new(root.path())
1960            .with_patterns(&["**/*.md"])
1961            .with_recursive(false);
1962
1963        let files = provider.list_files(&test_context()).expect("list files");
1964        let relative_paths = files
1965            .iter()
1966            .map(|entry| entry.relative_path.as_str())
1967            .collect::<Vec<_>>();
1968        assert_eq!(relative_paths, vec!["root.md"]);
1969    }
1970
1971    #[test]
1972    fn test_validate_path_rejects_absolute_and_parent_escape() {
1973        let root = TestDir::new("validate-path");
1974        write_text(&root.join("safe.txt"), "safe");
1975
1976        let outside_file = root
1977            .path()
1978            .parent()
1979            .expect("temp dir has parent")
1980            .join("outside-fastmcp-provider-test.txt");
1981        write_text(&outside_file, "outside");
1982
1983        let provider = FilesystemProvider::new(root.path());
1984
1985        // Use a platform-appropriate absolute path: on Windows
1986        // `/tmp/absolute.txt` is *not* absolute (no drive letter), so the
1987        // hard-coded Unix string fails to exercise the absolute-path
1988        // rejection branch and the assertion below misfires.
1989        let absolute_input = if cfg!(windows) {
1990            r"C:\Windows\System32\absolute.txt"
1991        } else {
1992            "/tmp/absolute.txt"
1993        };
1994        let absolute = provider.validate_path(absolute_input);
1995        assert!(matches!(
1996            absolute,
1997            Err(FilesystemProviderError::PathTraversal { .. })
1998        ));
1999
2000        let escape = provider.validate_path("../outside-fastmcp-provider-test.txt");
2001        assert!(matches!(
2002            escape,
2003            Err(FilesystemProviderError::PathTraversal { .. })
2004        ));
2005
2006        for aliased in ["safe.txt/", "nested//safe.txt"] {
2007            assert!(matches!(
2008                provider.validate_path(aliased),
2009                Err(FilesystemProviderError::PathTraversal { .. })
2010            ));
2011        }
2012
2013        let ok = provider.validate_path("safe.txt").expect("safe path");
2014        assert_eq!(ok, vec![OsString::from("safe.txt")]);
2015    }
2016
2017    #[cfg(any(target_os = "linux", target_os = "macos"))]
2018    #[test]
2019    fn test_read_file_text_binary_and_size_limit() {
2020        let root = TestDir::new("read-file");
2021        write_text(&root.join("doc.txt"), "hello world");
2022        write_bytes(&root.join("blob.bin"), &[0x00, 0x7F, 0xAA, 0x55]);
2023        write_bytes(&root.join("large.bin"), &[0u8; 8]);
2024
2025        let provider = FilesystemProvider::new(root.path()).with_max_size(32);
2026
2027        let text = provider
2028            .read_file(&test_context(), "doc.txt")
2029            .expect("read text");
2030        assert!(matches!(text, FileContent::Text(ref t) if t == "hello world"));
2031
2032        let binary = provider
2033            .read_file(&test_context(), "blob.bin")
2034            .expect("read binary");
2035        assert!(matches!(binary, FileContent::Binary(ref b) if b == &[0x00, 0x7F, 0xAA, 0x55]));
2036
2037        let size_limited = FilesystemProvider::new(root.path()).with_max_size(4);
2038        let too_large = size_limited.read_file(&test_context(), "large.bin");
2039        assert!(matches!(
2040            too_large,
2041            Err(FilesystemProviderError::TooLarge { path, size: 8, max: 4 })
2042                if path == "large.bin"
2043        ));
2044    }
2045
2046    #[cfg(any(target_os = "linux", target_os = "macos"))]
2047    #[test]
2048    fn test_handler_read_listing_and_read_with_uri() {
2049        let root = TestDir::new("handler-read");
2050        write_text(&root.join("docs/readme.md"), "# docs");
2051
2052        let handler = FilesystemProvider::new(root.path())
2053            .with_prefix("docs")
2054            .with_patterns(&["**/*.md"])
2055            .with_recursive(true)
2056            .with_description("Documentation")
2057            .build_for_test()
2058            .expect("valid filesystem provider");
2059
2060        let ctx = McpContext::new(asupersync::Cx::for_testing(), 1);
2061
2062        let definition = handler.definition();
2063        assert_eq!(definition.uri, "file:///docs/{+path}");
2064        assert_eq!(definition.name, "docs");
2065        assert_eq!(definition.description.as_deref(), Some("Documentation"));
2066
2067        let template = handler.template().expect("resource template");
2068        assert_eq!(template.uri_template, "file:///docs/{+path}");
2069
2070        let listing = handler.read(&ctx).expect("read listing");
2071        assert_eq!(listing[0].mime_type.as_deref(), Some("application/json"));
2072        let listing_text = listing[0].text.as_deref().expect("listing text");
2073        let listing_json: serde_json::Value =
2074            serde_json::from_str(listing_text).expect("valid JSON listing");
2075        assert_eq!(
2076            listing_json,
2077            serde_json::json!([{
2078                "uri": "file:///docs/docs/readme.md",
2079                "mimeType": "text/markdown"
2080            }])
2081        );
2082
2083        let mut params = HashMap::new();
2084        params.insert("path".to_string(), "docs/readme.md".to_string());
2085        let content = handler
2086            .read_with_uri(&ctx, "file:///docs/docs/readme.md", &params)
2087            .expect("read with params");
2088        assert_eq!(content[0].text.as_deref(), Some("# docs"));
2089
2090        let empty_params = HashMap::new();
2091        let content_from_uri = handler
2092            .read_with_uri(&ctx, "file:///docs/docs/readme.md", &empty_params)
2093            .expect("read using uri path");
2094        assert_eq!(content_from_uri[0].text.as_deref(), Some("# docs"));
2095
2096        let invalid = handler.read_with_uri(&ctx, "file:///wrong-prefix/readme.md", &empty_params);
2097        assert!(invalid.is_err());
2098
2099        params.insert("path".to_string(), "different.md".to_string());
2100        let mismatch = handler.read_with_uri(&ctx, "file:///docs/docs/readme.md", &params);
2101        assert_eq!(
2102            mismatch
2103                .expect_err("URI and template parameter must identify the same resource")
2104                .code,
2105            fastmcp_core::McpErrorCode::InvalidParams
2106        );
2107    }
2108
2109    #[cfg(any(target_os = "linux", target_os = "macos"))]
2110    #[test]
2111    fn listing_is_deterministic_and_omits_control_bearing_names() {
2112        let root = TestDir::new("deterministic-listing");
2113        write_text(&root.join("b.txt"), "b");
2114        write_text(&root.join("a.txt"), "a");
2115        write_text(&root.join("forged\nentry.txt"), "hidden from URI surface");
2116        write_text(&root.join("directional\u{202e}.txt"), "encoded safely");
2117
2118        let handler = FilesystemProvider::new(root.path())
2119            .with_exclude(&[])
2120            .build_for_test()
2121            .expect("valid filesystem provider");
2122        let listing = handler.read(&test_context()).expect("bounded listing");
2123
2124        let text = listing[0].text.as_deref().expect("JSON listing");
2125        assert_eq!(
2126            text,
2127            "[{\"uri\":\"file:///a.txt\",\"mimeType\":\"text/plain\"},{\"uri\":\"file:///b.txt\",\"mimeType\":\"text/plain\"},{\"uri\":\"file:///directional%E2%80%AE.txt\",\"mimeType\":\"text/plain\"}]"
2128        );
2129        assert!(!text.contains('\u{202e}'));
2130        serde_json::from_str::<serde_json::Value>(text).expect("listing must remain valid JSON");
2131    }
2132
2133    #[cfg(any(target_os = "linux", target_os = "macos"))]
2134    #[test]
2135    fn direct_read_of_fifo_fails_without_waiting_for_a_writer() {
2136        let root = TestDir::new("fifo");
2137        let fifo = root.join("pipe.bin");
2138        let status = std::process::Command::new("mkfifo")
2139            .arg(&fifo)
2140            .status()
2141            .expect("invoke mkfifo");
2142        assert!(status.success(), "mkfifo must create the test fixture");
2143
2144        let provider = FilesystemProvider::new(root.path()).with_exclude(&[]);
2145        let (sender, receiver) = std::sync::mpsc::sync_channel(1);
2146        std::thread::spawn(move || {
2147            let _ = sender.send(provider.read_file(&test_context(), "pipe.bin"));
2148        });
2149        let result = receiver
2150            .recv_timeout(std::time::Duration::from_secs(2))
2151            .expect("nonblocking FIFO read must complete promptly");
2152
2153        assert!(matches!(result, Err(FilesystemProviderError::Io { .. })));
2154    }
2155
2156    #[cfg(any(target_os = "linux", target_os = "macos"))]
2157    #[test]
2158    fn direct_reads_cannot_bypass_include_or_exclude_policy() {
2159        let root = TestDir::new("direct-policy");
2160        write_text(&root.join("visible.md"), "visible");
2161        write_text(&root.join("excluded.txt"), "excluded by include policy");
2162        write_text(
2163            &root.join(".secret.md"),
2164            "excluded by default hidden policy",
2165        );
2166        write_text(
2167            &root.join("nested/.private/secret.md"),
2168            "excluded hidden-directory descendant",
2169        );
2170
2171        let provider = FilesystemProvider::new(root.path())
2172            .with_patterns(&["**/*.md"])
2173            .with_recursive(true);
2174
2175        assert!(matches!(
2176            provider.read_file(&test_context(), "visible.md"),
2177            Ok(FileContent::Text(_))
2178        ));
2179        for denied in ["excluded.txt", ".secret.md", "nested/.private/secret.md"] {
2180            assert!(matches!(
2181                provider.read_file(&test_context(), denied),
2182                Err(FilesystemProviderError::NotFound { path }) if path == denied
2183            ));
2184        }
2185    }
2186
2187    #[cfg(any(target_os = "linux", target_os = "macos"))]
2188    #[test]
2189    fn test_handler_read_async_with_uri() {
2190        let root = TestDir::new("handler-async");
2191        write_text(&root.join("notes.md"), "async content");
2192
2193        let handler = FilesystemProvider::new(root.path())
2194            .with_patterns(&["*.md"])
2195            .build_for_test()
2196            .expect("valid filesystem provider");
2197        let ctx = McpContext::new(asupersync::Cx::for_testing(), 9);
2198
2199        let mut params = HashMap::new();
2200        params.insert("path".to_string(), "notes.md".to_string());
2201        let outcome =
2202            fastmcp_core::block_on(handler.read_async_with_uri(&ctx, "file:///notes.md", &params));
2203        match outcome {
2204            Outcome::Ok(content) => {
2205                assert_eq!(content.len(), 1);
2206                assert_eq!(content[0].text.as_deref(), Some("async content"));
2207            }
2208            other => panic!("unexpected async outcome: {other:?}"),
2209        }
2210    }
2211
2212    #[test]
2213    fn test_base64_encode_padding_variants() {
2214        assert_eq!(base64_encode(b"").unwrap(), "");
2215        assert_eq!(base64_encode(b"f").unwrap(), "Zg==");
2216        assert_eq!(base64_encode(b"fo").unwrap(), "Zm8=");
2217        assert_eq!(base64_encode(b"foo").unwrap(), "Zm9v");
2218    }
2219
2220    #[cfg(any(target_os = "linux", target_os = "macos"))]
2221    #[test]
2222    fn test_symlink_components_are_always_denied() {
2223        use std::os::unix::fs::symlink;
2224
2225        let root = TestDir::new("symlink-root");
2226        let outside = TestDir::new("symlink-outside");
2227
2228        write_text(&root.join("inside.txt"), "inside");
2229        write_text(&outside.join("outside.txt"), "outside");
2230
2231        let inside_link = root.join("inside-link.txt");
2232        let escape_link = root.join("escape-link.txt");
2233        let escape_directory_link = root.join("escape-directory");
2234        symlink(root.join("inside.txt"), &inside_link).expect("create inside symlink");
2235        symlink(outside.join("outside.txt"), &escape_link).expect("create escape symlink");
2236        symlink(outside.path(), &escape_directory_link).expect("create directory escape symlink");
2237
2238        let provider = FilesystemProvider::new(root.path()).with_recursive(true);
2239        let denied = provider.read_file(&test_context(), "inside-link.txt");
2240        assert!(matches!(
2241            denied,
2242            Err(FilesystemProviderError::SymlinkDenied { .. })
2243        ));
2244        let escaped = provider.read_file(&test_context(), "escape-link.txt");
2245        assert!(matches!(
2246            escaped,
2247            Err(FilesystemProviderError::SymlinkDenied { .. })
2248        ));
2249        let intermediate_escape =
2250            provider.read_file(&test_context(), "escape-directory/outside.txt");
2251        assert!(matches!(
2252            intermediate_escape,
2253            Err(FilesystemProviderError::SymlinkDenied { .. })
2254        ));
2255
2256        let listed = provider
2257            .list_files(&test_context())
2258            .expect("secure listing");
2259        assert_eq!(
2260            listed
2261                .iter()
2262                .map(|entry| entry.relative_path.as_str())
2263                .collect::<Vec<_>>(),
2264            vec!["inside.txt"]
2265        );
2266    }
2267
2268    #[cfg(any(target_os = "linux", target_os = "macos"))]
2269    #[test]
2270    fn test_open_handle_survives_final_component_symlink_swap() {
2271        use std::os::unix::fs::symlink;
2272
2273        let root = TestDir::new("symlink-swap-root");
2274        let outside = TestDir::new("symlink-swap-outside");
2275        write_text(&root.join("victim.txt"), "inside");
2276        write_text(&outside.join("secret.txt"), "outside-secret");
2277
2278        let provider = FilesystemProvider::new(root.path());
2279        let opened = provider
2280            .open_file_nofollow("victim.txt")
2281            .expect("open retained capability handle");
2282
2283        std::fs::rename(root.join("victim.txt"), root.join("retained.txt"))
2284            .expect("rename original after handle acquisition");
2285        symlink(outside.join("secret.txt"), root.join("victim.txt"))
2286            .expect("replace request name with escaping symlink");
2287
2288        let content = provider
2289            .read_open_file(&test_context(), opened, "victim.txt")
2290            .expect("read already-opened handle");
2291        assert!(matches!(content, FileContent::Text(ref text) if text == "inside"));
2292        assert!(matches!(
2293            provider.read_file(&test_context(), "victim.txt"),
2294            Err(FilesystemProviderError::SymlinkDenied { .. })
2295        ));
2296    }
2297
2298    #[cfg(any(target_os = "linux", target_os = "macos"))]
2299    #[test]
2300    fn test_retained_root_handle_survives_ambient_root_replacement() {
2301        let outer = TestDir::new("root-swap");
2302        let served = outer.join("served");
2303        std::fs::create_dir(&served).expect("create served root");
2304        write_text(&served.join("value.txt"), "retained-root");
2305
2306        let provider = FilesystemProvider::new(&served);
2307        std::fs::rename(&served, outer.join("retained-root"))
2308            .expect("rename served root after capability acquisition");
2309        std::fs::create_dir(&served).expect("create ambient replacement root");
2310        write_text(&served.join("value.txt"), "ambient-replacement");
2311
2312        let content = provider
2313            .read_file(&test_context(), "value.txt")
2314            .expect("read through retained root handle");
2315        assert!(matches!(content, FileContent::Text(ref text) if text == "retained-root"));
2316    }
2317
2318    #[cfg(any(target_os = "linux", target_os = "macos"))]
2319    #[test]
2320    fn test_multi_link_file_is_not_exposed() {
2321        let root = TestDir::new("hardlink-root");
2322        let outside = TestDir::new("hardlink-outside");
2323        write_text(&outside.join("shared.txt"), "shared");
2324        std::fs::hard_link(outside.join("shared.txt"), root.join("shared.txt"))
2325            .expect("create hard link into provider root");
2326
2327        let provider = FilesystemProvider::new(root.path());
2328        assert!(matches!(
2329            provider.read_file(&test_context(), "shared.txt"),
2330            Err(FilesystemProviderError::HardLinkDenied { links, .. }) if links >= 2
2331        ));
2332        assert!(
2333            provider
2334                .list_files(&test_context())
2335                .expect("list files")
2336                .is_empty()
2337        );
2338    }
2339
2340    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
2341    #[test]
2342    fn unqualified_target_fails_closed() {
2343        let root = TestDir::new("unsupported-platform");
2344        write_text(&root.join("ordinary.txt"), "ordinary");
2345        let provider = FilesystemProvider::new(root.path());
2346
2347        assert!(matches!(
2348            provider.list_files(&test_context()),
2349            Err(FilesystemProviderError::FeatureUnavailable { .. })
2350        ));
2351        assert!(matches!(
2352            provider.read_file(&test_context(), "ordinary.txt"),
2353            Err(FilesystemProviderError::FeatureUnavailable { .. })
2354        ));
2355    }
2356
2357    // ── FilesystemProviderError ────────────────────────────────────
2358
2359    #[test]
2360    fn error_path_traversal_display() {
2361        let err = FilesystemProviderError::PathTraversal {
2362            requested: "../etc/passwd".to_string(),
2363        };
2364        let msg = err.to_string();
2365        assert!(msg.contains("Path traversal attempt blocked"));
2366        assert!(msg.contains("../etc/passwd"));
2367    }
2368
2369    #[test]
2370    fn error_too_large_display() {
2371        let err = FilesystemProviderError::TooLarge {
2372            path: "big.bin".to_string(),
2373            size: 50_000_000,
2374            max: 10_000_000,
2375        };
2376        let msg = err.to_string();
2377        assert!(msg.contains("File too large"));
2378        assert!(msg.contains("big.bin"));
2379        assert!(msg.contains("50000000"));
2380        assert!(msg.contains("10000000"));
2381    }
2382
2383    #[test]
2384    fn error_symlink_denied_display() {
2385        let err = FilesystemProviderError::SymlinkDenied {
2386            path: "link.txt".to_string(),
2387        };
2388        assert!(err.to_string().contains("Symlink access denied"));
2389    }
2390
2391    #[test]
2392    fn error_hard_link_denied_display() {
2393        let err = FilesystemProviderError::HardLinkDenied {
2394            path: "aliased.txt".to_string(),
2395            links: 2,
2396        };
2397        let message = err.to_string();
2398        assert!(message.contains("Hard-linked file access denied"));
2399        assert!(message.contains("aliased.txt"));
2400        assert!(message.contains("2 links"));
2401    }
2402
2403    #[test]
2404    fn error_io_display() {
2405        let err = FilesystemProviderError::Io {
2406            message: "permission denied".to_string(),
2407        };
2408        assert!(err.to_string().contains("IO error"));
2409        assert!(err.to_string().contains("permission denied"));
2410    }
2411
2412    #[test]
2413    fn error_not_found_display() {
2414        let err = FilesystemProviderError::NotFound {
2415            path: "missing.txt".to_string(),
2416        };
2417        assert!(err.to_string().contains("File not found"));
2418        assert!(err.to_string().contains("missing.txt"));
2419    }
2420
2421    #[test]
2422    fn error_debug() {
2423        let err = FilesystemProviderError::PathTraversal {
2424            requested: "x".to_string(),
2425        };
2426        let debug = format!("{:?}", err);
2427        assert!(debug.contains("PathTraversal"));
2428    }
2429
2430    #[test]
2431    fn error_clone() {
2432        let err = FilesystemProviderError::NotFound {
2433            path: "a.txt".to_string(),
2434        };
2435        let cloned = err.clone();
2436        assert!(cloned.to_string().contains("a.txt"));
2437    }
2438
2439    #[test]
2440    fn error_std_error() {
2441        let err = FilesystemProviderError::Io {
2442            message: "oops".to_string(),
2443        };
2444        let std_err: &dyn std::error::Error = &err;
2445        assert!(std_err.to_string().contains("oops"));
2446    }
2447
2448    // ── From<FilesystemProviderError> for McpError ─────────────────
2449
2450    #[test]
2451    fn error_into_mcp_error_path_traversal() {
2452        let err = FilesystemProviderError::PathTraversal {
2453            requested: "forged\npeer-path".to_string(),
2454        };
2455        let mcp: McpError = err.into();
2456        assert_eq!(mcp.message, "Filesystem resource path was rejected");
2457        assert!(!mcp.message.contains("peer-path"));
2458    }
2459
2460    #[test]
2461    fn error_into_mcp_error_too_large() {
2462        let err = FilesystemProviderError::TooLarge {
2463            path: "forged\u{202e}.bin".to_string(),
2464            size: 100,
2465            max: 10,
2466        };
2467        let mcp: McpError = err.into();
2468        assert_eq!(
2469            mcp.message,
2470            "Filesystem resource exceeds the size limit: 100 > 10 bytes"
2471        );
2472        assert!(!mcp.message.contains('\u{202e}'));
2473    }
2474
2475    #[test]
2476    fn error_into_mcp_error_symlink_denied() {
2477        let err = FilesystemProviderError::SymlinkDenied {
2478            path: "x".to_string(),
2479        };
2480        let mcp: McpError = err.into();
2481        assert_eq!(mcp.message, "Filesystem resource link access was rejected");
2482    }
2483
2484    #[test]
2485    fn error_into_mcp_error_io() {
2486        let err = FilesystemProviderError::Io {
2487            message: "disk fail".to_string(),
2488        };
2489        let mcp: McpError = err.into();
2490        assert_eq!(mcp.message, "Filesystem resource operation failed");
2491    }
2492
2493    #[test]
2494    fn error_into_mcp_error_not_found() {
2495        let err = FilesystemProviderError::NotFound {
2496            path: "gone.txt".to_string(),
2497        };
2498        let mcp: McpError = err.into();
2499        assert!(mcp.message.contains(REDACTED_RESOURCE_PATH));
2500        assert!(!mcp.message.contains("gone.txt"));
2501    }
2502
2503    // ── FilesystemProvider construction and builders ───────────────
2504
2505    #[test]
2506    fn provider_new_defaults() {
2507        let root = TestDir::new("defaults");
2508        let provider = FilesystemProvider::new(root.path());
2509        assert_eq!(provider.root, root.path().to_path_buf());
2510        assert!(provider.prefix.is_none());
2511        assert!(provider.include_patterns.is_empty());
2512        assert_eq!(
2513            provider.exclude_patterns,
2514            vec![".*".to_string(), "**/.*".to_string()]
2515        );
2516        assert!(!provider.recursive);
2517        assert_eq!(provider.max_file_size, DEFAULT_MAX_SIZE);
2518        assert!(provider.description.is_none());
2519    }
2520
2521    #[test]
2522    fn provider_with_prefix() {
2523        let provider = FilesystemProvider::new("/tmp").with_prefix("myprefix");
2524        assert_eq!(provider.prefix, Some("myprefix".to_string()));
2525    }
2526
2527    #[test]
2528    fn provider_with_patterns() {
2529        let provider = FilesystemProvider::new("/tmp").with_patterns(&["*.md", "*.txt"]);
2530        assert_eq!(provider.include_patterns, vec!["*.md", "*.txt"]);
2531    }
2532
2533    #[test]
2534    fn provider_with_exclude() {
2535        let provider = FilesystemProvider::new("/tmp").with_exclude(&["*.bak", "*.tmp"]);
2536        // Default hidden file pattern should be replaced
2537        assert_eq!(provider.exclude_patterns, vec!["*.bak", "*.tmp"]);
2538    }
2539
2540    #[test]
2541    fn provider_with_recursive() {
2542        let provider = FilesystemProvider::new("/tmp").with_recursive(true);
2543        assert!(provider.recursive);
2544    }
2545
2546    #[test]
2547    fn provider_with_max_size() {
2548        let provider = FilesystemProvider::new("/tmp").with_max_size(1024);
2549        assert_eq!(provider.max_file_size, 1024);
2550    }
2551
2552    #[test]
2553    fn provider_with_description() {
2554        let provider = FilesystemProvider::new("/tmp").with_description("My files");
2555        assert_eq!(provider.description, Some("My files".to_string()));
2556    }
2557
2558    #[test]
2559    fn provider_debug_redacts_local_paths_and_policy_text() {
2560        let root_canary = "/tmp/FAST_MCP_SECRET_ROOT_CANARY";
2561        let pattern_canary = "FAST_MCP_SECRET_PATTERN_CANARY*";
2562        let description_canary = "FAST_MCP_SECRET_DESCRIPTION_CANARY";
2563        let provider = FilesystemProvider::new(root_canary)
2564            .with_prefix("dbg")
2565            .with_patterns(&[pattern_canary])
2566            .with_description(description_canary);
2567        let debug = format!("{:?}", provider);
2568        assert!(debug.contains("FilesystemProvider"));
2569        assert!(debug.contains("dbg"));
2570        assert!(!debug.contains(root_canary));
2571        assert!(!debug.contains(pattern_canary));
2572        assert!(!debug.contains(description_canary));
2573    }
2574
2575    #[test]
2576    fn provider_clone() {
2577        let provider = FilesystemProvider::new("/tmp")
2578            .with_prefix("cloned")
2579            .with_recursive(true)
2580            .with_max_size(5000);
2581        let cloned = provider.clone();
2582        assert_eq!(cloned.prefix, Some("cloned".to_string()));
2583        assert!(cloned.recursive);
2584        assert_eq!(cloned.max_file_size, 5000);
2585    }
2586
2587    // ── URI methods ───────────────────────────────────────────────
2588
2589    #[test]
2590    fn file_uri_with_prefix() {
2591        let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
2592        assert_eq!(
2593            provider.file_uri("readme.md").unwrap(),
2594            "file:///docs/readme.md"
2595        );
2596    }
2597
2598    #[test]
2599    fn file_uri_without_prefix() {
2600        let provider = FilesystemProvider::new("/tmp");
2601        assert_eq!(provider.file_uri("readme.md").unwrap(), "file:///readme.md");
2602    }
2603
2604    #[test]
2605    fn uri_template_with_prefix() {
2606        let provider = FilesystemProvider::new("/tmp").with_prefix("data");
2607        assert_eq!(provider.uri_template(), "file:///data/{+path}");
2608    }
2609
2610    #[test]
2611    fn uri_template_without_prefix() {
2612        let provider = FilesystemProvider::new("/tmp");
2613        assert_eq!(provider.uri_template(), "file:///{+path}");
2614    }
2615
2616    #[test]
2617    fn path_from_uri_with_prefix() {
2618        let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
2619        assert_eq!(
2620            provider
2621                .path_from_uri("file:///docs/readme.md")
2622                .expect("valid prefixed file URI"),
2623            "readme.md"
2624        );
2625    }
2626
2627    #[test]
2628    fn path_from_uri_without_prefix() {
2629        let provider = FilesystemProvider::new("/tmp");
2630        assert_eq!(
2631            provider
2632                .path_from_uri("file:///readme.md")
2633                .expect("valid file URI"),
2634            "readme.md"
2635        );
2636    }
2637
2638    #[test]
2639    fn path_from_uri_wrong_prefix() {
2640        let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
2641        assert!(provider.path_from_uri("file:///other/readme.md").is_err());
2642    }
2643
2644    #[test]
2645    fn path_from_uri_completely_wrong() {
2646        let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
2647        assert!(provider.path_from_uri("http://example.com").is_err());
2648    }
2649
2650    #[test]
2651    fn path_from_uri_rejects_query_fragment_and_control_delimiters() {
2652        let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
2653
2654        for uri in [
2655            "file:///docs/readme.md?version=2",
2656            "file:///docs/readme.md#section",
2657            "file:///docs/readme.md\nforged",
2658        ] {
2659            assert!(
2660                provider.path_from_uri(uri).is_err(),
2661                "ambiguous URI must be rejected: {uri:?}"
2662            );
2663        }
2664    }
2665
2666    #[test]
2667    fn resource_paths_have_one_canonical_reserved_expansion_uri() {
2668        let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
2669        let path = "nested/hello world/資料.md";
2670        let uri = provider.file_uri(path).expect("canonical resource URI");
2671        assert_eq!(
2672            uri,
2673            "file:///docs/nested/hello%20world/%E8%B3%87%E6%96%99.md"
2674        );
2675        assert_eq!(
2676            provider.path_from_uri(&uri).expect("canonical URI decodes"),
2677            path
2678        );
2679
2680        for alias in [
2681            "file:///docs/nested%2Fhello.txt",
2682            "file:///docs/hello world.txt",
2683            "file:///docs/hello%2fworld.txt",
2684            "file:///docs/%2E%2E/secret.txt",
2685            "file:///docs/truncated%2",
2686            "file:///docs/invalid%GG",
2687        ] {
2688            assert!(
2689                provider.path_from_uri(alias).is_err(),
2690                "non-canonical or unsafe alias must be rejected: {alias:?}"
2691            );
2692        }
2693    }
2694
2695    #[test]
2696    fn rejected_resource_uri_diagnostics_do_not_echo_peer_input() {
2697        let provider = FilesystemProvider::new("/tmp").with_prefix("docs");
2698        let canary = "PEER-PATH-CANARY\nforged";
2699        let error = provider
2700            .path_from_uri(&format!("file:///docs/{canary}"))
2701            .expect_err("control-bearing URI must be rejected");
2702        let message = error.to_string();
2703
2704        assert!(!message.contains(canary));
2705        assert!(!message.chars().any(char::is_control));
2706        assert!(message.contains(REDACTED_RESOURCE_PATH));
2707    }
2708
2709    #[test]
2710    fn generated_file_uris_have_an_empty_authority_and_valid_path_encoding() {
2711        let provider = FilesystemProvider::new("/tmp");
2712        for path in ["foo:bar", "[brackets]", "at@sign", "nested/a;b.txt"] {
2713            let uri = provider.file_uri(path).expect("canonical file URI");
2714            let parsed = url::Url::parse(&uri).expect("generated URI must parse");
2715
2716            assert_eq!(parsed.scheme(), "file");
2717            assert!(parsed.host_str().is_none());
2718            assert_eq!(
2719                provider.path_from_uri(&uri).expect("generated URI decodes"),
2720                path
2721            );
2722        }
2723        assert_eq!(
2724            provider.file_uri("[brackets]").unwrap(),
2725            "file:///%5Bbrackets%5D"
2726        );
2727    }
2728
2729    // ── matches_patterns ──────────────────────────────────────────
2730
2731    #[test]
2732    fn matches_patterns_no_includes_no_excludes() {
2733        let provider = FilesystemProvider::new("/tmp").with_exclude(&[]);
2734        assert!(provider.matches_patterns("anything.txt"));
2735        assert!(provider.matches_patterns(".hidden"));
2736    }
2737
2738    #[test]
2739    fn matches_patterns_excludes_only() {
2740        let provider = FilesystemProvider::new("/tmp"); // default excludes .*
2741        assert!(provider.matches_patterns("visible.txt"));
2742        assert!(!provider.matches_patterns(".hidden"));
2743    }
2744
2745    #[test]
2746    fn matches_patterns_includes_only() {
2747        let provider = FilesystemProvider::new("/tmp")
2748            .with_exclude(&[])
2749            .with_patterns(&["*.md"]);
2750        assert!(provider.matches_patterns("readme.md"));
2751        assert!(!provider.matches_patterns("readme.txt"));
2752    }
2753
2754    #[test]
2755    fn matches_patterns_exclude_takes_priority() {
2756        let provider = FilesystemProvider::new("/tmp")
2757            .with_patterns(&["*.md"])
2758            .with_exclude(&["secret.md"]);
2759        assert!(provider.matches_patterns("readme.md"));
2760        assert!(!provider.matches_patterns("secret.md"));
2761    }
2762
2763    // ── handle-relative open edge cases ───────────────────────────
2764
2765    #[cfg(any(target_os = "linux", target_os = "macos"))]
2766    #[test]
2767    fn open_file_nofollow_not_found() {
2768        let root = TestDir::new("validate-notfound");
2769        let provider = FilesystemProvider::new(root.path());
2770        let result = provider.open_file_nofollow("nonexistent.txt");
2771        assert!(matches!(
2772            result,
2773            Err(FilesystemProviderError::NotFound { .. })
2774        ));
2775    }
2776
2777    // ── read_file edge cases ──────────────────────────────────────
2778
2779    #[cfg(any(target_os = "linux", target_os = "macos"))]
2780    #[test]
2781    fn read_file_not_found() {
2782        let root = TestDir::new("read-notfound");
2783        let provider = FilesystemProvider::new(root.path());
2784        let result = provider.read_file(&test_context(), "missing.txt");
2785        assert!(matches!(
2786            result,
2787            Err(FilesystemProviderError::NotFound { .. })
2788        ));
2789    }
2790
2791    // ── detect_mime_type extended ──────────────────────────────────
2792
2793    #[test]
2794    fn detect_mime_type_text_formats() {
2795        assert_eq!(detect_mime_type(Path::new("f.txt")), "text/plain");
2796        assert_eq!(detect_mime_type(Path::new("f.html")), "text/html");
2797        assert_eq!(detect_mime_type(Path::new("f.htm")), "text/html");
2798        assert_eq!(detect_mime_type(Path::new("f.css")), "text/css");
2799        assert_eq!(detect_mime_type(Path::new("f.csv")), "text/csv");
2800        assert_eq!(detect_mime_type(Path::new("f.xml")), "application/xml");
2801        assert_eq!(detect_mime_type(Path::new("f.markdown")), "text/markdown");
2802    }
2803
2804    #[test]
2805    fn detect_mime_type_programming_languages() {
2806        assert_eq!(detect_mime_type(Path::new("f.py")), "text/x-python");
2807        assert_eq!(detect_mime_type(Path::new("f.js")), "text/javascript");
2808        assert_eq!(detect_mime_type(Path::new("f.mjs")), "text/javascript");
2809        assert_eq!(detect_mime_type(Path::new("f.ts")), "text/typescript");
2810        assert_eq!(detect_mime_type(Path::new("f.mts")), "text/typescript");
2811        assert_eq!(detect_mime_type(Path::new("f.yaml")), "application/yaml");
2812        assert_eq!(detect_mime_type(Path::new("f.yml")), "application/yaml");
2813        assert_eq!(detect_mime_type(Path::new("f.toml")), "application/toml");
2814        assert_eq!(detect_mime_type(Path::new("f.sh")), "text/x-shellscript");
2815        assert_eq!(detect_mime_type(Path::new("f.bash")), "text/x-shellscript");
2816        assert_eq!(detect_mime_type(Path::new("f.c")), "text/x-c");
2817        assert_eq!(detect_mime_type(Path::new("f.cpp")), "text/x-c++");
2818        assert_eq!(detect_mime_type(Path::new("f.cc")), "text/x-c++");
2819        assert_eq!(detect_mime_type(Path::new("f.cxx")), "text/x-c++");
2820        assert_eq!(detect_mime_type(Path::new("f.h")), "text/x-c-header");
2821        assert_eq!(detect_mime_type(Path::new("f.hpp")), "text/x-c-header");
2822        assert_eq!(detect_mime_type(Path::new("f.java")), "text/x-java");
2823        assert_eq!(detect_mime_type(Path::new("f.go")), "text/x-go");
2824        assert_eq!(detect_mime_type(Path::new("f.rb")), "text/x-ruby");
2825        assert_eq!(detect_mime_type(Path::new("f.php")), "text/x-php");
2826        assert_eq!(detect_mime_type(Path::new("f.swift")), "text/x-swift");
2827        assert_eq!(detect_mime_type(Path::new("f.kt")), "text/x-kotlin");
2828        assert_eq!(detect_mime_type(Path::new("f.kts")), "text/x-kotlin");
2829        assert_eq!(detect_mime_type(Path::new("f.sql")), "text/x-sql");
2830    }
2831
2832    #[test]
2833    fn detect_mime_type_images() {
2834        assert_eq!(detect_mime_type(Path::new("f.jpg")), "image/jpeg");
2835        assert_eq!(detect_mime_type(Path::new("f.jpeg")), "image/jpeg");
2836        assert_eq!(detect_mime_type(Path::new("f.gif")), "image/gif");
2837        assert_eq!(detect_mime_type(Path::new("f.svg")), "image/svg+xml");
2838        assert_eq!(detect_mime_type(Path::new("f.webp")), "image/webp");
2839        assert_eq!(detect_mime_type(Path::new("f.ico")), "image/x-icon");
2840        assert_eq!(detect_mime_type(Path::new("f.bmp")), "image/bmp");
2841    }
2842
2843    #[test]
2844    fn detect_mime_type_binary() {
2845        assert_eq!(detect_mime_type(Path::new("f.pdf")), "application/pdf");
2846        assert_eq!(detect_mime_type(Path::new("f.zip")), "application/zip");
2847        assert_eq!(detect_mime_type(Path::new("f.gz")), "application/gzip");
2848        assert_eq!(detect_mime_type(Path::new("f.gzip")), "application/gzip");
2849        assert_eq!(detect_mime_type(Path::new("f.tar")), "application/x-tar");
2850        assert_eq!(detect_mime_type(Path::new("f.wasm")), "application/wasm");
2851        assert_eq!(
2852            detect_mime_type(Path::new("f.exe")),
2853            "application/octet-stream"
2854        );
2855        assert_eq!(
2856            detect_mime_type(Path::new("f.dll")),
2857            "application/octet-stream"
2858        );
2859        assert_eq!(
2860            detect_mime_type(Path::new("f.so")),
2861            "application/octet-stream"
2862        );
2863        assert_eq!(
2864            detect_mime_type(Path::new("f.bin")),
2865            "application/octet-stream"
2866        );
2867    }
2868
2869    #[test]
2870    fn detect_mime_type_no_extension() {
2871        assert_eq!(
2872            detect_mime_type(Path::new("Makefile")),
2873            "application/octet-stream"
2874        );
2875    }
2876
2877    // ── is_binary_mime_type extended ───────────────────────────────
2878
2879    #[test]
2880    fn is_binary_mime_type_audio_video() {
2881        assert!(is_binary_mime_type("audio/mpeg"));
2882        assert!(is_binary_mime_type("video/mp4"));
2883    }
2884
2885    #[test]
2886    fn is_binary_mime_type_archives() {
2887        assert!(is_binary_mime_type("application/zip"));
2888        assert!(is_binary_mime_type("application/gzip"));
2889        assert!(is_binary_mime_type("application/x-tar"));
2890        assert!(is_binary_mime_type("application/wasm"));
2891        assert!(is_binary_mime_type("application/octet-stream"));
2892    }
2893
2894    #[test]
2895    fn is_binary_mime_type_text_types_false() {
2896        assert!(!is_binary_mime_type("text/html"));
2897        assert!(!is_binary_mime_type("text/markdown"));
2898        assert!(!is_binary_mime_type("application/yaml"));
2899        assert!(!is_binary_mime_type("application/toml"));
2900    }
2901
2902    // ── base64_encode extended ────────────────────────────────────
2903
2904    #[test]
2905    fn base64_encode_hello_world() {
2906        assert_eq!(
2907            base64_encode(b"Hello, World!").unwrap(),
2908            "SGVsbG8sIFdvcmxkIQ=="
2909        );
2910    }
2911
2912    #[test]
2913    fn base64_encode_binary_sequence() {
2914        // Known value: bytes [0, 1, 2] → AAEC
2915        assert_eq!(base64_encode(&[0, 1, 2]).unwrap(), "AAEC");
2916    }
2917
2918    // ── glob_match edge cases ─────────────────────────────────────
2919
2920    #[test]
2921    fn glob_match_exact() {
2922        assert!(glob_match("readme.md", "readme.md"));
2923        assert!(!glob_match("readme.md", "other.md"));
2924    }
2925
2926    #[test]
2927    fn glob_match_empty_pattern_empty_path() {
2928        assert!(glob_match("", ""));
2929    }
2930
2931    #[test]
2932    fn glob_match_star_empty() {
2933        assert!(glob_match("*", ""));
2934        assert!(glob_match("*", "anything"));
2935    }
2936
2937    #[test]
2938    fn glob_match_double_star_alone() {
2939        assert!(glob_match("**", ""));
2940        assert!(glob_match("**", "a/b/c"));
2941    }
2942
2943    #[test]
2944    fn glob_match_mixed_pattern() {
2945        assert!(glob_match("src/*.rs", "src/main.rs"));
2946        assert!(!glob_match("src/*.rs", "src/sub/main.rs"));
2947        assert!(glob_match("src/**/*.rs", "src/sub/main.rs"));
2948    }
2949
2950    // ── FilesystemResourceHandler ─────────────────────────────────
2951
2952    #[cfg(any(target_os = "linux", target_os = "macos"))]
2953    #[test]
2954    fn handler_debug() {
2955        let root = TestDir::new("handler-debug");
2956        write_text(&root.join("a.txt"), "hello");
2957        let handler = FilesystemProvider::new(root.path())
2958            .build_for_test()
2959            .expect("valid filesystem provider");
2960        let debug = format!("{:?}", handler);
2961        assert!(debug.contains("FilesystemResourceHandler"));
2962        assert!(debug.contains("provider"));
2963    }
2964
2965    #[cfg(any(target_os = "linux", target_os = "macos"))]
2966    #[test]
2967    fn handler_definition_without_prefix() {
2968        let root = TestDir::new("handler-no-prefix");
2969        let handler = FilesystemProvider::new(root.path())
2970            .build_for_test()
2971            .expect("valid filesystem provider");
2972        let def = handler.definition();
2973        assert_eq!(def.name, "files");
2974        assert_eq!(def.uri, "file:///{+path}");
2975        assert!(def.description.is_none());
2976    }
2977
2978    #[cfg(any(target_os = "linux", target_os = "macos"))]
2979    #[test]
2980    fn handler_template_without_prefix() {
2981        let root = TestDir::new("handler-tmpl-no-prefix");
2982        let handler = FilesystemProvider::new(root.path())
2983            .build_for_test()
2984            .expect("valid filesystem provider");
2985        let tmpl = handler.template().unwrap();
2986        assert_eq!(tmpl.uri_template, "file:///{+path}");
2987        assert_eq!(tmpl.name, "files");
2988    }
2989
2990    #[cfg(any(target_os = "linux", target_os = "macos"))]
2991    #[test]
2992    fn handler_listing_is_a_live_view_not_a_stale_snapshot() {
2993        let root = TestDir::new("handler-live-view");
2994        write_text(&root.join("one.txt"), "1");
2995        write_text(&root.join("two.md"), "2");
2996        let handler = FilesystemProvider::new(root.path())
2997            .with_exclude(&[])
2998            .build_for_test()
2999            .expect("valid filesystem provider");
3000        write_text(&root.join("added-after-build.txt"), "3");
3001
3002        let ctx = McpContext::new(asupersync::Cx::for_testing(), 1);
3003        let listing = handler.read(&ctx).expect("read live listing");
3004        let text = listing[0].text.as_deref().expect("text listing");
3005        assert!(text.contains("file:///one.txt"));
3006        assert!(text.contains("file:///two.md"));
3007        assert!(text.contains("file:///added-after-build.txt"));
3008    }
3009
3010    #[cfg(any(target_os = "linux", target_os = "macos"))]
3011    #[test]
3012    fn handler_read_with_uri_missing_path_param() {
3013        let root = TestDir::new("handler-missing-param");
3014        let handler = FilesystemProvider::new(root.path())
3015            .with_prefix("p")
3016            .build_for_test()
3017            .expect("valid filesystem provider");
3018        let ctx = McpContext::new(asupersync::Cx::for_testing(), 1);
3019        let empty_params = HashMap::new();
3020        // URI doesn't match prefix either
3021        let result = handler.read_with_uri(&ctx, "file://wrong/x", &empty_params);
3022        assert!(result.is_err());
3023    }
3024
3025    #[cfg(any(target_os = "linux", target_os = "macos"))]
3026    #[test]
3027    fn handler_read_binary_file_returns_blob() {
3028        let root = TestDir::new("handler-binary");
3029        write_bytes(&root.join("data.bin"), &[0xDE, 0xAD, 0xBE, 0xEF]);
3030
3031        let handler = FilesystemProvider::new(root.path())
3032            .with_exclude(&[])
3033            .build_for_test()
3034            .expect("valid filesystem provider");
3035        let ctx = McpContext::new(asupersync::Cx::for_testing(), 1);
3036        let mut params = HashMap::new();
3037        params.insert("path".to_string(), "data.bin".to_string());
3038        let result = handler
3039            .read_with_uri(&ctx, "file:///data.bin", &params)
3040            .unwrap();
3041        assert!(result[0].text.is_none());
3042        assert!(result[0].blob.is_some());
3043    }
3044
3045    // ── list_files with exclude ───────────────────────────────────
3046
3047    #[cfg(any(target_os = "linux", target_os = "macos"))]
3048    #[test]
3049    fn list_files_excludes_hidden_by_default() {
3050        let root = TestDir::new("list-hidden");
3051        write_text(&root.join("visible.txt"), "v");
3052        write_text(&root.join(".hidden"), "h");
3053        write_text(&root.join("nested/.hidden"), "nested hidden file");
3054        write_text(
3055            &root.join("nested/.private/visible-name.txt"),
3056            "hidden directory descendant",
3057        );
3058
3059        let provider = FilesystemProvider::new(root.path()).with_recursive(true);
3060        let files = provider.list_files(&test_context()).unwrap();
3061        let paths: Vec<&str> = files.iter().map(|e| e.relative_path.as_str()).collect();
3062        assert!(paths.contains(&"visible.txt"));
3063        assert!(!paths.contains(&".hidden"));
3064        assert!(!paths.contains(&"nested/.hidden"));
3065        assert!(!paths.contains(&"nested/.private/visible-name.txt"));
3066    }
3067
3068    #[cfg(any(target_os = "linux", target_os = "macos"))]
3069    #[test]
3070    fn list_files_no_patterns_includes_all() {
3071        let root = TestDir::new("list-all");
3072        write_text(&root.join("a.txt"), "a");
3073        write_text(&root.join("b.rs"), "b");
3074
3075        let provider = FilesystemProvider::new(root.path()).with_exclude(&[]);
3076        let files = provider.list_files(&test_context()).unwrap();
3077        assert!(files.len() >= 2);
3078    }
3079
3080    #[cfg(any(target_os = "linux", target_os = "macos"))]
3081    #[test]
3082    fn listing_entry_ceiling_accepts_n_and_rejects_n_plus_one() {
3083        let root = TestDir::new("list-entry-limit");
3084        write_text(&root.join("a.txt"), "a");
3085        write_text(&root.join("b.txt"), "b");
3086
3087        let exact = FilesystemProvider::new(root.path())
3088            .with_exclude(&[])
3089            .with_max_entries(2);
3090        assert_eq!(exact.list_files(&test_context()).unwrap().len(), 2);
3091
3092        let too_small = FilesystemProvider::new(root.path())
3093            .with_exclude(&[])
3094            .with_max_entries(1);
3095        assert!(matches!(
3096            too_small.list_files(&test_context()),
3097            Err(FilesystemProviderError::TooManyEntries { count: 2, max: 1 })
3098        ));
3099    }
3100
3101    #[cfg(any(target_os = "linux", target_os = "macos"))]
3102    #[test]
3103    fn listing_byte_ceiling_accepts_n_and_rejects_n_plus_one() {
3104        let root = TestDir::new("list-byte-limit");
3105        write_text(&root.join("a.txt"), "a");
3106        let expected_bytes = "[{\"uri\":\"file:///a.txt\",\"mimeType\":\"text/plain\"}]".len();
3107
3108        let exact = FilesystemProvider::new(root.path())
3109            .with_exclude(&[])
3110            .with_max_listing_bytes(expected_bytes);
3111        assert_eq!(exact.list_files(&test_context()).unwrap().len(), 1);
3112
3113        let too_small = FilesystemProvider::new(root.path())
3114            .with_exclude(&[])
3115            .with_max_listing_bytes(expected_bytes - 1);
3116        assert!(matches!(
3117            too_small.list_files(&test_context()),
3118            Err(FilesystemProviderError::ListingTooLarge { size, max })
3119                if size == expected_bytes && max == expected_bytes - 1
3120        ));
3121    }
3122
3123    #[cfg(any(target_os = "linux", target_os = "macos"))]
3124    #[test]
3125    fn listing_depth_ceiling_accepts_n_and_rejects_n_plus_one() {
3126        let root = TestDir::new("list-depth-limit");
3127        write_text(&root.join("nested/file.txt"), "nested");
3128
3129        let exact = FilesystemProvider::new(root.path())
3130            .with_exclude(&[])
3131            .with_recursive(true)
3132            .with_max_depth(1);
3133        assert_eq!(exact.list_files(&test_context()).unwrap().len(), 1);
3134
3135        let too_shallow = FilesystemProvider::new(root.path())
3136            .with_exclude(&[])
3137            .with_recursive(true)
3138            .with_max_depth(0);
3139        assert!(matches!(
3140            too_shallow.list_files(&test_context()),
3141            Err(FilesystemProviderError::TooDeep {
3142                depth: 1,
3143                max: 0,
3144                ..
3145            })
3146        ));
3147    }
3148
3149    #[cfg(any(target_os = "linux", target_os = "macos"))]
3150    #[test]
3151    fn listing_and_direct_reads_reject_cancelled_contexts() {
3152        let root = TestDir::new("cancelled-listing");
3153        write_text(&root.join("a.txt"), "a");
3154        let provider = FilesystemProvider::new(root.path()).with_exclude(&[]);
3155        let cx = asupersync::Cx::for_testing();
3156        cx.set_cancel_requested(true);
3157        let ctx = McpContext::new(cx, 1);
3158
3159        assert!(matches!(
3160            provider.list_files(&ctx),
3161            Err(FilesystemProviderError::Cancelled)
3162        ));
3163        assert!(matches!(
3164            provider.read_file(&ctx, "a.txt"),
3165            Err(FilesystemProviderError::Cancelled)
3166        ));
3167    }
3168
3169    #[cfg(any(target_os = "linux", target_os = "macos"))]
3170    #[test]
3171    fn nonrecursive_provider_rejects_direct_nested_uri_bypass() {
3172        let root = TestDir::new("nonrecursive-direct-read");
3173        write_text(&root.join("nested/file.txt"), "nested");
3174
3175        let nonrecursive = FilesystemProvider::new(root.path()).with_exclude(&[]);
3176        assert!(matches!(
3177            nonrecursive.read_file(&test_context(), "nested/file.txt"),
3178            Err(FilesystemProviderError::NotFound { .. })
3179        ));
3180
3181        let recursive = FilesystemProvider::new(root.path())
3182            .with_exclude(&[])
3183            .with_recursive(true)
3184            .with_max_depth(1);
3185        assert!(matches!(
3186            recursive.read_file(&test_context(), "nested/file.txt"),
3187            Ok(FileContent::Text(text)) if text == "nested"
3188        ));
3189
3190        write_text(&root.join("nested/deeper/file.txt"), "too deep");
3191        assert!(matches!(
3192            recursive.read_file(&test_context(), "nested/deeper/file.txt"),
3193            Err(FilesystemProviderError::TooDeep {
3194                depth: 2,
3195                max: 1,
3196                ..
3197            })
3198        ));
3199    }
3200
3201    // ── DEFAULT_MAX_SIZE ──────────────────────────────────────────
3202
3203    #[test]
3204    fn default_max_size_is_10mb() {
3205        assert_eq!(DEFAULT_MAX_SIZE, 10 * 1024 * 1024);
3206    }
3207
3208    // ── FileEntry ─────────────────────────────────────────────────
3209
3210    #[test]
3211    fn file_entry_debug() {
3212        let entry = FileEntry {
3213            relative_path: "test.txt".to_string(),
3214            uri: "file:///test.txt".to_string(),
3215            size: Some(42),
3216            mime_type: "text/plain".to_string(),
3217        };
3218        let debug = format!("{:?}", entry);
3219        assert!(debug.contains("test.txt"));
3220        assert!(debug.contains("42"));
3221    }
3222
3223    // ── Provider builder chaining ─────────────────────────────────
3224
3225    #[test]
3226    fn provider_builder_chaining() {
3227        let root = TestDir::new("builder-chain");
3228        let provider = FilesystemProvider::new(root.path())
3229            .with_prefix("chain")
3230            .with_patterns(&["*.md"])
3231            .with_exclude(&["*.bak"])
3232            .with_recursive(true)
3233            .with_max_size(2048)
3234            .with_max_entries(20)
3235            .with_max_depth(3)
3236            .with_max_listing_bytes(4096)
3237            .with_description("Chain test");
3238
3239        assert_eq!(provider.prefix, Some("chain".to_string()));
3240        assert_eq!(provider.include_patterns, vec!["*.md"]);
3241        assert_eq!(provider.exclude_patterns, vec!["*.bak"]);
3242        assert!(provider.recursive);
3243        assert_eq!(provider.max_file_size, 2048);
3244        assert_eq!(provider.max_entries, 20);
3245        assert_eq!(provider.max_depth, 3);
3246        assert_eq!(provider.max_listing_bytes, 4096);
3247        assert_eq!(provider.description, Some("Chain test".to_string()));
3248    }
3249
3250    // ── Additional coverage ─────────────────────────────────────────
3251
3252    #[test]
3253    fn detect_mime_type_case_insensitive() {
3254        assert_eq!(detect_mime_type(Path::new("README.MD")), "text/markdown");
3255        assert_eq!(detect_mime_type(Path::new("photo.JPG")), "image/jpeg");
3256        assert_eq!(detect_mime_type(Path::new("data.JSON")), "application/json");
3257    }
3258
3259    #[test]
3260    fn glob_match_question_mark_at_end_fails_when_no_char() {
3261        assert!(!glob_match("file?", "file"));
3262        assert!(glob_match("file?", "fileA"));
3263    }
3264
3265    #[test]
3266    fn base64_encode_round_trips_with_std_decoder() {
3267        use base64::Engine as _;
3268        let data = b"The quick brown fox jumps over the lazy dog";
3269        let encoded = base64_encode(data).expect("bounded base64");
3270        let decoded = base64::engine::general_purpose::STANDARD
3271            .decode(&encoded)
3272            .expect("valid base64");
3273        assert_eq!(decoded, data);
3274    }
3275
3276    #[test]
3277    fn base64_encode_rejects_payload_above_raw_input_ceiling() {
3278        let oversized = vec![0_u8; MAX_CONFIGURED_FILE_SIZE + 1];
3279
3280        assert!(matches!(
3281            base64_encode(&oversized),
3282            Err(FilesystemProviderError::TooLarge { .. })
3283        ));
3284    }
3285
3286    #[cfg(any(target_os = "linux", target_os = "macos"))]
3287    #[test]
3288    fn handler_empty_root_has_an_empty_live_listing() {
3289        let root = TestDir::new("handler-empty");
3290        let handler = FilesystemProvider::new(root.path())
3291            .build_for_test()
3292            .expect("valid filesystem provider");
3293        let ctx = McpContext::new(asupersync::Cx::for_testing(), 1);
3294        let listing = handler.read(&ctx).expect("read empty listing");
3295        assert_eq!(listing[0].text.as_deref(), Some("[]"));
3296    }
3297
3298    #[test]
3299    fn list_files_nonexistent_root_returns_error() {
3300        let provider = FilesystemProvider::new("/nonexistent-fastmcp-test-dir-xyz");
3301        let result = provider.list_files(&test_context());
3302        assert!(result.is_err());
3303    }
3304
3305    #[test]
3306    fn read_file_path_traversal_blocked() {
3307        let root = TestDir::new("read-traversal");
3308        write_text(&root.join("safe.txt"), "ok");
3309        let provider = FilesystemProvider::new(root.path());
3310        let result = provider.read_file(&test_context(), "../../../etc/passwd");
3311        assert!(matches!(
3312            result,
3313            Err(FilesystemProviderError::PathTraversal { .. }
3314                | FilesystemProviderError::NotFound { .. })
3315        ));
3316    }
3317}