Skip to main content

task_graph_mcp/
paths.rs

1//! Path mapping and sandboxing system.
2//!
3//! This module provides a configurable path prefix mapping system that:
4//! - Maps custom prefixes (e.g., `home:`, `project:`, `media:`) to configured paths
5//! - Enforces lowercase prefixes
6//! - Optionally maps Windows drive letters
7//! - Sandboxes paths to prevent escape above root
8//! - Is pure string manipulation (no filesystem I/O)
9//! - Provides reverse translation API to get actual filesystem paths
10
11use crate::config::{Config, PathStyle, PathsConfig};
12use crate::error::ToolError;
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16/// Result type for path operations.
17pub type PathResult<T> = Result<T, ToolError>;
18
19/// Pure string-based path mapper. No filesystem I/O.
20#[derive(Debug, Clone)]
21pub struct PathMapper {
22    /// Resolved root path (canonical form).
23    root: String,
24    /// Prefix to resolved path mappings.
25    mappings: HashMap<String, String>,
26    /// Whether to auto-map single-letter Windows drive prefixes.
27    map_windows_drives: bool,
28    /// Display style for paths.
29    style: PathStyle,
30}
31
32impl PathMapper {
33    /// Create a PathMapper from configuration.
34    ///
35    /// Resolves `$ENV` and `${config.ref}` in mapping values.
36    /// The root path is resolved relative to the current working directory.
37    pub fn from_config(config: &PathsConfig, full_config: Option<&Config>) -> PathResult<Self> {
38        // Resolve the root path
39        let root = Self::resolve_root(&config.root)?;
40
41        // Resolve all mappings
42        let mut mappings = HashMap::new();
43        for (prefix, value) in &config.mappings {
44            // Validate prefix is lowercase
45            if !prefix.chars().all(|c: char| c.is_ascii_lowercase()) {
46                return Err(ToolError::prefix_not_lowercase(prefix));
47            }
48
49            let resolved = Self::resolve_mapping_value(value, &root, full_config)?;
50            mappings.insert(prefix.clone(), resolved);
51        }
52
53        Ok(Self {
54            root,
55            mappings,
56            map_windows_drives: config.map_windows_drives,
57            style: config.style,
58        })
59    }
60
61    /// Create a PathMapper with default configuration.
62    pub fn new() -> PathResult<Self> {
63        Self::from_config(&PathsConfig::default(), None)
64    }
65
66    /// Resolve the root path to an absolute canonical string.
67    fn resolve_root(root: &str) -> PathResult<String> {
68        let root_path = if root == "." || root.is_empty() {
69            std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
70        } else {
71            let path = Path::new(root);
72            if path.is_absolute() {
73                path.to_path_buf()
74            } else {
75                std::env::current_dir()
76                    .unwrap_or_else(|_| PathBuf::from("."))
77                    .join(path)
78            }
79        };
80
81        // Normalize the path (resolve . and ..)
82        let normalized = normalize_path_components(&root_path);
83        Ok(path_to_forward_slashes(&normalized))
84    }
85
86    /// Resolve a mapping value, expanding $ENV and ${config.ref}.
87    fn resolve_mapping_value(
88        value: &str,
89        root: &str,
90        full_config: Option<&Config>,
91    ) -> PathResult<String> {
92        // Handle "." as root
93        if value == "." {
94            return Ok(root.to_string());
95        }
96
97        // Handle $ENV_VAR
98        if let Some(env_var) = value.strip_prefix('$') {
99            // Check if it's ${config.path} format
100            if let Some(config_path) = env_var.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
101                return Self::resolve_config_ref(config_path, root, full_config);
102            }
103
104            // Plain $ENV_VAR
105            return match std::env::var(env_var) {
106                Ok(val) => {
107                    // Normalize the resolved path
108                    let path = Path::new(&val);
109                    let absolute = if path.is_absolute() {
110                        path.to_path_buf()
111                    } else {
112                        std::env::current_dir()
113                            .unwrap_or_else(|_| PathBuf::from("."))
114                            .join(path)
115                    };
116                    let normalized = normalize_path_components(&absolute);
117                    Ok(path_to_forward_slashes(&normalized))
118                }
119                Err(_) => Err(ToolError::invalid_path(
120                    value,
121                    &format!("Environment variable {} not set", env_var),
122                )),
123            };
124        }
125
126        // Literal path - make it absolute and normalize
127        let path = Path::new(value);
128        let absolute = if path.is_absolute() {
129            path.to_path_buf()
130        } else {
131            // Relative paths are relative to root
132            PathBuf::from(root).join(path)
133        };
134        let normalized = normalize_path_components(&absolute);
135        Ok(path_to_forward_slashes(&normalized))
136    }
137
138    /// Resolve a ${config.path} reference.
139    fn resolve_config_ref(
140        config_path: &str,
141        root: &str,
142        full_config: Option<&Config>,
143    ) -> PathResult<String> {
144        let config = full_config.ok_or_else(|| {
145            ToolError::invalid_path(
146                config_path,
147                "Config reference requires full config, but none provided",
148            )
149        })?;
150
151        // Parse config.path format (e.g., "server.media_dir")
152        let parts: Vec<&str> = config_path.split('.').collect();
153        if parts.len() != 2 {
154            return Err(ToolError::invalid_path(
155                config_path,
156                "Config reference must be in format 'section.field'",
157            ));
158        }
159
160        let value = match (parts[0], parts[1]) {
161            ("server", "media_dir") => config.server.media_dir.to_string_lossy().to_string(),
162            ("server", "db_path") => config.server.db_path.to_string_lossy().to_string(),
163            ("server", "skills_dir") => config.server.skills_dir.to_string_lossy().to_string(),
164            ("server", "log_dir") => config.server.log_dir.to_string_lossy().to_string(),
165            _ => {
166                return Err(ToolError::invalid_path(
167                    config_path,
168                    &format!("Unknown config path: {}", config_path),
169                ));
170            }
171        };
172
173        // Make absolute and normalize
174        let path = Path::new(&value);
175        let absolute = if path.is_absolute() {
176            path.to_path_buf()
177        } else {
178            PathBuf::from(root).join(path)
179        };
180        let normalized = normalize_path_components(&absolute);
181        Ok(path_to_forward_slashes(&normalized))
182    }
183
184    /// Normalize a path to canonical internal form.
185    ///
186    /// This function:
187    /// - Resolves prefixes (home:, project:, c:)
188    /// - Resolves . and ..
189    /// - Converts to forward slashes
190    /// - Validates sandbox (no escape above root)
191    ///
192    /// Returns: Canonical path string (still virtual/internal)
193    pub fn normalize(&self, path: &str) -> PathResult<String> {
194        // Parse prefix if present
195        let (resolved_base, remainder) = self.resolve_prefix(path)?;
196
197        // Build the full path
198        let full_path = if let Some(base) = resolved_base {
199            if remainder.is_empty() {
200                base
201            } else {
202                format!("{}/{}", base.trim_end_matches('/'), remainder)
203            }
204        } else {
205            // No prefix - relative to root
206            if Path::new(remainder).is_absolute() {
207                remainder.to_string()
208            } else {
209                format!("{}/{}", self.root.trim_end_matches('/'), remainder)
210            }
211        };
212
213        // Normalize path components (resolve . and ..)
214        let path_buf = PathBuf::from(&full_path);
215        let normalized = normalize_path_components(&path_buf);
216        let canonical = path_to_forward_slashes(&normalized);
217
218        // Check sandbox - path must start with root (or be within root)
219        self.check_sandbox(&canonical)?;
220
221        Ok(canonical)
222    }
223
224    /// Normalize multiple paths.
225    pub fn normalize_all(&self, paths: Vec<String>) -> PathResult<Vec<String>> {
226        paths.into_iter().map(|p| self.normalize(&p)).collect()
227    }
228
229    /// Parse and resolve a prefix from a path.
230    ///
231    /// Returns (Some(resolved_base), remainder) if a prefix was found,
232    /// or (None, original_path) if no prefix.
233    fn resolve_prefix<'a>(&self, path: &'a str) -> PathResult<(Option<String>, &'a str)> {
234        // Check for prefix pattern: letters followed by colon
235        if let Some(colon_pos) = path.find(':') {
236            let prefix = &path[..colon_pos];
237            let remainder = &path[colon_pos + 1..].trim_start_matches('/');
238
239            // Validate prefix is all lowercase letters (or single letter for Windows drives)
240            if prefix.is_empty() {
241                return Err(ToolError::invalid_path(path, "Empty prefix before colon"));
242            }
243
244            // Check for uppercase in prefix
245            if prefix.chars().any(|c: char| c.is_ascii_uppercase()) {
246                return Err(ToolError::prefix_not_lowercase(prefix));
247            }
248
249            // Check if all characters are ASCII letters
250            if !prefix.chars().all(|c: char| c.is_ascii_lowercase()) {
251                return Err(ToolError::invalid_path(
252                    path,
253                    &format!("Prefix '{}' contains non-letter characters", prefix),
254                ));
255            }
256
257            // Single letter prefix - could be Windows drive
258            if prefix.len() == 1 {
259                // First check if it's in mappings
260                if let Some(base) = self.mappings.get(prefix) {
261                    return Ok((Some(base.clone()), remainder));
262                }
263
264                // Check for Windows drive mapping
265                if self.map_windows_drives {
266                    // Map single letter to Windows drive path (e.g., "c" -> "C:/")
267                    let drive = prefix.to_ascii_uppercase();
268                    let drive_path = format!("{}:/", drive);
269                    return Ok((Some(drive_path), remainder));
270                }
271
272                // Single letter not in mappings and drive mapping disabled
273                return Err(ToolError::unknown_prefix(prefix));
274            }
275
276            // Multi-letter prefix - must be in mappings
277            if let Some(base) = self.mappings.get(prefix) {
278                return Ok((Some(base.clone()), remainder));
279            }
280
281            // Unknown prefix
282            return Err(ToolError::unknown_prefix(prefix));
283        }
284
285        // Check for Windows absolute path (e.g., C:\... or C:/...)
286        if path.len() >= 2 {
287            let first_char = path.chars().next().unwrap();
288            let second_char = path.chars().nth(1).unwrap();
289            if first_char.is_ascii_alphabetic() && second_char == ':' {
290                // This is a Windows absolute path without our prefix system
291                // Just return as-is (no prefix resolution)
292                return Ok((None, path));
293            }
294        }
295
296        // No prefix
297        Ok((None, path))
298    }
299
300    /// Check that a normalized path doesn't escape the sandbox.
301    fn check_sandbox(&self, canonical: &str) -> PathResult<()> {
302        // Normalize both for comparison
303        let canonical_normalized = canonical.to_lowercase();
304        let root_normalized = self.root.to_lowercase();
305
306        // Path must start with root (case-insensitive for cross-platform)
307        if !canonical_normalized.starts_with(&root_normalized) {
308            return Err(ToolError::sandbox_escape(canonical, &self.root));
309        }
310
311        // Additional check: ensure we're not just matching a prefix of a directory name
312        // e.g., root = "/home/user" should not match "/home/username"
313        if canonical_normalized.len() > root_normalized.len() {
314            let next_char = canonical_normalized.chars().nth(root_normalized.len());
315            if next_char != Some('/') && next_char.is_some() {
316                return Err(ToolError::sandbox_escape(canonical, &self.root));
317            }
318        }
319
320        Ok(())
321    }
322
323    /// Convert canonical path to display format based on style.
324    pub fn to_display(&self, canonical: &str) -> String {
325        match self.style {
326            PathStyle::Relative => {
327                // Strip the root prefix to get relative path
328                let root_with_slash = if self.root.ends_with('/') {
329                    self.root.clone()
330                } else {
331                    format!("{}/", self.root)
332                };
333
334                if let Some(relative) = canonical.strip_prefix(&root_with_slash) {
335                    relative.to_string()
336                } else if canonical == self.root {
337                    ".".to_string()
338                } else {
339                    canonical.to_string()
340                }
341            }
342            PathStyle::ProjectPrefixed => {
343                // Same as relative but with ${project}/ prefix
344                let root_with_slash = if self.root.ends_with('/') {
345                    self.root.clone()
346                } else {
347                    format!("{}/", self.root)
348                };
349
350                if let Some(relative) = canonical.strip_prefix(&root_with_slash) {
351                    format!("${{project}}/{}", relative)
352                } else if canonical == self.root {
353                    "${project}".to_string()
354                } else {
355                    canonical.to_string()
356                }
357            }
358        }
359    }
360
361    /// Convert canonical path to actual filesystem path.
362    /// This is where virtual paths become real OS paths.
363    pub fn to_filesystem_path(&self, canonical: &str) -> PathBuf {
364        PathBuf::from(canonical)
365    }
366
367    /// Convert filesystem path back to canonical form.
368    pub fn from_filesystem_path(&self, fs_path: &Path) -> PathResult<String> {
369        // Make path absolute if not already
370        let absolute = if fs_path.is_absolute() {
371            fs_path.to_path_buf()
372        } else {
373            std::env::current_dir()
374                .unwrap_or_else(|_| PathBuf::from("."))
375                .join(fs_path)
376        };
377
378        // Normalize and convert to canonical form
379        let normalized = normalize_path_components(&absolute);
380        let canonical = path_to_forward_slashes(&normalized);
381
382        // Validate sandbox
383        self.check_sandbox(&canonical)?;
384
385        Ok(canonical)
386    }
387
388    /// Get the resolved root.
389    pub fn root(&self) -> &str {
390        &self.root
391    }
392
393    /// Get the path style.
394    pub fn style(&self) -> PathStyle {
395        self.style
396    }
397
398    /// Check if a prefix is defined in mappings.
399    pub fn has_prefix(&self, prefix: &str) -> bool {
400        self.mappings.contains_key(prefix)
401    }
402
403    /// Get all defined prefixes.
404    pub fn prefixes(&self) -> Vec<&str> {
405        self.mappings.keys().map(|s| s.as_str()).collect()
406    }
407}
408
409impl Default for PathMapper {
410    fn default() -> Self {
411        Self::new().expect("Failed to create default PathMapper")
412    }
413}
414
415/// Normalize path components without requiring the file to exist.
416/// Handles `.` and `..` components.
417fn normalize_path_components(path: &Path) -> PathBuf {
418    use std::path::Component;
419
420    let mut components = Vec::new();
421
422    for component in path.components() {
423        match component {
424            Component::Prefix(p) => {
425                // Windows drive prefix (e.g., C:)
426                components.push(Component::Prefix(p));
427            }
428            Component::RootDir => {
429                components.push(Component::RootDir);
430            }
431            Component::CurDir => {
432                // Skip `.` - it refers to current directory
433            }
434            Component::ParentDir => {
435                // Go up one directory if possible
436                if let Some(Component::Normal(_)) = components.last() {
437                    components.pop();
438                } else {
439                    // Can't go up from root, keep the component
440                    // (this handles edge cases like `/../foo`)
441                    components.push(Component::ParentDir);
442                }
443            }
444            Component::Normal(name) => {
445                components.push(Component::Normal(name));
446            }
447        }
448    }
449
450    components.iter().collect()
451}
452
453/// Convert path to string using forward slashes.
454fn path_to_forward_slashes(path: &Path) -> String {
455    path.to_string_lossy().replace('\\', "/")
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn test_default_path_mapper() {
464        let mapper = PathMapper::new().unwrap();
465        assert!(!mapper.root().is_empty());
466    }
467
468    #[test]
469    fn test_normalize_relative_path() {
470        let mapper = PathMapper::new().unwrap();
471        let result = mapper.normalize("src/main.rs").unwrap();
472        assert!(result.contains("src/main.rs"));
473        assert!(result.starts_with(&*mapper.root()));
474    }
475
476    #[test]
477    fn test_normalize_with_dot_components() {
478        let mapper = PathMapper::new().unwrap();
479        let result = mapper.normalize("./src/../src/main.rs").unwrap();
480        assert!(result.ends_with("/src/main.rs"));
481    }
482
483    #[test]
484    fn test_sandbox_escape_blocked() {
485        let mapper = PathMapper::new().unwrap();
486        // Try to escape with ..
487        let result = mapper.normalize("../../../etc/passwd");
488        assert!(result.is_err());
489        if let Err(e) = result {
490            assert_eq!(e.code, crate::error::ErrorCode::InvalidPath);
491        }
492    }
493
494    #[test]
495    fn test_prefix_must_be_lowercase() {
496        let mapper = PathMapper::new().unwrap();
497        let result = mapper.normalize("HOME:projects/foo");
498        assert!(result.is_err());
499        if let Err(e) = result {
500            assert_eq!(e.code, crate::error::ErrorCode::InvalidPrefix);
501        }
502    }
503
504    #[test]
505    fn test_unknown_prefix_rejected() {
506        let mapper = PathMapper::new().unwrap();
507        let result = mapper.normalize("unknown:path/to/file");
508        assert!(result.is_err());
509        if let Err(e) = result {
510            assert_eq!(e.code, crate::error::ErrorCode::InvalidPrefix);
511        }
512    }
513
514    #[test]
515    fn test_display_relative_style() {
516        let mapper = PathMapper::new().unwrap();
517        let canonical = mapper.normalize("src/main.rs").unwrap();
518        let display = mapper.to_display(&canonical);
519        assert_eq!(display, "src/main.rs");
520    }
521
522    #[test]
523    fn test_round_trip_filesystem_path() {
524        let mapper = PathMapper::new().unwrap();
525        let original = "src/main.rs";
526        let canonical = mapper.normalize(original).unwrap();
527        let fs_path = mapper.to_filesystem_path(&canonical);
528        let back = mapper.from_filesystem_path(&fs_path).unwrap();
529        assert_eq!(canonical, back);
530    }
531
532    #[test]
533    fn test_normalize_all() {
534        let mapper = PathMapper::new().unwrap();
535        let paths = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
536        let results = mapper.normalize_all(paths).unwrap();
537        assert_eq!(results.len(), 2);
538        assert!(results[0].ends_with("/src/main.rs"));
539        assert!(results[1].ends_with("/src/lib.rs"));
540    }
541
542    #[test]
543    fn test_config_with_mappings() {
544        let mut config = PathsConfig::default();
545        config.mappings.insert("test".to_string(), ".".to_string());
546
547        let mapper = PathMapper::from_config(&config, None).unwrap();
548        assert!(mapper.has_prefix("test"));
549    }
550
551    #[test]
552    fn test_normalize_path_components() {
553        let path = Path::new("/foo/bar/../baz/./qux");
554        let normalized = normalize_path_components(path);
555        let result = path_to_forward_slashes(&normalized);
556        assert_eq!(result, "/foo/baz/qux");
557    }
558
559    #[test]
560    fn test_path_to_forward_slashes() {
561        let path = Path::new("foo\\bar\\baz");
562        let result = path_to_forward_slashes(path);
563        assert_eq!(result, "foo/bar/baz");
564    }
565
566    #[test]
567    fn test_uppercase_prefix_in_config_rejected() {
568        let mut config = PathsConfig::default();
569        config.mappings.insert("Home".to_string(), ".".to_string());
570
571        let result = PathMapper::from_config(&config, None);
572        assert!(result.is_err());
573    }
574
575    #[cfg(windows)]
576    #[test]
577    fn test_windows_drive_mapping() {
578        let mut config = PathsConfig::default();
579        config.map_windows_drives = true;
580
581        let mapper = PathMapper::from_config(&config, None).unwrap();
582        // Note: This test would need a valid Windows path within the sandbox
583        // For now, just verify the mapper was created
584        assert!(mapper.map_windows_drives);
585    }
586}