const UNPAIRED_SURROGATE_REPLACEMENT: u8 = b'?';
fn passes_through(unit: u16) -> bool {
let Ok(byte) = u8::try_from(unit) else {
return false;
};
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'*' | b'_')
}
pub(crate) fn url_encode(units: impl IntoIterator<Item = u16>) -> String {
let mut encoded = String::new();
let mut units = units.into_iter().peekable();
while let Some(unit) = units.next() {
if passes_through(unit) {
encoded.push(char::from(unit as u8));
} else if unit == u16::from(b' ') {
encoded.push('+');
} else {
append_percent_encoded(&mut encoded, code_point_bytes(unit, &mut units));
}
}
encoded
}
fn code_point_bytes(
unit: u16,
rest: &mut std::iter::Peekable<impl Iterator<Item = u16>>,
) -> ([u8; 4], usize) {
let mut bytes = [0u8; 4];
let scalar = if is_high_surrogate(unit) {
match rest.peek().copied() {
Some(low) if is_low_surrogate(low) => {
rest.next();
combine_surrogates(unit, low)
}
_ => None,
}
} else if is_low_surrogate(unit) {
None
} else {
char::from_u32(u32::from(unit))
};
if let Some(character) = scalar {
let length = character.encode_utf8(&mut bytes).len();
(bytes, length)
} else {
bytes[0] = UNPAIRED_SURROGATE_REPLACEMENT;
(bytes, 1)
}
}
fn is_high_surrogate(unit: u16) -> bool {
(0xD800..=0xDBFF).contains(&unit)
}
fn is_low_surrogate(unit: u16) -> bool {
(0xDC00..=0xDFFF).contains(&unit)
}
fn combine_surrogates(high: u16, low: u16) -> Option<char> {
let code_point = 0x1_0000 + ((u32::from(high) - 0xD800) << 10) + (u32::from(low) - 0xDC00);
char::from_u32(code_point)
}
fn append_percent_encoded(encoded: &mut String, (bytes, length): ([u8; 4], usize)) {
for &byte in &bytes[..length] {
encoded.push('%');
encoded.push(upper_case_hexadecimal_digit(byte >> 4));
encoded.push(upper_case_hexadecimal_digit(byte & 0x0F));
}
}
fn upper_case_hexadecimal_digit(nibble: u8) -> char {
char::from(b"0123456789ABCDEF"[usize::from(nibble)])
}
#[cfg(test)]
mod tests {
use super::url_encode;
const VECTORS: &str = include_str!("../../tests/fixtures/java-url-encoder-vectors.tsv");
fn encode(text: &str) -> String {
url_encode(text.encode_utf16())
}
#[test]
fn alphanumerics_pass_through() {
assert_eq!(encode("azAZ09"), "azAZ09");
}
#[test]
fn the_four_unreserved_punctuation_characters_pass_through() {
assert_eq!(encode(".-*_"), ".-*_");
}
#[test]
fn a_space_becomes_a_plus_sign() {
assert_eq!(encode("a b"), "a+b");
}
#[test]
fn a_plus_sign_is_encoded_rather_than_kept() {
assert_eq!(encode("a+b"), "a%2Bb");
}
#[test]
fn hexadecimal_digits_are_upper_case() {
assert_eq!(encode("ä"), "%C3%A4");
}
#[test]
fn a_colon_encodes_as_the_property_index_key_separator() {
assert_eq!(encode("sling:Folder"), "sling%3AFolder");
}
#[test]
fn tilde_and_exclamation_mark_are_not_unreserved_here() {
assert_eq!(encode("~!"), "%7E%21");
}
#[test]
fn a_control_character_encodes_as_its_own_byte() {
assert_eq!(encode("\u{0}\u{1f}\u{7f}"), "%00%1F%7F");
}
#[test]
fn an_astral_code_point_encodes_as_four_bytes() {
assert_eq!(encode("\u{1f600}"), "%F0%9F%98%80");
}
#[test]
fn a_lone_high_surrogate_encodes_as_a_question_mark() {
assert_eq!(url_encode([0xD83Du16]), "%3F");
}
#[test]
fn a_lone_low_surrogate_encodes_as_a_question_mark() {
assert_eq!(url_encode([0xDE00u16]), "%3F");
}
#[test]
fn two_unpaired_surrogates_encode_as_two_question_marks() {
assert_eq!(url_encode([0xD83Du16, 0xD83Du16]), "%3F%3F");
}
#[test]
fn a_pair_followed_by_a_lone_high_surrogate_keeps_the_pair() {
assert_eq!(url_encode([0xD83Du16, 0xDE00, 0xD83D]), "%F0%9F%98%80%3F");
}
#[test]
fn the_empty_string_encodes_as_the_empty_string() {
assert_eq!(encode(""), "");
}
#[test]
fn every_committed_vector_round_trips() {
let mut replayed = 0usize;
for line in VECTORS.lines() {
if line.starts_with('#') || line.is_empty() {
continue;
}
let mut fields = line.split('\t');
let name = fields.next().expect("vector line has a name");
let units = fields.next().expect("vector line has an input column");
let expected = fields.next().expect("vector line has an expected column");
assert!(fields.next().is_none(), "{name}: vector has extra columns");
let input: Vec<u16> = units
.split_whitespace()
.map(|unit| {
u16::from_str_radix(unit, 16)
.unwrap_or_else(|_| panic!("{name}: {unit} is not a code unit"))
})
.collect();
assert_eq!(url_encode(input), expected, "vector {name}");
replayed += 1;
}
assert!(
replayed >= 20,
"expected the committed vector set, replayed {replayed}"
);
}
}