use std::borrow::Cow;
use percent_encoding::{
AsciiSet, NON_ALPHANUMERIC, PercentEncode, percent_decode_str, utf8_percent_encode,
};
const QUERY_COMPONENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~');
pub(super) fn encode_query_component(component: &str) -> PercentEncode<'_> {
utf8_percent_encode(component, QUERY_COMPONENT_ENCODE_SET)
}
pub(super) fn decode_query_component(component: &str) -> Option<String> {
if !is_valid_query_component(component) {
return None;
}
percent_decode_str(component)
.decode_utf8()
.ok()
.map(Cow::into_owned)
}
fn is_valid_query_component(component: &str) -> bool {
let bytes = component.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let escape = bytes.get(i + 1..i + 3);
if !escape.is_some_and(|hex| hex.iter().all(u8::is_ascii_hexdigit)) {
return false;
}
i += 3;
} else if is_query_char(bytes[i]) {
i += 1;
} else {
return false;
}
}
true
}
fn is_query_char(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:@/?".contains(&byte)
}
#[cfg(test)]
mod tests {
use super::*;
use test_case::test_case;
#[test_case("%2541", "%41"; "decodes_once")]
#[test_case("a+b", "a+b"; "plus_is_literal")]
fn decodes_query_components(component: &str, expected: &str) {
assert_eq!(decode_query_component(component).as_deref(), Some(expected));
}
#[test_case("%zz"; "invalid_hex_escape")]
#[test_case("%4"; "short_escape")]
#[test_case("%ff"; "invalid_utf8")]
#[test_case("hello world"; "raw_space")]
#[test_case("caf\u{e9}"; "raw_non_ascii")]
#[test_case("\u{7}"; "raw_control")]
#[test_case("a<b"; "raw_delimiter")]
fn rejects_invalid_query_components(component: &str) {
assert_eq!(decode_query_component(component), None);
}
}