1use 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
47const DEFAULT_MAX_SIZE: usize = 10 * 1024 * 1024;
49const DEFAULT_MAX_ENTRIES: usize = 10_000;
51const DEFAULT_MAX_DEPTH: usize = 64;
53const DEFAULT_MAX_LISTING_BYTES: usize = 1024 * 1024;
55const MAX_CONFIGURED_FILE_SIZE: usize = 10 * 1024 * 1024;
57const MAX_ENCODED_BINARY_BYTES: usize = ((MAX_CONFIGURED_FILE_SIZE + 2) / 3) * 4;
59const MAX_RELATIVE_PATH_BYTES: usize = 4096;
61const MAX_ENCODED_RELATIVE_PATH_BYTES: usize = MAX_RELATIVE_PATH_BYTES * 3;
63const MAX_URI_PREFIX_BYTES: usize = 256;
65const 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;
71const 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>";
76const 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#[derive(Debug, Clone)]
195pub enum FilesystemProviderError {
196 PathTraversal { requested: String },
198 TooLarge { path: String, size: u64, max: usize },
200 SymlinkDenied { path: String },
202 HardLinkDenied { path: String, links: u64 },
204 FeatureUnavailable { platform: String },
206 Io { message: String },
208 NotFound { path: String },
210 TooManyEntries { count: usize, max: usize },
212 TooDeep {
214 path: String,
215 depth: usize,
216 max: usize,
217 },
218 ListingTooLarge { size: usize, max: usize },
220 InvalidConfiguration { field: &'static str },
222 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#[derive(Clone)]
330pub struct FilesystemProvider {
331 root: PathBuf,
333 root_directory: Result<Arc<Dir>, Arc<str>>,
335 prefix: Option<String>,
337 include_patterns: Vec<String>,
339 include_patterns_valid: bool,
341 exclude_patterns: Vec<String>,
343 exclude_patterns_valid: bool,
345 recursive: bool,
347 max_file_size: usize,
349 max_entries: usize,
351 max_depth: usize,
353 max_listing_bytes: usize,
355 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 #[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_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 #[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 #[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 #[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 #[must_use]
501 pub fn with_recursive(mut self, enabled: bool) -> Self {
502 self.recursive = enabled;
503 self
504 }
505
506 #[must_use]
518 pub fn with_max_size(mut self, bytes: usize) -> Self {
519 self.max_file_size = bytes;
520 self
521 }
522
523 #[must_use]
525 pub fn with_max_entries(mut self, entries: usize) -> Self {
526 self.max_entries = entries;
527 self
528 }
529
530 #[must_use]
533 pub fn with_max_depth(mut self, depth: usize) -> Self {
534 self.max_depth = depth;
535 self
536 }
537
538 #[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 #[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 pub fn build(self) -> Result<FilesystemResourceHandler, FilesystemProviderError> {
570 self.validate_configuration()?;
571 self.root_directory()?;
572 Ok(FilesystemResourceHandler { provider: self })
573 }
574
575 #[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 #[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 #[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 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 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 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 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 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 ¤t,
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(¤t, final_component, requested, error)
770 })
771 }
772
773 fn matches_patterns(&self, relative_path: &str) -> bool {
775 if self.is_excluded(relative_path) {
776 return false;
777 }
778
779 if self.include_patterns.is_empty() {
781 return true;
782 }
783
784 for pattern in &self.include_patterns {
786 if glob_match(pattern, relative_path) {
787 return true;
788 }
789 }
790
791 false
792 }
793
794 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 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 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 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 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 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 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 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 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 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 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 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 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#[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
1188enum FileContent {
1190 Text(String),
1191 Binary(Vec<u8>),
1192}
1193
1194#[derive(Clone)]
1196pub struct FilesystemResourceHandler {
1197 provider: FilesystemProvider,
1198}
1199
1200impl FilesystemResourceHandler {
1201 #[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 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 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, ¶ms)
1310 })
1311 .await
1312 })
1313 }
1314
1315 fn read(&self, ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
1316 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 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
1413fn 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 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 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 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 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 _ => "application/octet-stream",
1471 }
1472 .to_string()
1473}
1474
1475fn 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
1488const BASE64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1490
1491fn 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
1630fn 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")); }
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 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", ¶ms)
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", ¶ms);
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", ¶ms));
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 #[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 #[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 #[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 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 #[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 #[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"); 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 #[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 #[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 #[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 #[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 #[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 assert_eq!(base64_encode(&[0, 1, 2]).unwrap(), "AAEC");
2916 }
2917
2918 #[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 #[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 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", ¶ms)
3040 .unwrap();
3041 assert!(result[0].text.is_none());
3042 assert!(result[0].blob.is_some());
3043 }
3044
3045 #[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 #[test]
3204 fn default_max_size_is_10mb() {
3205 assert_eq!(DEFAULT_MAX_SIZE, 10 * 1024 * 1024);
3206 }
3207
3208 #[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 #[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 #[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}