use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode, utf8_percent_encode};
const URI_PATH_ENCODE_SET: AsciiSet = NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~')
.remove(b'/');
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NormalizedPath {
text: String,
}
impl NormalizedPath {
pub fn new(raw: &str) -> Self {
let decoded = percent_decode(raw.as_bytes()).decode_utf8_lossy();
Self {
text: fold_components(&decoded),
}
}
pub fn as_str(&self) -> &str {
&self.text
}
pub fn to_uri_path(&self) -> String {
utf8_percent_encode(&self.text, &URI_PATH_ENCODE_SET).to_string()
}
}
fn fold_components(decoded: &str) -> String {
let mut segments: Vec<&str> = Vec::new();
for segment in decoded.split('/') {
match segment {
"" | "." => {}
".." => {
segments.pop();
}
segment => segments.push(segment),
}
}
let mut path = String::with_capacity(decoded.len() + 1);
for segment in segments {
path.push('/');
path.push_str(segment);
}
if path.is_empty() {
path.push('/');
}
path
}
#[cfg(test)]
mod tests {
use super::*;
fn normalized(raw: &str) -> String {
NormalizedPath::new(raw).as_str().to_string()
}
fn decode_once(encoded: &str) -> String {
percent_decode(encoded.as_bytes())
.decode_utf8_lossy()
.into_owned()
}
#[test]
fn un_path_limpio_no_cambia() {
assert_eq!(normalized("/"), "/");
assert_eq!(normalized("/404"), "/404");
assert_eq!(normalized("/404.html"), "/404.html");
assert_eq!(normalized("/css/404.css"), "/css/404.css");
}
#[test]
fn el_percent_encoding_se_decodifica() {
assert_eq!(normalized("/404%2Ehtml"), "/404.html");
assert_eq!(normalized("/40%34.html"), "/404.html");
assert_eq!(normalized("/404%2eJSON"), "/404.JSON");
}
#[test]
fn un_slash_codificado_es_un_separador() {
assert_eq!(normalized("/css%2f404.css"), "/css/404.css");
}
#[test]
fn un_porcentaje_literal_no_se_decodifica_dos_veces() {
let path = NormalizedPath::new("/404%252Ehtml");
assert_eq!(path.as_str(), "/404%2Ehtml");
assert!(
path.to_uri_path().contains("%25"),
"el `%` literal debe ir escapado en la URI: {}",
path.to_uri_path()
);
}
#[test]
fn un_percent_encoding_incompleto_o_invalido_se_deja_tal_cual() {
assert_eq!(normalized("/404%"), "/404%");
assert_eq!(normalized("/404%zz"), "/404%zz");
}
#[test]
fn un_componente_curdir_se_pliega() {
assert_eq!(normalized("/./404.html"), "/404.html");
assert_eq!(normalized("/css/./404.css"), "/css/404.css");
assert_eq!(normalized("/././"), "/");
}
#[test]
fn un_componente_parentdir_hace_pop_y_nunca_sale_de_la_raiz() {
assert_eq!(normalized("/css/../404.html"), "/404.html");
assert_eq!(normalized("/css/../../Cargo.toml"), "/Cargo.toml");
assert_eq!(normalized("/../Cargo.toml"), "/Cargo.toml");
assert_eq!(normalized("/..%2f..%2fetc%2fpasswd"), "/etc/passwd");
assert_eq!(normalized("/../.."), "/");
}
#[test]
fn los_separadores_repetidos_se_pliegan() {
assert_eq!(normalized("//css//404.css"), "/css/404.css");
assert_eq!(normalized("/"), "/");
}
#[test]
fn un_path_sin_barra_inicial_queda_absoluto() {
assert_eq!(normalized("404.html"), "/404.html");
}
#[test]
fn la_uri_reescrita_decodifica_al_path_normalizado() {
for raw in [
"/",
"/404",
"/404.html",
"/404%2Ehtml",
"/40%34.html",
"/./404.html",
"/css/../css/404.css",
"/404%252Ehtml",
"/css/n%C3%A9.html",
] {
let path = NormalizedPath::new(raw);
assert_eq!(
decode_once(&path.to_uri_path()),
path.as_str(),
"la URI de `{raw}` no vuelve al path normalizado"
);
}
}
#[test]
fn la_uri_reescrita_es_ascii_y_valida() {
for raw in ["/404%2Ehtml", "/css/n%C3%A9.html", "/a b/c?d"] {
let uri_path = NormalizedPath::new(raw).to_uri_path();
assert!(
uri_path.is_ascii() && uri_path.starts_with('/') && !uri_path.contains('?'),
"path de URI inválido para `{raw}`: {uri_path}"
);
}
}
#[test]
fn los_bytes_no_utf8_no_colisionan_con_un_patron_ascii() {
assert_ne!(normalized("/%FF04.html"), "/404.html");
assert_eq!(normalized("/%FF04.html"), "/\u{fffd}04.html");
}
}