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
32#[allow(clippy::result_large_err)]
33impl PathMapper {
34 pub fn from_config(config: &PathsConfig, full_config: Option<&Config>) -> PathResult<Self> {
39 let root = Self::resolve_root(&config.root)?;
41
42 let mut mappings = HashMap::new();
44 for (prefix, value) in &config.mappings {
45 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 pub fn new() -> PathResult<Self> {
64 Self::from_config(&PathsConfig::default(), None)
65 }
66
67 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 let normalized = normalize_path_components(&root_path);
84 Ok(path_to_forward_slashes(&normalized))
85 }
86
87 fn resolve_mapping_value(
89 value: &str,
90 root: &str,
91 full_config: Option<&Config>,
92 ) -> PathResult<String> {
93 if value == "." {
95 return Ok(root.to_string());
96 }
97
98 if let Some(env_var) = value.strip_prefix('$') {
100 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 return match std::env::var(env_var) {
107 Ok(val) => {
108 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 let path = Path::new(value);
129 let absolute = if path.is_absolute() {
130 path.to_path_buf()
131 } else {
132 PathBuf::from(root).join(path)
134 };
135 let normalized = normalize_path_components(&absolute);
136 Ok(path_to_forward_slashes(&normalized))
137 }
138
139 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 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 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 pub fn normalize(&self, path: &str) -> PathResult<String> {
195 let (resolved_base, remainder) = self.resolve_prefix(path)?;
197
198 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 if Path::new(remainder).is_absolute() {
208 remainder.to_string()
209 } else {
210 format!("{}/{}", self.root.trim_end_matches('/'), remainder)
211 }
212 };
213
214 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 self.check_sandbox(&canonical)?;
221
222 Ok(canonical)
223 }
224
225 pub fn normalize_all(&self, paths: Vec<String>) -> PathResult<Vec<String>> {
227 paths.into_iter().map(|p| self.normalize(&p)).collect()
228 }
229
230 fn resolve_prefix<'a>(&self, path: &'a str) -> PathResult<(Option<String>, &'a str)> {
235 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 if prefix.is_empty() {
242 return Err(ToolError::invalid_path(path, "Empty prefix before colon"));
243 }
244
245 if prefix.chars().any(|c: char| c.is_ascii_uppercase()) {
247 return Err(ToolError::prefix_not_lowercase(prefix));
248 }
249
250 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 if prefix.len() == 1 {
260 if let Some(base) = self.mappings.get(prefix) {
262 return Ok((Some(base.clone()), remainder));
263 }
264
265 if self.map_windows_drives {
267 let drive = prefix.to_ascii_uppercase();
269 let drive_path = format!("{}:/", drive);
270 return Ok((Some(drive_path), remainder));
271 }
272
273 return Err(ToolError::unknown_prefix(prefix));
275 }
276
277 if let Some(base) = self.mappings.get(prefix) {
279 return Ok((Some(base.clone()), remainder));
280 }
281
282 return Err(ToolError::unknown_prefix(prefix));
284 }
285
286 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 return Ok((None, path));
294 }
295 }
296
297 Ok((None, path))
299 }
300
301 fn check_sandbox(&self, canonical: &str) -> PathResult<()> {
303 let canonical_normalized = canonical.to_lowercase();
305 let root_normalized = self.root.to_lowercase();
306
307 if !canonical_normalized.starts_with(&root_normalized) {
309 return Err(ToolError::sandbox_escape(canonical, &self.root));
310 }
311
312 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 pub fn to_display(&self, canonical: &str) -> String {
326 match self.style {
327 PathStyle::Relative => {
328 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 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 pub fn to_filesystem_path(&self, canonical: &str) -> PathBuf {
365 PathBuf::from(canonical)
366 }
367
368 pub fn from_filesystem_path(&self, fs_path: &Path) -> PathResult<String> {
370 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 let normalized = normalize_path_components(&absolute);
381 let canonical = path_to_forward_slashes(&normalized);
382
383 self.check_sandbox(&canonical)?;
385
386 Ok(canonical)
387 }
388
389 pub fn root(&self) -> &str {
391 &self.root
392 }
393
394 pub fn style(&self) -> PathStyle {
396 self.style
397 }
398
399 pub fn has_prefix(&self, prefix: &str) -> bool {
401 self.mappings.contains_key(prefix)
402 }
403
404 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
416fn 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 components.push(Component::Prefix(p));
428 }
429 Component::RootDir => {
430 components.push(Component::RootDir);
431 }
432 Component::CurDir => {
433 }
435 Component::ParentDir => {
436 if let Some(Component::Normal(_)) = components.last() {
438 components.pop();
439 } else {
440 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
454fn 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 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 assert!(mapper.map_windows_drives);
588 }
589}