pub(in crate::http) fn has_dot_dot_traversal(path: &str) -> bool {
path.split('/').any(is_traversal_segment)
}
fn is_traversal_segment(seg: &str) -> bool {
if seg == ".." {
return true;
}
let mut dots = 0_u16;
let mut i = 0;
let b = seg.as_bytes();
while let Some(&c) = b.get(i) {
if c == b'%'
&& b.get(i + 1).is_some_and(|d| d.eq_ignore_ascii_case(&b'2'))
&& b.get(i + 2).is_some_and(|e| e.eq_ignore_ascii_case(&b'e'))
{
dots += 1;
i += 3;
} else if c == b'.' {
dots += 1;
i += 1;
} else {
return false;
}
}
dots == 2
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
use super::has_dot_dot_traversal;
#[test]
fn traversal_detector_matches_encoded_variants() {
assert!(has_dot_dot_traversal("/a/../b"), "literal '..' is traversal");
assert!(has_dot_dot_traversal("/a/%2e%2e/b"), "fully encoded is traversal");
assert!(has_dot_dot_traversal("/a/%2E%2E/b"), "uppercase encoded is traversal");
assert!(has_dot_dot_traversal("/a/.%2e/b"), "mixed dot+encoded is traversal");
assert!(has_dot_dot_traversal("/a/%2e./b"), "mixed encoded+dot is traversal");
}
#[test]
fn traversal_detector_allows_non_traversal_dot_segments() {
assert!(!has_dot_dot_traversal("/a/..config"), "'..config' is not traversal");
assert!(!has_dot_dot_traversal("/a/."), "single dot is not traversal");
assert!(
!has_dot_dot_traversal("/a/%2e%2e%2e"),
"triple encoded dot is not traversal"
);
assert!(
!has_dot_dot_traversal(&format!("/a/{}", "%2e".repeat(258))),
"long encoded dot segment is not traversal"
);
}
}