1use crate::error::{Error, Result};
28use std::convert::TryFrom;
29use std::fmt;
30use std::path::{Path, PathBuf};
31
32const MAX_PATH_DEPTH: usize = 20;
34
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub struct SafePath {
41 inner: PathBuf,
43}
44
45impl SafePath {
46 pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
71 let path = path.as_ref();
72 Self::validate_path(path)?;
73 Ok(Self {
74 inner: Self::normalize_path(path),
75 })
76 }
77
78 pub fn join<P: AsRef<Path>>(&self, path: P) -> Result<Self> {
95 let joined = self.inner.join(path.as_ref());
96 Self::validate_path(&joined)?;
97 Ok(Self {
98 inner: Self::normalize_path(&joined),
99 })
100 }
101
102 #[must_use]
113 pub fn as_path(&self) -> &Path {
114 &self.inner
115 }
116
117 #[must_use]
129 pub fn into_path_buf(self) -> PathBuf {
130 self.inner
131 }
132
133 #[must_use]
145 pub fn as_path_buf(&self) -> PathBuf {
146 self.inner.clone()
147 }
148
149 pub fn new_absolute<P: AsRef<Path>>(path: P) -> Result<Self> {
160 let path = path.as_ref();
161 if path.as_os_str().is_empty() {
166 return Err(Error::invalid_input("Path cannot be empty"));
167 }
168
169 for component in path.components() {
171 if let std::path::Component::ParentDir = component {
172 return Err(Error::invalid_input(format!(
173 "Path contains parent directory reference (..): {}",
174 path.display()
175 )));
176 }
177 }
178
179 Ok(Self {
181 inner: path.to_path_buf(),
182 })
183 }
184
185 pub fn current_dir() -> Result<Self> {
196 let cwd = std::env::current_dir()
197 .map_err(|e| Error::new(&format!("Failed to get current directory: {}", e)))?;
198 Self::new_absolute(cwd)
199 }
200
201 pub fn parent(&self) -> Result<Self> {
213 let parent_path = self
214 .inner
215 .parent()
216 .ok_or_else(|| Error::invalid_input("Path has no parent"))?;
217 if parent_path.is_absolute() {
219 Self::new_absolute(parent_path)
220 } else {
221 Self::new(parent_path)
222 }
223 }
224
225 #[must_use]
236 pub fn exists(&self) -> bool {
237 self.inner.exists()
238 }
239
240 fn validate_path(path: &Path) -> Result<()> {
248 if path.as_os_str().is_empty() {
250 return Err(Error::invalid_input("Path cannot be empty"));
251 }
252
253 let normalized = Self::normalize_path(path);
255
256 let components: Vec<_> = normalized.components().collect();
258
259 if components.len() > MAX_PATH_DEPTH {
261 return Err(Error::invalid_input(format!(
262 "Path depth {} exceeds maximum allowed depth of {}",
263 components.len(),
264 MAX_PATH_DEPTH
265 )));
266 }
267
268 for component in path.components() {
270 if let std::path::Component::ParentDir = component {
271 return Err(Error::invalid_input(format!(
272 "Path contains parent directory reference (..): {}",
273 path.display()
274 )));
275 }
276 }
277
278 for component in &components {
280 if let std::path::Component::Normal(os_str) = component {
281 if let Some(s) = os_str.to_str() {
282 if s.trim().is_empty() {
283 return Err(Error::invalid_input(
284 "Path components cannot be empty or whitespace-only",
285 ));
286 }
287 }
288 }
289 }
290
291 Ok(())
292 }
293
294 fn normalize_path(path: &Path) -> PathBuf {
299 let mut normalized = PathBuf::new();
300
301 for component in path.components() {
302 match component {
303 std::path::Component::CurDir => {
304 }
306 std::path::Component::Normal(_) => {
307 normalized.push(component);
308 }
309 _ => {
310 normalized.push(component);
313 }
314 }
315 }
316
317 normalized
318 }
319}
320
321impl TryFrom<&str> for SafePath {
322 type Error = Error;
323
324 fn try_from(value: &str) -> Result<Self> {
325 Self::new(value)
326 }
327}
328
329impl TryFrom<String> for SafePath {
330 type Error = Error;
331
332 fn try_from(value: String) -> Result<Self> {
333 Self::new(value)
334 }
335}
336
337impl TryFrom<&String> for SafePath {
338 type Error = Error;
339
340 fn try_from(value: &String) -> Result<Self> {
341 Self::new(value)
342 }
343}
344
345impl TryFrom<PathBuf> for SafePath {
346 type Error = Error;
347
348 fn try_from(value: PathBuf) -> Result<Self> {
349 Self::new(value)
350 }
351}
352
353impl TryFrom<&Path> for SafePath {
354 type Error = Error;
355
356 fn try_from(value: &Path) -> Result<Self> {
357 Self::new(value)
358 }
359}
360
361impl fmt::Display for SafePath {
362 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
363 write!(f, "{}", self.inner.display())
364 }
365}
366
367impl AsRef<Path> for SafePath {
368 fn as_ref(&self) -> &Path {
369 &self.inner
370 }
371}
372
373impl AsRef<PathBuf> for SafePath {
374 fn as_ref(&self) -> &PathBuf {
375 &self.inner
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
386 fn test_new_simple_path() {
387 let path = "src/generated";
389
390 let result = SafePath::new(path);
392
393 assert!(result.is_ok());
395 let safe_path = result.unwrap();
396 assert_eq!(safe_path.as_path().to_str().unwrap(), "src/generated");
397 }
398
399 #[test]
400 fn test_new_single_component() {
401 let path = "test";
403
404 let result = SafePath::new(path);
406
407 assert!(result.is_ok());
409 assert_eq!(result.unwrap().as_path().to_str().unwrap(), "test");
410 }
411
412 #[test]
413 fn test_new_with_current_dir_prefix() {
414 let path = "./output";
416
417 let result = SafePath::new(path);
419
420 assert!(result.is_ok());
422 assert_eq!(result.unwrap().as_path().to_str().unwrap(), "output");
424 }
425
426 #[test]
427 fn test_new_with_multiple_current_dirs() {
428 let path = "./src/./generated/./output";
430
431 let result = SafePath::new(path);
433
434 assert!(result.is_ok());
436 assert_eq!(
437 result.unwrap().as_path().to_str().unwrap(),
438 "src/generated/output"
439 );
440 }
441
442 #[test]
445 fn test_new_parent_dir_fails() {
446 let path = "../etc/passwd";
448
449 let result = SafePath::new(path);
451
452 assert!(result.is_err());
454 let err = result.unwrap_err();
455 assert!(err.to_string().contains("parent directory reference"));
456 }
457
458 #[test]
459 fn test_new_parent_dir_in_middle_fails() {
460 let path = "src/../../../etc/passwd";
462
463 let result = SafePath::new(path);
465
466 assert!(result.is_err());
468 assert!(result.unwrap_err().to_string().contains("parent directory"));
469 }
470
471 #[test]
472 fn test_new_parent_dir_at_end_fails() {
473 let path = "src/generated/..";
475
476 let result = SafePath::new(path);
478
479 assert!(result.is_err());
481 assert!(result.unwrap_err().to_string().contains("parent directory"));
482 }
483
484 #[test]
485 fn test_new_multiple_parent_dirs_fails() {
486 let path = "../../../../../../etc/passwd";
488
489 let result = SafePath::new(path);
491
492 assert!(result.is_err());
494 assert!(result.unwrap_err().to_string().contains("parent directory"));
495 }
496
497 #[test]
500 fn test_new_empty_path_fails() {
501 let path = "";
503
504 let result = SafePath::new(path);
506
507 assert!(result.is_err());
509 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
510 }
511
512 #[test]
513 fn test_new_whitespace_component_fails() {
514 let path = "src/ /generated";
516
517 let result = SafePath::new(path);
519
520 assert!(result.is_err());
522 assert!(result.unwrap_err().to_string().contains("whitespace-only"));
523 }
524
525 #[test]
528 fn test_new_max_depth_allowed() {
529 let components: Vec<String> = (0..MAX_PATH_DEPTH).map(|i| format!("level{}", i)).collect();
531 let path = components.join("/");
532
533 let result = SafePath::new(&path);
535
536 assert!(result.is_ok());
538 }
539
540 #[test]
541 fn test_new_exceeds_max_depth_fails() {
542 let components: Vec<String> = (0..=MAX_PATH_DEPTH)
544 .map(|i| format!("level{}", i))
545 .collect();
546 let path = components.join("/");
547
548 let result = SafePath::new(&path);
550
551 assert!(result.is_err());
553 let err = result.unwrap_err();
554 assert!(err.to_string().contains("exceeds maximum allowed depth"));
555 assert!(err.to_string().contains("20"));
556 }
557
558 #[test]
561 fn test_join_simple() {
562 let base = SafePath::new("src").unwrap();
564
565 let result = base.join("generated");
567
568 assert!(result.is_ok());
570 assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
571 }
572
573 #[test]
574 fn test_join_multiple_times() {
575 let base = SafePath::new("src").unwrap();
577
578 let step1 = base.join("generated").unwrap();
580 let step2 = step1.join("output").unwrap();
581 let result = step2.join("file.rs");
582
583 assert!(result.is_ok());
585 assert_eq!(
586 result.unwrap().as_path().to_str().unwrap(),
587 "src/generated/output/file.rs"
588 );
589 }
590
591 #[test]
592 fn test_join_with_parent_dir_fails() {
593 let base = SafePath::new("src").unwrap();
595
596 let result = base.join("../etc");
598
599 assert!(result.is_err());
601 assert!(result.unwrap_err().to_string().contains("parent directory"));
602 }
603
604 #[test]
605 fn test_join_exceeding_depth_fails() {
606 let components: Vec<String> = (0..18).map(|i| format!("level{}", i)).collect();
608 let base = SafePath::new(components.join("/")).unwrap();
609
610 let result = base.join("level18/level19/level20");
612
613 assert!(result.is_err());
615 assert!(result
616 .unwrap_err()
617 .to_string()
618 .contains("exceeds maximum allowed depth"));
619 }
620
621 #[test]
624 fn test_try_from_str() {
625 let path = "src/generated";
627
628 let result = SafePath::try_from(path);
630
631 assert!(result.is_ok());
633 assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
634 }
635
636 #[test]
637 fn test_try_from_string() {
638 let path = String::from("src/generated");
640
641 let result = SafePath::try_from(path);
643
644 assert!(result.is_ok());
646 assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
647 }
648
649 #[test]
650 fn test_try_from_string_ref() {
651 let path = String::from("src/generated");
653
654 let result = SafePath::try_from(&path);
656
657 assert!(result.is_ok());
659 assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
660 }
661
662 #[test]
663 fn test_try_from_path_buf() {
664 let path = PathBuf::from("src/generated");
666
667 let result = SafePath::try_from(path);
669
670 assert!(result.is_ok());
672 assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
673 }
674
675 #[test]
676 fn test_try_from_path() {
677 let path = Path::new("src/generated");
679
680 let result = SafePath::try_from(path);
682
683 assert!(result.is_ok());
685 assert_eq!(result.unwrap().as_path().to_str().unwrap(), "src/generated");
686 }
687
688 #[test]
689 fn test_try_from_invalid_str_fails() {
690 let path = "../etc/passwd";
692
693 let result = SafePath::try_from(path);
695
696 assert!(result.is_err());
698 assert!(result.unwrap_err().to_string().contains("parent directory"));
699 }
700
701 #[test]
704 fn test_display_trait() {
705 let safe = SafePath::new("src/generated").unwrap();
707
708 let display_string = format!("{}", safe);
710
711 assert_eq!(display_string, "src/generated");
713 }
714
715 #[test]
716 fn test_debug_trait() {
717 let safe = SafePath::new("src/generated").unwrap();
719
720 let debug_string = format!("{:?}", safe);
722
723 assert!(debug_string.contains("SafePath"));
725 assert!(debug_string.contains("src/generated"));
726 }
727
728 #[test]
729 fn test_as_ref_path() {
730 let safe = SafePath::new("src/generated").unwrap();
732
733 let path_ref: &Path = safe.as_ref();
735
736 assert_eq!(path_ref.to_str().unwrap(), "src/generated");
738 }
739
740 #[test]
741 fn test_as_ref_path_buf() {
742 let safe = SafePath::new("src/generated").unwrap();
744
745 let path_buf_ref: &PathBuf = safe.as_ref();
747
748 assert_eq!(path_buf_ref.to_str().unwrap(), "src/generated");
750 }
751
752 #[test]
755 fn test_equality() {
756 let safe1 = SafePath::new("src/generated").unwrap();
758 let safe2 = SafePath::new("src/generated").unwrap();
759 let safe3 = SafePath::new("src/output").unwrap();
760
761 assert_eq!(safe1, safe2);
763 assert_ne!(safe1, safe3);
764 }
765
766 #[test]
767 fn test_clone() {
768 let safe = SafePath::new("src/generated").unwrap();
770
771 let cloned = safe.clone();
773
774 assert_eq!(safe, cloned);
776 assert_eq!(safe.as_path(), cloned.as_path());
777 }
778
779 #[test]
782 fn test_normalization_removes_current_dir() {
783 let path = "./src/./generated/./output";
785
786 let safe = SafePath::new(path).unwrap();
788
789 assert_eq!(safe.as_path().to_str().unwrap(), "src/generated/output");
791 }
792
793 #[test]
794 fn test_into_path_buf() {
795 let safe = SafePath::new("src/generated").unwrap();
797
798 let path_buf = safe.into_path_buf();
800
801 assert_eq!(path_buf.to_str().unwrap(), "src/generated");
803 }
804
805 #[test]
808 fn test_path_with_file_extension() {
809 let path = "src/generated/output.rs";
811
812 let result = SafePath::new(path);
814
815 assert!(result.is_ok());
817 assert_eq!(
818 result.unwrap().as_path().to_str().unwrap(),
819 "src/generated/output.rs"
820 );
821 }
822
823 #[test]
824 fn test_path_with_special_chars_in_name() {
825 let path = "src/my-project_v2.0/output";
827
828 let result = SafePath::new(path);
830
831 assert!(result.is_ok());
833 assert_eq!(
834 result.unwrap().as_path().to_str().unwrap(),
835 "src/my-project_v2.0/output"
836 );
837 }
838}