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