use std::path::{Path, PathBuf};
use percent_encoding::percent_decode_str;
#[must_use]
pub fn normalize_base_path(raw: &str) -> String {
let trimmed = raw.trim_matches('/');
if trimmed.is_empty() {
"/".to_string()
} else {
format!("/{trimmed}/")
}
}
#[must_use]
pub fn strip_base_path<'a>(path: &'a str, base_path: &str) -> Option<&'a str> {
if base_path == "/" {
return Some(path.trim_start_matches('/'));
}
let without_trailing = base_path.trim_end_matches('/');
if path == without_trailing || path == base_path {
return Some("");
}
path.strip_prefix(base_path)
}
#[must_use]
pub fn resolve_safe_path(out_dir: &Path, remainder: &str) -> Option<PathBuf> {
let trailing_slash = remainder.is_empty() || remainder.ends_with('/');
let trimmed = remainder.trim_matches('/');
let mut buf = out_dir.to_path_buf();
if !trimmed.is_empty() {
for raw_segment in trimmed.split('/') {
let decoded = percent_decode_str(raw_segment).decode_utf8().ok()?;
let s = decoded.as_ref();
if s.is_empty() || s == "." || s == ".." {
return None;
}
if s.contains('\\') || s.contains('\0') {
return None;
}
if s.starts_with('/') {
return None;
}
if has_drive_letter_prefix(s) {
return None;
}
buf.push(s);
}
}
if trailing_slash {
buf.push("index.html");
}
Some(buf)
}
fn has_drive_letter_prefix(s: &str) -> bool {
let mut chars = s.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphabetic()) && matches!(chars.next(), Some(':'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_base_path_handles_root() {
assert_eq!(normalize_base_path("/"), "/");
assert_eq!(normalize_base_path(""), "/");
}
#[test]
fn normalize_base_path_adds_slashes() {
assert_eq!(normalize_base_path("webui"), "/webui/");
assert_eq!(normalize_base_path("/webui"), "/webui/");
assert_eq!(normalize_base_path("/webui/"), "/webui/");
assert_eq!(normalize_base_path("a/b"), "/a/b/");
}
#[test]
fn strip_base_path_root() {
assert_eq!(strip_base_path("/", "/"), Some(""));
assert_eq!(strip_base_path("/foo.css", "/"), Some("foo.css"));
assert_eq!(strip_base_path("/guide/intro/", "/"), Some("guide/intro/"));
}
#[test]
fn strip_base_path_segment_boundary() {
assert_eq!(strip_base_path("/webui/", "/webui/"), Some(""));
assert_eq!(strip_base_path("/webui", "/webui/"), Some(""));
assert_eq!(strip_base_path("/webui/guide/", "/webui/"), Some("guide/"));
assert_eq!(
strip_base_path("/webui/foo.css", "/webui/"),
Some("foo.css")
);
}
#[test]
fn strip_base_path_rejects_similar_prefix() {
assert_eq!(strip_base_path("/webui-evil/x", "/webui/"), None);
assert_eq!(strip_base_path("/webuixyz", "/webui/"), None);
assert_eq!(strip_base_path("/", "/webui/"), None);
assert_eq!(strip_base_path("/other/foo", "/webui/"), None);
}
#[test]
fn resolve_safe_path_basic() {
let out = Path::new("/tmp/dist");
assert_eq!(
resolve_safe_path(out, "").as_deref(),
Some(Path::new("/tmp/dist/index.html"))
);
assert_eq!(
resolve_safe_path(out, "foo.css").as_deref(),
Some(Path::new("/tmp/dist/foo.css"))
);
assert_eq!(
resolve_safe_path(out, "guide/intro/").as_deref(),
Some(Path::new("/tmp/dist/guide/intro/index.html"))
);
}
#[test]
fn resolve_safe_path_rejects_traversal() {
let out = Path::new("/tmp/dist");
assert_eq!(resolve_safe_path(out, "../etc/passwd"), None);
assert_eq!(resolve_safe_path(out, "foo/../../bar"), None);
assert_eq!(resolve_safe_path(out, "%2e%2e/etc"), None);
assert_eq!(resolve_safe_path(out, "foo/%2e%2e/bar"), None);
assert_eq!(resolve_safe_path(out, "foo//bar"), None);
assert_eq!(resolve_safe_path(out, "foo\\bar"), None);
assert_eq!(resolve_safe_path(out, "./foo"), None);
}
#[test]
fn resolve_safe_path_decodes_legal_percent() {
let out = Path::new("/tmp/dist");
assert_eq!(
resolve_safe_path(out, "my%20file.html").as_deref(),
Some(Path::new("/tmp/dist/my file.html"))
);
}
#[test]
fn resolve_safe_path_rejects_drive_letters() {
let out = Path::new("/tmp/dist");
assert_eq!(resolve_safe_path(out, "C:/Windows/System32"), None);
assert_eq!(resolve_safe_path(out, "c:foo"), None);
assert_eq!(resolve_safe_path(out, "foo/C:/bar"), None);
assert_eq!(resolve_safe_path(out, "C%3A/win"), None);
}
}