use std::borrow::Cow;
use std::string::FromUtf8Error;
const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF";
#[inline]
const fn is_unreserved(byte: u8) -> bool {
matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~')
}
#[inline]
const fn is_ascii_alphanumeric_byte(byte: u8) -> bool {
matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9')
}
#[inline]
const fn from_hex_digit(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'A'..=b'F' => Some(byte - b'A' + 10),
b'a'..=b'f' => Some(byte - b'a' + 10),
_ => None,
}
}
fn encode_with(input: &str, keep: fn(u8) -> bool) -> Cow<'_, str> {
let bytes = input.as_bytes();
let unchanged_prefix = bytes.iter().take_while(|&&b| keep(b)).count();
if unchanged_prefix == bytes.len() {
return Cow::Borrowed(input);
}
let mut encoded = String::with_capacity(bytes.len() + 2 * (bytes.len() - unchanged_prefix));
encoded.push_str(&input[..unchanged_prefix]);
for &byte in &bytes[unchanged_prefix..] {
if keep(byte) {
encoded.push(byte as char);
} else {
encoded.push('%');
encoded.push(HEX_UPPER[(byte >> 4) as usize] as char);
encoded.push(HEX_UPPER[(byte & 0x0F) as usize] as char);
}
}
Cow::Owned(encoded)
}
pub fn percent_encode(input: &str) -> Cow<'_, str> {
encode_with(input, is_unreserved)
}
pub fn percent_encode_strict(input: &str) -> Cow<'_, str> {
encode_with(input, is_ascii_alphanumeric_byte)
}
fn percent_decode_bytes(data: &[u8]) -> Cow<'_, [u8]> {
let unchanged_prefix = data.iter().take_while(|&&b| b != b'%').count();
if unchanged_prefix == data.len() {
return Cow::Borrowed(data);
}
let mut decoded = Vec::with_capacity(data.len());
decoded.extend_from_slice(&data[..unchanged_prefix]);
let mut index = unchanged_prefix;
while index < data.len() {
let byte = data[index];
if byte != b'%' {
decoded.push(byte);
index += 1;
continue;
}
match (data.get(index + 1).copied(), data.get(index + 2).copied()) {
(Some(first), Some(second)) => match from_hex_digit(first) {
Some(high) => match from_hex_digit(second) {
Some(low) => {
decoded.push((high << 4) | low);
index += 3;
}
None => {
decoded.push(b'%');
decoded.push(first);
index += 2;
}
},
None => {
decoded.push(b'%');
index += 1;
}
},
_ => {
decoded.extend_from_slice(&data[index..]);
break;
}
}
}
Cow::Owned(decoded)
}
pub fn percent_decode(input: &str) -> Result<Cow<'_, str>, FromUtf8Error> {
match percent_decode_bytes(input.as_bytes()) {
Cow::Borrowed(_) => Ok(Cow::Borrowed(input)),
Cow::Owned(bytes) => Ok(Cow::Owned(String::from_utf8(bytes)?)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_empty_string_is_borrowed() {
let encoded = percent_encode("");
assert_eq!(encoded, "");
assert!(matches!(encoded, Cow::Borrowed(_)));
}
#[test]
fn encode_unreserved_is_borrowed_fast_path() {
let input = "ABCXYZabcxyz0189-_.~";
let encoded = percent_encode(input);
assert_eq!(encoded, input);
assert!(matches!(encoded, Cow::Borrowed(_)));
}
#[test]
fn encode_space_is_percent20_not_plus() {
assert_eq!(percent_encode("hello world"), "hello%20world");
assert!(!percent_encode("hello world").contains('+'));
}
#[test]
fn encode_reserved_characters() {
assert_eq!(percent_encode("/"), "%2F");
assert_eq!(percent_encode("?"), "%3F");
assert_eq!(percent_encode("#"), "%23");
assert_eq!(percent_encode("&"), "%26");
assert_eq!(percent_encode("="), "%3D");
assert_eq!(
percent_encode("http://example.org/a?b=c&d=e#f"),
"http%3A%2F%2Fexample.org%2Fa%3Fb%3Dc%26d%3De%23f"
);
}
#[test]
fn encode_percent_literal() {
assert_eq!(percent_encode("100%"), "100%25");
assert_eq!(percent_encode("%"), "%25");
}
#[test]
fn encode_multibyte_utf8_uppercase_hex() {
assert_eq!(percent_encode("日本語"), "%E6%97%A5%E6%9C%AC%E8%AA%9E");
assert_eq!(percent_encode("ä"), "%C3%A4");
assert_eq!(percent_encode("🦀"), "%F0%9F%A6%80");
}
#[test]
fn encode_mixed_prefix_keeps_unreserved_run() {
let encoded = percent_encode("abc def");
assert_eq!(encoded, "abc%20def");
assert!(matches!(encoded, Cow::Owned(_)));
}
#[test]
fn strict_encode_alphanumeric_is_borrowed_fast_path() {
let input = "ABCXYZabcxyz0189";
let encoded = percent_encode_strict(input);
assert_eq!(encoded, input);
assert!(matches!(encoded, Cow::Borrowed(_)));
}
#[test]
fn strict_encode_escapes_unreserved_punctuation() {
assert_eq!(percent_encode_strict("-"), "%2D");
assert_eq!(percent_encode_strict("_"), "%5F");
assert_eq!(percent_encode_strict("."), "%2E");
assert_eq!(percent_encode_strict("~"), "%7E");
assert_eq!(
percent_encode_strict("test_client_id"),
"test%5Fclient%5Fid"
);
}
#[test]
fn strict_encode_space_and_reserved_match_rfc_variant() {
assert_eq!(percent_encode_strict("hello world"), "hello%20world");
assert_eq!(
percent_encode_strict("a/b?c#d&e=f"),
"a%2Fb%3Fc%23d%26e%3Df"
);
assert_eq!(percent_encode_strict("100%"), "100%25");
}
#[test]
fn strict_encode_multibyte_utf8_uppercase_hex() {
assert_eq!(percent_encode_strict("日本"), "%E6%97%A5%E6%9C%AC");
assert_eq!(percent_encode_strict("ä"), "%C3%A4");
}
#[test]
fn strict_encode_round_trips_through_percent_decode() {
let samples = [
"",
"plain",
"test_client_id",
"openid profile email",
"code-challenge_~.value",
"日本語のテキスト",
];
for sample in samples {
let encoded = percent_encode_strict(sample);
let decoded = percent_decode(&encoded).expect("strict round trip decodes");
assert_eq!(decoded, sample, "strict round trip failed for {sample:?}");
}
}
#[test]
fn strict_encoding_is_superset_of_rfc_encoding() {
let input = "AZaz09-_.~ /?#&=%";
let rfc = percent_encode(input);
let strict = percent_encode_strict(input);
assert_eq!(rfc, "AZaz09-_.~%20%2F%3F%23%26%3D%25");
assert_eq!(strict, "AZaz09%2D%5F%2E%7E%20%2F%3F%23%26%3D%25");
}
#[test]
fn decode_empty_string_is_borrowed() {
let decoded = percent_decode("").expect("empty input decodes");
assert_eq!(decoded, "");
assert!(matches!(decoded, Cow::Borrowed(_)));
}
#[test]
fn decode_without_percent_is_borrowed_fast_path() {
let decoded = percent_decode("plain text!").expect("plain input decodes");
assert_eq!(decoded, "plain text!");
assert!(matches!(decoded, Cow::Borrowed(_)));
}
#[test]
fn decode_basic_sequences() {
assert_eq!(
percent_decode("hello%20world").expect("valid input"),
"hello world"
);
assert_eq!(
percent_decode("%2F%3F%23%26%3D").expect("valid input"),
"/?#&="
);
assert_eq!(percent_decode("%25").expect("valid input"), "%");
}
#[test]
fn decode_hex_is_case_insensitive() {
assert_eq!(percent_decode("%2f").expect("valid input"), "/");
assert_eq!(percent_decode("%c3%a4").expect("valid input"), "ä");
assert_eq!(percent_decode("%C3%A4").expect("valid input"), "ä");
assert_eq!(percent_decode("%e6%97%A5").expect("valid input"), "日");
}
#[test]
fn decode_malformed_sequences_pass_through() {
assert_eq!(percent_decode("%").expect("malformed passes through"), "%");
assert_eq!(
percent_decode("100%").expect("malformed passes through"),
"100%"
);
assert_eq!(
percent_decode("%2").expect("malformed passes through"),
"%2"
);
assert_eq!(
percent_decode("%ZZ").expect("malformed passes through"),
"%ZZ"
);
assert_eq!(
percent_decode("%2%41").expect("malformed passes through"),
"%2A"
);
assert_eq!(
percent_decode("%%41").expect("malformed passes through"),
"%A"
);
}
#[test]
fn decode_invalid_utf8_errors() {
assert!(percent_decode("%FF").is_err());
assert!(percent_decode("%80").is_err());
assert!(percent_decode("%C3").is_err());
}
#[test]
fn round_trip_ascii_and_unicode() {
let samples = [
"",
"plain",
"hello world",
"a/b?c#d&e=f",
"100% sure",
"日本語のテキスト",
"emoji 🦀 crab",
"tab\tnewline\nquote\"",
"AZaz09-_.~",
];
for sample in samples {
let encoded = percent_encode(sample);
let decoded = percent_decode(&encoded).expect("round trip decodes");
assert_eq!(decoded, sample, "round trip failed for {sample:?}");
}
}
#[test]
fn encode_matches_sparql_encode_for_uri_semantics() {
assert_eq!(percent_encode("Los Angeles"), "Los%20Angeles");
}
}