1use std::path::{Component, Path, PathBuf};
2use thiserror::Error;
3
4#[derive(Debug, Error, PartialEq)]
5pub enum PathError {
6 #[error("Invalid path: {0}")]
7 InvalidPath(String),
8 #[error("Path traversal attempt detected")]
9 PathTraversal,
10}
11
12pub fn normalize_path(path: &str) -> Result<String, PathError> {
14 let mut buf = PathBuf::new();
15 for comp in Path::new(path).components() {
16 match comp {
17 Component::Normal(name) => {
18 buf.push(name);
19 }
20 Component::ParentDir if !buf.pop() => {
21 return Err(PathError::PathTraversal);
22 }
23 Component::ParentDir => {}
24 _ => {}
25 }
26 }
27
28 Ok(buf.to_string_lossy().to_string())
29}
30
31pub fn collapse_slashes(path: &str) -> String {
33 let mut result = String::with_capacity(path.len());
34 let mut prev_slash = false;
35 for ch in path.chars() {
36 if ch == '/' {
37 if !prev_slash {
38 result.push(ch);
39 }
40 prev_slash = true;
41 } else {
42 result.push(ch);
43 prev_slash = false;
44 }
45 }
46 result
47}
48
49pub fn relative_to_base(path: &str, base: &str) -> String {
51 let base = base.trim_matches('/');
52 if base.is_empty() {
53 return path.trim_start_matches('/').to_string();
54 }
55 let base_with_slash = format!("{}/", base);
56 let path_no_lead = path.trim_start_matches('/');
57 if let Some(stripped) = path_no_lead.strip_prefix(&base_with_slash) {
58 if stripped.is_empty() {
59 "index.html".to_string()
60 } else {
61 stripped.to_string()
62 }
63 } else if path_no_lead == base {
64 "index.html".to_string()
65 } else {
66 path_no_lead.to_string()
67 }
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73
74 #[test]
75 fn test_normalize_path() {
76 assert_eq!(normalize_path("a/b/../c").unwrap(), "a/c");
77 assert_eq!(normalize_path("/a//b/").unwrap(), "a/b");
78 assert_eq!(normalize_path("").unwrap(), "");
79 assert_eq!(normalize_path("css/style.css").unwrap(), "css/style.css");
80 assert_eq!(normalize_path("a/../../etc/passwd"), Err(PathError::PathTraversal));
81 }
82
83 #[test]
84 fn test_collapse_slashes() {
85 assert_eq!(collapse_slashes("/a//b///c/"), "/a/b/c/");
86 assert_eq!(collapse_slashes("/"), "/");
87 assert_eq!(collapse_slashes("///"), "/");
88 assert_eq!(collapse_slashes("css//style.css"), "css/style.css");
89 }
90
91 #[test]
92 fn test_relative_to_base() {
93 assert_eq!(relative_to_base("/app/index.html", "app"), "index.html");
94 assert_eq!(relative_to_base("/app/", "app"), "index.html");
95 assert_eq!(relative_to_base("/app/css/style.css", "app"), "css/style.css");
96 assert_eq!(relative_to_base("/other", "app"), "other");
97 assert_eq!(relative_to_base("/css/style.css", "/"), "css/style.css");
98 }
99}