Skip to main content

anycms_spa/core/
path.rs

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
12/// 路径规范化:解析 `.` 和 `..`,去除连续斜杠,防止路径遍历
13pub 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
31/// 合并连续斜杠为单个 `/`
32pub 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
49/// 提取相对于基路径的资源路径
50pub 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}