use core::fmt;
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_url_safe(byte: u8) -> bool {
is_unreserved(byte)
|| matches!(
byte,
b':' | b'/'
| b'?'
| b'#'
| b'['
| b']'
| b'@'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b';'
| b'='
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UrlDecodeErrorKind {
InvalidPercentSequence,
InvalidUtf8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UrlDecodeError {
kind: UrlDecodeErrorKind,
}
impl UrlDecodeError {
const fn new(kind: UrlDecodeErrorKind) -> Self {
Self { kind }
}
}
impl fmt::Display for UrlDecodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
UrlDecodeErrorKind::InvalidPercentSequence => {
write!(f, "url: invalid percent-encoded sequence")
}
UrlDecodeErrorKind::InvalidUtf8 => {
write!(f, "url: decoded bytes are not valid UTF-8")
}
}
}
}
impl std::error::Error for UrlDecodeError {}
#[must_use]
pub fn url_encode(input: &str) -> String {
encode_impl(input, is_url_safe)
}
#[must_use]
pub fn url_encode_component(input: &str) -> String {
encode_impl(input, is_unreserved)
}
fn encode_impl(input: &str, preserve: fn(u8) -> bool) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
for &byte in bytes {
if preserve(byte) {
out.push(byte);
} else {
out.push(b'%');
out.push(HEX_UPPER[(byte >> 4) as usize]);
out.push(HEX_UPPER[(byte & 0x0F) as usize]);
}
}
#[allow(clippy::expect_used)]
String::from_utf8(out).expect("url-encoded output is always valid ASCII")
}
pub fn url_decode(input: &str) -> Result<String, UrlDecodeError> {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len() {
return Err(UrlDecodeError::new(
UrlDecodeErrorKind::InvalidPercentSequence,
));
}
let high = hex_value(bytes[i + 1])?;
let low = hex_value(bytes[i + 2])?;
out.push((high << 4) | low);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).map_err(|_| UrlDecodeError::new(UrlDecodeErrorKind::InvalidUtf8))
}
#[inline]
fn hex_value(byte: u8) -> Result<u8, UrlDecodeError> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'A'..=b'F' => Ok(byte - b'A' + 10),
b'a'..=b'f' => Ok(byte - b'a' + 10),
_ => Err(UrlDecodeError::new(
UrlDecodeErrorKind::InvalidPercentSequence,
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_empty() {
assert_eq!(url_encode(""), "");
}
#[test]
fn encode_unreserved_passthrough() {
let unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
assert_eq!(url_encode(unreserved), unreserved);
}
#[test]
fn encode_preserves_reserved_characters() {
let reserved = ":/?#[]@!$&'()*+,;=";
assert_eq!(url_encode(reserved), reserved);
}
#[test]
fn encode_space() {
assert_eq!(url_encode("hello world"), "hello%20world");
}
#[test]
fn encode_utf8_multibyte() {
assert_eq!(url_encode("\u{00E9}"), "%C3%A9");
}
#[test]
fn component_encode_empty() {
assert_eq!(url_encode_component(""), "");
}
#[test]
fn component_encode_unreserved_passthrough() {
let unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
assert_eq!(url_encode_component(unreserved), unreserved);
}
#[test]
fn component_encode_reserved_characters() {
assert_eq!(url_encode_component("a=b&c=d"), "a%3Db%26c%3Dd");
}
#[test]
fn component_encode_slash() {
assert_eq!(
url_encode_component("path/to/resource"),
"path%2Fto%2Fresource"
);
}
#[test]
fn component_encode_space() {
assert_eq!(url_encode_component("hello world"), "hello%20world");
}
#[test]
fn component_encode_utf8_multibyte() {
assert_eq!(url_encode_component("\u{00E9}"), "%C3%A9");
}
#[test]
fn component_encode_all_ascii_special() {
for byte in 0x20u8..=0x7E {
let ch = byte as char;
let s = ch.to_string();
let encoded = url_encode_component(&s);
if is_unreserved(byte) {
assert_eq!(encoded, s, "unreserved char {ch:?} should not be encoded");
} else {
assert!(
encoded.starts_with('%'),
"non-unreserved char {ch:?} should be percent-encoded, got {encoded:?}"
);
}
}
}
#[test]
fn decode_empty() {
assert_eq!(url_decode("").unwrap(), "");
}
#[test]
fn decode_no_percent() {
assert_eq!(url_decode("hello").unwrap(), "hello");
}
#[test]
fn decode_space() {
assert_eq!(url_decode("hello%20world").unwrap(), "hello world");
}
#[test]
fn decode_uppercase_hex() {
assert_eq!(url_decode("%2F").unwrap(), "/");
}
#[test]
fn decode_lowercase_hex() {
assert_eq!(url_decode("%2f").unwrap(), "/");
}
#[test]
fn decode_mixed_case_hex() {
assert_eq!(url_decode("%2F%2f").unwrap(), "//");
}
#[test]
fn decode_utf8_multibyte() {
assert_eq!(url_decode("%C3%A9").unwrap(), "\u{00E9}");
}
#[test]
fn decode_preserves_unreserved() {
let input = "abcABC123-._~";
assert_eq!(url_decode(input).unwrap(), input);
}
#[test]
fn round_trip_component() {
let inputs = [
"",
"hello",
"hello world",
"a=b&c=d",
"path/to/resource",
"caf\u{00E9}",
"100%",
"\u{1F600}",
];
for &input in &inputs {
let encoded = url_encode_component(input);
let decoded = url_decode(&encoded).unwrap();
assert_eq!(decoded, input, "round-trip failed for {input:?}");
}
}
#[test]
fn round_trip_ascii_range() {
for byte in 0x20u8..=0x7E {
let s = String::from(byte as char);
let encoded = url_encode_component(&s);
let decoded = url_decode(&encoded).unwrap();
assert_eq!(decoded, s, "round-trip failed for byte {byte:#04X}");
}
}
#[test]
fn decode_rejects_incomplete_percent_at_end() {
let err = url_decode("abc%2").unwrap_err();
assert_eq!(
err,
UrlDecodeError::new(UrlDecodeErrorKind::InvalidPercentSequence)
);
}
#[test]
fn decode_rejects_percent_at_very_end() {
let err = url_decode("abc%").unwrap_err();
assert_eq!(
err,
UrlDecodeError::new(UrlDecodeErrorKind::InvalidPercentSequence)
);
}
#[test]
fn decode_rejects_invalid_hex_digits() {
let err = url_decode("%GG").unwrap_err();
assert_eq!(
err,
UrlDecodeError::new(UrlDecodeErrorKind::InvalidPercentSequence)
);
}
#[test]
fn decode_rejects_percent_with_non_hex() {
assert!(url_decode("%ZZ").is_err());
assert!(url_decode("% 0").is_err());
}
#[test]
fn decode_rejects_invalid_utf8() {
let err = url_decode("%FF").unwrap_err();
assert_eq!(err, UrlDecodeError::new(UrlDecodeErrorKind::InvalidUtf8));
}
#[test]
fn error_display_messages() {
let pct = UrlDecodeError::new(UrlDecodeErrorKind::InvalidPercentSequence);
assert_eq!(pct.to_string(), "url: invalid percent-encoded sequence");
let utf8 = UrlDecodeError::new(UrlDecodeErrorKind::InvalidUtf8);
assert_eq!(utf8.to_string(), "url: decoded bytes are not valid UTF-8");
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> = Box::new(UrlDecodeError::new(
UrlDecodeErrorKind::InvalidPercentSequence,
));
let _ = err.to_string();
}
}