1use crate::config::{Config, PathStyle, PathsConfig};
12use crate::error::ToolError;
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16pub type PathResult<T> = Result<T, ToolError>;
18
19#[derive(Debug, Clone)]
21pub struct PathMapper {
22 root: String,
24 mappings: HashMap<String, String>,
26 map_windows_drives: bool,
28 style: PathStyle,
30}
31
32impl PathMapper {
33 pub fn from_config(config: &PathsConfig, full_config: Option<&Config>) -> PathResult<Self> {
38 let root = Self::resolve_root(&config.root)?;
40
41 let mut mappings = HashMap::new();
43 for (prefix, value) in &config.mappings {
44 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 pub fn new() -> PathResult<Self> {
63 Self::from_config(&PathsConfig::default(), None)
64 }
65
66 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 let normalized = normalize_path_components(&root_path);
83 Ok(path_to_forward_slashes(&normalized))
84 }
85
86 fn resolve_mapping_value(
88 value: &str,
89 root: &str,
90 full_config: Option<&Config>,
91 ) -> PathResult<String> {
92 if value == "." {
94 return Ok(root.to_string());
95 }
96
97 if let Some(env_var) = value.strip_prefix('$') {
99 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 return match std::env::var(env_var) {
106 Ok(val) => {
107 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 let path = Path::new(value);
128 let absolute = if path.is_absolute() {
129 path.to_path_buf()
130 } else {
131 PathBuf::from(root).join(path)
133 };
134 let normalized = normalize_path_components(&absolute);
135 Ok(path_to_forward_slashes(&normalized))
136 }
137
138 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 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 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 pub fn normalize(&self, path: &str) -> PathResult<String> {
194 let (resolved_base, remainder) = self.resolve_prefix(path)?;
196
197 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 if Path::new(remainder).is_absolute() {
207 remainder.to_string()
208 } else {
209 format!("{}/{}", self.root.trim_end_matches('/'), remainder)
210 }
211 };
212
213 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 self.check_sandbox(&canonical)?;
220
221 Ok(canonical)
222 }
223
224 pub fn normalize_all(&self, paths: Vec<String>) -> PathResult<Vec<String>> {
226 paths.into_iter().map(|p| self.normalize(&p)).collect()
227 }
228
229 fn resolve_prefix<'a>(&self, path: &'a str) -> PathResult<(Option<String>, &'a str)> {
234 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 if prefix.is_empty() {
241 return Err(ToolError::invalid_path(path, "Empty prefix before colon"));
242 }
243
244 if prefix.chars().any(|c: char| c.is_ascii_uppercase()) {
246 return Err(ToolError::prefix_not_lowercase(prefix));
247 }
248
249 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 if prefix.len() == 1 {
259 if let Some(base) = self.mappings.get(prefix) {
261 return Ok((Some(base.clone()), remainder));
262 }
263
264 if self.map_windows_drives {
266 let drive = prefix.to_ascii_uppercase();
268 let drive_path = format!("{}:/", drive);
269 return Ok((Some(drive_path), remainder));
270 }
271
272 return Err(ToolError::unknown_prefix(prefix));
274 }
275
276 if let Some(base) = self.mappings.get(prefix) {
278 return Ok((Some(base.clone()), remainder));
279 }
280
281 return Err(ToolError::unknown_prefix(prefix));
283 }
284
285 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 return Ok((None, path));
293 }
294 }
295
296 Ok((None, path))
298 }
299
300 fn check_sandbox(&self, canonical: &str) -> PathResult<()> {
302 let canonical_normalized = canonical.to_lowercase();
304 let root_normalized = self.root.to_lowercase();
305
306 if !canonical_normalized.starts_with(&root_normalized) {
308 return Err(ToolError::sandbox_escape(canonical, &self.root));
309 }
310
311 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 pub fn to_display(&self, canonical: &str) -> String {
325 match self.style {
326 PathStyle::Relative => {
327 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 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 pub fn to_filesystem_path(&self, canonical: &str) -> PathBuf {
364 PathBuf::from(canonical)
365 }
366
367 pub fn from_filesystem_path(&self, fs_path: &Path) -> PathResult<String> {
369 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 let normalized = normalize_path_components(&absolute);
380 let canonical = path_to_forward_slashes(&normalized);
381
382 self.check_sandbox(&canonical)?;
384
385 Ok(canonical)
386 }
387
388 pub fn root(&self) -> &str {
390 &self.root
391 }
392
393 pub fn style(&self) -> PathStyle {
395 self.style
396 }
397
398 pub fn has_prefix(&self, prefix: &str) -> bool {
400 self.mappings.contains_key(prefix)
401 }
402
403 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
415fn 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 components.push(Component::Prefix(p));
427 }
428 Component::RootDir => {
429 components.push(Component::RootDir);
430 }
431 Component::CurDir => {
432 }
434 Component::ParentDir => {
435 if let Some(Component::Normal(_)) = components.last() {
437 components.pop();
438 } else {
439 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
453fn 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 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 assert!(mapper.map_windows_drives);
585 }
586}