use proptest::prelude::*;
use unicode_normalization::UnicodeNormalization;
use waf_normalizer::url::{canonicalize_value, percent_decode};
fn hexval(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn naive_decode(input: &str, plus: bool) -> String {
let b = input.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
let c = b[i];
if plus && c == b'+' {
out.push(b' ');
i += 1;
} else if c == b'%' {
match (
b.get(i + 1).copied().and_then(hexval),
b.get(i + 2).copied().and_then(hexval),
) {
(Some(h), Some(l)) => {
out.push((h << 4) | l);
i += 3;
}
_ => {
out.push(c);
i += 1;
}
}
} else {
out.push(c);
i += 1;
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn has_decodable(s: &str) -> bool {
naive_decode(s, false) != *s
}
fn oracle_decode_bytes(input: &[u8], plus: bool) -> Vec<u8> {
let mut out: Vec<u8> = Vec::with_capacity(input.len());
let mut i = 0;
while i < input.len() {
let c = input[i];
if plus && c == b'+' {
out.push(b' ');
i += 1;
} else if c == b'%' {
match (input.get(i + 1).copied().and_then(hexval), input.get(i + 2).copied().and_then(hexval)) {
(Some(h), Some(l)) => {
out.push((h << 4) | l);
i += 3;
}
_ => {
out.push(c);
i += 1;
}
}
} else {
out.push(c);
i += 1;
}
}
out
}
fn oracle_collapse_overlong(bytes: &[u8]) -> Vec<u8> {
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if (b == 0xC0 || b == 0xC1) && bytes.get(i + 1).is_some_and(|&n| (0x80..=0xBF).contains(&n)) {
out.push(((b & 0x1F) << 6) | (bytes[i + 1] & 0x3F));
i += 2;
} else {
out.push(b);
i += 1;
}
}
out
}
fn oracle_full(input: &str, plus: bool) -> String {
let mut bytes = input.as_bytes().to_vec();
let mut first = true;
loop {
let next = oracle_collapse_overlong(&oracle_decode_bytes(&bytes, plus && first));
first = false;
if next == bytes {
break;
}
bytes = next;
}
String::from_utf8_lossy(&bytes).nfkc().collect()
}
proptest! {
#[test]
fn prop_a_single_pass_equals_oracle(
s in "[%+0-9a-zA-Z<>'\"./ -]{0,48}",
plus in any::<bool>(),
) {
let (ours, _) = percent_decode(&s, plus);
prop_assert_eq!(ours, naive_decode(&s, plus));
}
}
proptest! {
#[test]
fn prop_b_canonical_is_nfkc_stable(
s in "[%+0-9a-zA-Z\u{ff10}-\u{ff5a}<>]{0,48}",
plus in any::<bool>(),
) {
let (canon, _) = canonicalize_value(&s, plus);
let renorm: String = canon.nfkc().collect();
prop_assert_eq!(canon, renorm);
}
}
proptest! {
#[test]
fn prop_c_le2_never_diverges(
s in "[%+0-9a-zA-Z<>'./ ]{0,40}",
plus in any::<bool>(),
) {
let d1 = naive_decode(&s, plus);
let converged = if has_decodable(&d1) {
let d2 = naive_decode(&d1, false);
!has_decodable(&d2)
} else {
true
};
if converged {
let (canon, _) = canonicalize_value(&s, plus);
prop_assert_eq!(canon, oracle_full(&s, plus));
}
}
}
#[test]
fn prop_c_beyond_cap_must_diverge() {
let l5 = "%2525252527"; let (canon5, _) = canonicalize_value(l5, true);
assert_eq!(canon5, "'", "5-level (<=cap) must fully converge, got {canon5:?}");
assert_eq!(canon5, oracle_full(l5, true));
let l6 = "%252525252527"; let (canon6, _) = canonicalize_value(l6, true);
assert_eq!(canon6, "%27", "6-level: bounded canonical stops at %27, got {canon6:?}");
assert_eq!(oracle_full(l6, true), "'", "unbounded oracle reaches the quote");
assert_ne!(canon6, oracle_full(l6, true), "the >cap divergence must exist");
}
#[test]
fn overlong_utf8_is_decoded_10c() {
let (canon, _) = canonicalize_value("%C0%AE", true);
assert_eq!(canon, ".", "overlong must now decode to '.', got {canon:?}");
assert!(!canon.contains('\u{FFFD}'), "no replacement char, got {canon:?}");
assert_eq!(canon, oracle_full("%C0%AE", true));
let (canon2, _) = canonicalize_value("%25C0%25AE", true);
assert_eq!(canon2, ".", "double-encoded overlong decodes too, got {canon2:?}");
}