const RESERVED: &[u8] = b";/?:@&=+$,";
#[must_use]
fn is_reserved(b: u8) -> bool {
RESERVED.contains(&b)
}
#[must_use]
fn hex_value(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,
}
}
#[must_use]
pub(crate) fn escapes_are_well_formed(input: &[u8]) -> bool {
let mut i = 0;
while i < input.len() {
let Some(&b) = input.get(i) else { break };
if b == b'%' {
let ok = input
.get(i + 1)
.and_then(|&h| hex_value(h))
.and(input.get(i + 2).and_then(|&h| hex_value(h)))
.is_some();
if !ok {
return false;
}
i += 3;
} else {
i += 1;
}
}
true
}
#[must_use]
pub(crate) fn decode(input: &[u8]) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(input.len());
let mut i = 0;
while let Some(&b) = input.get(i) {
if b == b'%' {
let hi = hex_value(*input.get(i + 1)?)?;
let lo = hex_value(*input.get(i + 2)?)?;
out.push((hi << 4) | lo);
i += 3;
} else {
out.push(b);
i += 1;
}
}
Some(out)
}
#[must_use]
pub(crate) fn normalize_for_comparison(input: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(input.len());
let mut i = 0;
while let Some(&b) = input.get(i) {
if b == b'%'
&& let Some(decoded) = input
.get(i + 1)
.and_then(|&h| hex_value(h))
.zip(input.get(i + 2).and_then(|&h| hex_value(h)))
.map(|(hi, lo)| (hi << 4) | lo)
{
if is_reserved(decoded) {
out.push(b'%');
out.extend_from_slice(&upper_hex(decoded));
} else {
out.push(decoded);
}
i += 3;
continue;
}
out.push(b);
i += 1;
}
out
}
#[must_use]
fn upper_hex(b: u8) -> [u8; 2] {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let hi = HEX.get(usize::from(b >> 4)).copied().unwrap_or(b'0');
let lo = HEX.get(usize::from(b & 0x0f)).copied().unwrap_or(b'0');
[hi, lo]
}
#[must_use]
pub(crate) fn eq_ignore_ascii_case(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b.iter())
.all(|(x, y)| x.eq_ignore_ascii_case(y))
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
#[test]
fn decodes_escaped_null() {
assert_eq!(decode(b"null-%00-null").unwrap(), b"null-\x00-null");
}
#[test]
fn rejects_truncated_escape() {
assert!(decode(b"abc%4").is_none());
assert!(decode(b"abc%").is_none());
assert!(decode(b"abc%zz").is_none());
assert!(!escapes_are_well_formed(b"abc%4"));
assert!(escapes_are_well_formed(b"abc%41"));
}
#[test]
fn normalization_decodes_unreserved_and_keeps_reserved() {
assert_eq!(normalize_for_comparison(b"%61lice"), b"alice");
assert_eq!(normalize_for_comparison(b"bob%40biloxi"), b"bob%40biloxi");
assert_eq!(
normalize_for_comparison(b"bob%40x"),
normalize_for_comparison(b"bob%40x")
);
assert_eq!(
normalize_for_comparison(b"a%2fb"),
normalize_for_comparison(b"a%2Fb")
);
assert_ne!(
normalize_for_comparison(b"a%2fb"),
normalize_for_comparison(b"a/b")
);
}
#[test]
fn normalization_leaves_malformed_escapes_alone() {
assert_eq!(normalize_for_comparison(b"100%"), b"100%");
assert_eq!(normalize_for_comparison(b"%zz"), b"%zz");
}
}