1use std::path::{Path, PathBuf};
14
15use percent_encoding::percent_decode_str;
16
17#[must_use]
20pub fn normalize_base_path(raw: &str) -> String {
21 let trimmed = raw.trim_matches('/');
22 if trimmed.is_empty() {
23 "/".to_string()
24 } else {
25 format!("/{trimmed}/")
26 }
27}
28
29#[must_use]
37pub fn strip_base_path<'a>(path: &'a str, base_path: &str) -> Option<&'a str> {
38 if base_path == "/" {
39 return Some(path.trim_start_matches('/'));
40 }
41 let without_trailing = base_path.trim_end_matches('/');
42 if path == without_trailing || path == base_path {
43 return Some("");
44 }
45 path.strip_prefix(base_path)
46}
47
48#[must_use]
59pub fn resolve_safe_path(out_dir: &Path, remainder: &str) -> Option<PathBuf> {
60 let trailing_slash = remainder.is_empty() || remainder.ends_with('/');
61 let trimmed = remainder.trim_matches('/');
62
63 let mut buf = out_dir.to_path_buf();
64 if !trimmed.is_empty() {
65 for raw_segment in trimmed.split('/') {
66 let decoded = percent_decode_str(raw_segment).decode_utf8().ok()?;
67 let s = decoded.as_ref();
68 if s.is_empty() || s == "." || s == ".." {
69 return None;
70 }
71 if s.contains('\\') || s.contains('\0') {
72 return None;
73 }
74 if s.starts_with('/') {
75 return None;
76 }
77 if has_drive_letter_prefix(s) {
80 return None;
81 }
82 buf.push(s);
83 }
84 }
85
86 if trailing_slash {
87 buf.push("index.html");
88 }
89 Some(buf)
90}
91
92fn has_drive_letter_prefix(s: &str) -> bool {
96 let mut chars = s.chars();
97 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic()) && matches!(chars.next(), Some(':'))
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn normalize_base_path_handles_root() {
106 assert_eq!(normalize_base_path("/"), "/");
107 assert_eq!(normalize_base_path(""), "/");
108 }
109
110 #[test]
111 fn normalize_base_path_adds_slashes() {
112 assert_eq!(normalize_base_path("webui"), "/webui/");
113 assert_eq!(normalize_base_path("/webui"), "/webui/");
114 assert_eq!(normalize_base_path("/webui/"), "/webui/");
115 assert_eq!(normalize_base_path("a/b"), "/a/b/");
116 }
117
118 #[test]
119 fn strip_base_path_root() {
120 assert_eq!(strip_base_path("/", "/"), Some(""));
121 assert_eq!(strip_base_path("/foo.css", "/"), Some("foo.css"));
122 assert_eq!(strip_base_path("/guide/intro/", "/"), Some("guide/intro/"));
123 }
124
125 #[test]
126 fn strip_base_path_segment_boundary() {
127 assert_eq!(strip_base_path("/webui/", "/webui/"), Some(""));
128 assert_eq!(strip_base_path("/webui", "/webui/"), Some(""));
129 assert_eq!(strip_base_path("/webui/guide/", "/webui/"), Some("guide/"));
130 assert_eq!(
131 strip_base_path("/webui/foo.css", "/webui/"),
132 Some("foo.css")
133 );
134 }
135
136 #[test]
137 fn strip_base_path_rejects_similar_prefix() {
138 assert_eq!(strip_base_path("/webui-evil/x", "/webui/"), None);
139 assert_eq!(strip_base_path("/webuixyz", "/webui/"), None);
140 assert_eq!(strip_base_path("/", "/webui/"), None);
141 assert_eq!(strip_base_path("/other/foo", "/webui/"), None);
142 }
143
144 #[test]
145 fn resolve_safe_path_basic() {
146 let out = Path::new("/tmp/dist");
147 assert_eq!(
148 resolve_safe_path(out, "").as_deref(),
149 Some(Path::new("/tmp/dist/index.html"))
150 );
151 assert_eq!(
152 resolve_safe_path(out, "foo.css").as_deref(),
153 Some(Path::new("/tmp/dist/foo.css"))
154 );
155 assert_eq!(
156 resolve_safe_path(out, "guide/intro/").as_deref(),
157 Some(Path::new("/tmp/dist/guide/intro/index.html"))
158 );
159 }
160
161 #[test]
162 fn resolve_safe_path_rejects_traversal() {
163 let out = Path::new("/tmp/dist");
164 assert_eq!(resolve_safe_path(out, "../etc/passwd"), None);
165 assert_eq!(resolve_safe_path(out, "foo/../../bar"), None);
166 assert_eq!(resolve_safe_path(out, "%2e%2e/etc"), None);
167 assert_eq!(resolve_safe_path(out, "foo/%2e%2e/bar"), None);
168 assert_eq!(resolve_safe_path(out, "foo//bar"), None);
169 assert_eq!(resolve_safe_path(out, "foo\\bar"), None);
170 assert_eq!(resolve_safe_path(out, "./foo"), None);
171 }
172
173 #[test]
174 fn resolve_safe_path_decodes_legal_percent() {
175 let out = Path::new("/tmp/dist");
176 assert_eq!(
177 resolve_safe_path(out, "my%20file.html").as_deref(),
178 Some(Path::new("/tmp/dist/my file.html"))
179 );
180 }
181
182 #[test]
183 fn resolve_safe_path_rejects_drive_letters() {
184 let out = Path::new("/tmp/dist");
185 assert_eq!(resolve_safe_path(out, "C:/Windows/System32"), None);
186 assert_eq!(resolve_safe_path(out, "c:foo"), None);
187 assert_eq!(resolve_safe_path(out, "foo/C:/bar"), None);
188 assert_eq!(resolve_safe_path(out, "C%3A/win"), None);
190 }
191}