pub fn percent_decode_url_path(url_path: &str) -> std::borrow::Cow<'_, str> {
percent_encoding::percent_decode_str(url_path).decode_utf8_lossy()
}
pub fn normalize_url_path(url_path: &str, url_path_prefix: Option<&str>) -> String {
let url_path_prefix = match url_path_prefix {
Some(prefix) if !prefix.is_empty() => prefix.strip_suffix('/').unwrap_or(prefix),
_ => "",
};
let url_path = url_path.strip_prefix('/').unwrap_or(url_path);
let merged = format!("{}/{}", url_path_prefix, url_path);
let trimmed = merged.strip_suffix('/').unwrap_or(merged.as_str());
let trimmed = trimmed.strip_prefix('/').unwrap_or(trimmed);
let mut result = String::with_capacity(trimmed.len() + 1);
result.push('/');
let mut segments = trimmed.split('/').filter(|seg| *seg != "..");
if let Some(first) = segments.next() {
result.push_str(first);
for seg in segments {
result.push('/');
result.push_str(seg);
}
}
result
}
#[cfg(test)]
mod tests {
use super::{normalize_url_path, percent_decode_url_path};
#[test]
fn ordinary_percent_escapes_decode() {
assert_eq!(percent_decode_url_path("/my%20file.json"), "/my file.json");
assert_eq!(percent_decode_url_path("/caf%C3%A9.json"), "/café.json");
}
#[test]
fn a_plus_is_not_treated_as_a_space() {
assert_eq!(percent_decode_url_path("/a+b.json"), "/a+b.json");
}
#[test]
fn an_encoded_dot_dot_decodes_to_a_literal_dot_dot() {
assert_eq!(
percent_decode_url_path("/%2e%2e/outside.txt"),
"/../outside.txt"
);
assert_eq!(
percent_decode_url_path("/%2E%2E/outside.txt"),
"/../outside.txt"
);
assert_eq!(
percent_decode_url_path("/%2e%2e%2foutside.txt"),
"/../outside.txt"
);
}
#[test]
fn invalid_utf8_after_decoding_is_replaced_not_rejected() {
let decoded = percent_decode_url_path("/%FF.json");
assert!(decoded.contains('\u{FFFD}'), "decoded was: {decoded:?}");
}
#[test]
fn an_incomplete_or_invalid_escape_passes_through_literally() {
assert_eq!(percent_decode_url_path("/100%off"), "/100%off");
}
#[test]
fn decoding_then_normalising_strips_an_encoded_dot_dot() {
let decoded = percent_decode_url_path("/%2e%2e%2foutside.txt");
assert_eq!(normalize_url_path(&decoded, None), "/outside.txt");
}
#[test]
fn ordinary_paths_are_unaffected() {
assert_eq!(normalize_url_path("/api/v1", None), "/api/v1");
assert_eq!(normalize_url_path("api/v1", None), "/api/v1");
assert_eq!(normalize_url_path("/api/v1/", None), "/api/v1");
}
#[test]
fn a_leading_dot_dot_segment_is_stripped() {
assert_eq!(normalize_url_path("/../outside.txt", None), "/outside.txt");
}
#[test]
fn repeated_leading_dot_dot_segments_are_all_stripped() {
assert_eq!(
normalize_url_path("/../../outside.txt", None),
"/outside.txt"
);
}
#[test]
fn a_mid_path_dot_dot_segment_is_stripped() {
assert_eq!(normalize_url_path("/foo/../bar", None), "/foo/bar");
}
#[test]
fn a_bare_dot_dot_normalises_to_root() {
assert_eq!(normalize_url_path("/..", None), "/");
}
#[test]
fn a_prefix_is_still_applied_alongside_stripping() {
assert_eq!(
normalize_url_path("/../outside.txt", Some("/api")),
"/api/outside.txt"
);
}
}