use std::borrow::Cow;
use crate::protocol::ProtocolError;
const REASON_TRUNCATED: &str = "`%` is not followed by two characters";
const REASON_NOT_HEX: &str = "`%` is not followed by two hexadecimal digits";
const REASON_NOT_UTF8: &str = "decoded bytes are not valid utf-8";
pub(crate) fn percent_decode(input: &str) -> Result<Cow<'_, str>, ProtocolError> {
let bytes = input.as_bytes();
let Some(first) = input.find('%') else {
return Ok(Cow::Borrowed(input));
};
let mut decoded: Vec<u8> = Vec::with_capacity(bytes.len());
if let Some(prefix) = bytes.get(..first) {
decoded.extend_from_slice(prefix);
}
let mut index = first;
while let Some(&byte) = bytes.get(index) {
if byte != b'%' {
decoded.push(byte);
index += 1;
continue;
}
let high = bytes.get(index + 1).copied();
let low = bytes.get(index + 2).copied();
let (Some(high), Some(low)) = (high, low) else {
return Err(ProtocolError::Escaping {
position: index,
reason: REASON_TRUNCATED,
});
};
let (Some(high), Some(low)) = (hex_value(high), hex_value(low)) else {
return Err(ProtocolError::Escaping {
position: index,
reason: REASON_NOT_HEX,
});
};
decoded.push((high << 4) | low);
index += 3;
}
match String::from_utf8(decoded) {
Ok(text) => Ok(Cow::Owned(text)),
Err(error) => Err(ProtocolError::Escaping {
position: input_offset_of_decoded_byte(input, error.utf8_error().valid_up_to()),
reason: REASON_NOT_UTF8,
}),
}
}
#[must_use]
pub(crate) fn encode_form_value(input: &str) -> Cow<'_, str> {
if input.bytes().all(is_unreserved) {
return Cow::Borrowed(input);
}
let mut encoded = String::with_capacity(input.len() + input.len() / 2 + 8);
for byte in input.bytes() {
if is_unreserved(byte) {
encoded.push(char::from(byte));
} else {
encoded.push('%');
encoded.push(hex_digit(byte >> 4));
encoded.push(hex_digit(byte & 0x0F));
}
}
Cow::Owned(encoded)
}
#[inline]
const fn is_unreserved(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
}
#[inline]
const fn hex_value(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,
}
}
#[inline]
fn hex_digit(nibble: u8) -> char {
match nibble {
0..=9 => char::from(b'0' + nibble),
10..=15 => char::from(b'A' + nibble - 10),
_ => '0',
}
}
#[cold]
fn input_offset_of_decoded_byte(input: &str, decoded_offset: usize) -> usize {
let bytes = input.as_bytes();
let mut produced = 0usize;
let mut index = 0usize;
while let Some(&byte) = bytes.get(index) {
if produced == decoded_offset {
return index;
}
index += if byte == b'%' { 3 } else { 1 };
produced += 1;
}
bytes.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_percent_decode_no_escape_borrows() -> Result<(), ProtocolError> {
let decoded = percent_decode("20:00:33")?;
assert!(matches!(decoded, Cow::Borrowed(_)));
assert_eq!(decoded, "20:00:33");
Ok(())
}
#[test]
fn test_percent_decode_empty_input_borrows() -> Result<(), ProtocolError> {
let decoded = percent_decode("")?;
assert!(matches!(decoded, Cow::Borrowed(_)));
assert_eq!(decoded, "");
Ok(())
}
#[test]
fn test_percent_decode_non_ascii_without_escape_borrows() -> Result<(), ProtocolError> {
let decoded = percent_decode("Suspended — é€🚀")?;
assert!(matches!(decoded, Cow::Borrowed(_)));
assert_eq!(decoded, "Suspended — é€🚀");
Ok(())
}
#[test]
fn test_percent_decode_with_escape_allocates() -> Result<(), ProtocolError> {
let decoded = percent_decode("aa%7Cbb")?;
assert!(matches!(decoded, Cow::Owned(_)));
Ok(())
}
#[test]
fn test_percent_decode_spec_pipe_example_decodes_separator() -> Result<(), ProtocolError> {
assert_eq!(percent_decode("aa%7Cbb")?, "aa|bb");
Ok(())
}
#[test]
fn test_percent_decode_spec_snapshot_json_example_decodes_separator()
-> Result<(), ProtocolError> {
let wire = r#"{ "text": "aa%7Cbb", "attributes": [ { "font": "courier" }, "..." ] }"#;
let expected = r#"{ "text": "aa|bb", "attributes": [ { "font": "courier" }, "..." ] }"#;
assert_eq!(percent_decode(wire)?, expected);
Ok(())
}
#[test]
fn test_percent_decode_spec_json_patch_payload_decodes_separators() -> Result<(), ProtocolError>
{
let wire = r#"[{"op":"replace","path":"/text","value":"aa%7Cbb%7Ccc"},{"op":"add","path":"/attributes/1","value":"bold"}]"#;
let expected = r#"[{"op":"replace","path":"/text","value":"aa|bb|cc"},{"op":"add","path":"/attributes/1","value":"bold"}]"#;
assert_eq!(percent_decode(wire)?, expected);
Ok(())
}
#[test]
fn test_percent_decode_marker_characters_decode_to_themselves() -> Result<(), ProtocolError> {
assert_eq!(percent_decode("%23")?, "#");
assert_eq!(percent_decode("%24")?, "$");
assert_eq!(percent_decode("%5E")?, "^");
Ok(())
}
#[test]
fn test_percent_decode_line_terminator_decodes() -> Result<(), ProtocolError> {
assert_eq!(percent_decode("a%0D%0Ab")?, "a\r\nb");
Ok(())
}
#[test]
fn test_percent_decode_lowercase_hex_decodes() -> Result<(), ProtocolError> {
assert_eq!(percent_decode("aa%7cbb")?, "aa|bb");
Ok(())
}
#[test]
fn test_percent_decode_uppercase_hex_decodes() -> Result<(), ProtocolError> {
assert_eq!(percent_decode("aa%7Cbb")?, "aa|bb");
Ok(())
}
#[test]
fn test_percent_decode_mixed_case_hex_decodes() -> Result<(), ProtocolError> {
assert_eq!(percent_decode("%2c%2C")?, ",,");
assert_eq!(percent_decode("%c3%A9")?, "é");
assert_eq!(percent_decode("%eF%bF%bD")?, "\u{FFFD}");
Ok(())
}
#[test]
fn test_percent_decode_escaped_percent_is_not_decoded_twice() -> Result<(), ProtocolError> {
assert_eq!(percent_decode("%25")?, "%");
assert_eq!(percent_decode("%2525")?, "%25");
assert_eq!(percent_decode("100%25")?, "100%");
Ok(())
}
#[test]
fn test_percent_decode_nul_byte_decodes() -> Result<(), ProtocolError> {
assert_eq!(percent_decode("a%00b")?, "a\0b");
Ok(())
}
#[test]
fn test_percent_decode_plus_is_left_literal() -> Result<(), ProtocolError> {
let decoded = percent_decode("a+b")?;
assert!(matches!(decoded, Cow::Borrowed(_)));
assert_eq!(decoded, "a+b");
assert_eq!(percent_decode("a%2Bb")?, "a+b");
Ok(())
}
#[test]
fn test_percent_decode_two_byte_sequence_assembles() -> Result<(), ProtocolError> {
assert_eq!(percent_decode("%C3%A9")?, "é");
Ok(())
}
#[test]
fn test_percent_decode_three_byte_sequence_assembles() -> Result<(), ProtocolError> {
assert_eq!(percent_decode("%E2%82%AC")?, "€");
Ok(())
}
#[test]
fn test_percent_decode_four_byte_sequence_assembles() -> Result<(), ProtocolError> {
assert_eq!(percent_decode("%F0%9F%9A%80")?, "🚀");
Ok(())
}
#[test]
fn test_percent_decode_multibyte_between_literals_assembles() -> Result<(), ProtocolError> {
assert_eq!(
percent_decode("caf%C3%A9 %E2%82%AC5 %F0%9F%9A%80!")?,
"café €5 🚀!"
);
Ok(())
}
#[test]
fn test_percent_decode_escaped_and_raw_multibyte_mix_assembles() -> Result<(), ProtocolError> {
assert_eq!(percent_decode("é%C3%A9é")?, "ééé");
Ok(())
}
#[test]
fn test_percent_decode_trailing_percent_errors() {
assert_eq!(
percent_decode("%"),
Err(ProtocolError::Escaping {
position: 0,
reason: REASON_TRUNCATED,
})
);
assert_eq!(
percent_decode("abc%"),
Err(ProtocolError::Escaping {
position: 3,
reason: REASON_TRUNCATED,
})
);
}
#[test]
fn test_percent_decode_single_hex_digit_at_end_errors() {
assert_eq!(
percent_decode("%7"),
Err(ProtocolError::Escaping {
position: 0,
reason: REASON_TRUNCATED,
})
);
assert_eq!(
percent_decode("aa%7"),
Err(ProtocolError::Escaping {
position: 2,
reason: REASON_TRUNCATED,
})
);
}
#[test]
fn test_percent_decode_non_hex_digits_error() {
assert_eq!(
percent_decode("%ZZ"),
Err(ProtocolError::Escaping {
position: 0,
reason: REASON_NOT_HEX,
})
);
assert_eq!(
percent_decode("aa%7Gbb"),
Err(ProtocolError::Escaping {
position: 2,
reason: REASON_NOT_HEX,
})
);
assert_eq!(
percent_decode("%%41"),
Err(ProtocolError::Escaping {
position: 0,
reason: REASON_NOT_HEX,
})
);
}
#[test]
fn test_percent_decode_reports_position_of_second_bad_escape() {
assert_eq!(
percent_decode("%41%ZZ"),
Err(ProtocolError::Escaping {
position: 3,
reason: REASON_NOT_HEX,
})
);
}
fn unsupported_utf8_err(position: usize) -> ProtocolError {
ProtocolError::Escaping {
position,
reason: REASON_NOT_UTF8,
}
}
#[test]
fn test_percent_decode_truncated_utf8_sequence_errors() {
assert_eq!(percent_decode("%C3%28"), Err(unsupported_utf8_err(0)));
}
#[test]
fn test_percent_decode_lone_continuation_byte_errors() {
assert_eq!(percent_decode("%80"), Err(unsupported_utf8_err(0)));
}
#[test]
fn test_percent_decode_invalid_utf8_position_is_an_input_offset() {
assert_eq!(percent_decode("%C3%A9%C3%28"), Err(unsupported_utf8_err(6)));
}
#[test]
fn test_percent_decode_invalid_utf8_after_literals_position_is_an_input_offset() {
assert_eq!(percent_decode("ab%FF"), Err(unsupported_utf8_err(2)));
}
#[test]
fn test_percent_decode_surrogate_encoding_errors() {
assert_eq!(percent_decode("%ED%A0%80"), Err(unsupported_utf8_err(0)));
}
#[test]
fn test_encode_form_value_unreserved_only_borrows() {
let encoded = encode_form_value("abcXYZ019-._~");
assert!(matches!(encoded, Cow::Borrowed(_)));
assert_eq!(encoded, "abcXYZ019-._~");
}
#[test]
fn test_encode_form_value_empty_input_borrows() {
let encoded = encode_form_value("");
assert!(matches!(encoded, Cow::Borrowed(_)));
assert_eq!(encoded, "");
}
#[test]
fn test_encode_form_value_session_id_borrows() {
let encoded = encode_form_value("S1aa6c792585db57aT1726545");
assert!(matches!(encoded, Cow::Borrowed(_)));
}
#[test]
fn test_encode_form_value_spec_group_name_borrows() {
assert!(matches!(
encode_form_value("NASDAQ-100"),
Cow::Borrowed("NASDAQ-100")
));
}
#[test]
fn test_encode_form_value_spec_reserved_characters_are_escaped() {
assert_eq!(encode_form_value("\r"), "%0D");
assert_eq!(encode_form_value("\n"), "%0A");
assert_eq!(encode_form_value("&"), "%26");
assert_eq!(encode_form_value("="), "%3D");
assert_eq!(encode_form_value("%"), "%25");
assert_eq!(encode_form_value("+"), "%2B");
assert_eq!(encode_form_value("\r\n&=%+"), "%0D%0A%26%3D%25%2B");
}
#[test]
fn test_encode_form_value_space_becomes_percent_20() {
assert_eq!(encode_form_value("AAPL MSFT GOOGL"), "AAPL%20MSFT%20GOOGL");
}
#[test]
fn test_encode_form_value_conservative_set_escapes_beyond_the_spec_list() {
assert_eq!(encode_form_value(","), "%2C");
assert_eq!(encode_form_value("|"), "%7C");
assert_eq!(encode_form_value("#"), "%23");
assert_eq!(encode_form_value("$"), "%24");
assert_eq!(encode_form_value("^"), "%5E");
assert_eq!(encode_form_value("/"), "%2F");
assert_eq!(encode_form_value("?"), "%3F");
assert_eq!(encode_form_value(":"), "%3A");
assert_eq!(encode_form_value("\0"), "%00");
}
#[test]
fn test_encode_form_value_hex_digits_are_uppercase() {
let encoded = encode_form_value("~\u{7f}");
assert_eq!(encoded, "~%7F");
assert!(!encoded.contains('f'));
}
#[test]
fn test_encode_form_value_multibyte_is_escaped_per_utf8_byte() {
assert_eq!(encode_form_value("é"), "%C3%A9");
assert_eq!(encode_form_value("€"), "%E2%82%AC");
assert_eq!(encode_form_value("🚀"), "%F0%9F%9A%80");
assert_eq!(encode_form_value("café"), "caf%C3%A9");
}
#[test]
fn test_encode_form_value_mixed_input_escapes_only_what_is_needed() {
assert_eq!(encode_form_value("LS_data=a&b c"), "LS_data%3Da%26b%20c");
}
#[test]
fn test_encode_then_decode_round_trips() -> Result<(), ProtocolError> {
let cases = [
"",
"plain",
"NASDAQ-100",
"AAPL MSFT GOOGL",
"a&b=c%d+e",
"\r\n",
"café €5 🚀",
"#$^|,",
"S1aa6c792585db57aT1726545",
"100% sure",
];
for case in cases {
let encoded = encode_form_value(case);
let decoded = percent_decode(&encoded)?;
assert_eq!(decoded, case, "round trip failed for {case:?}");
}
Ok(())
}
#[test]
fn test_hex_value_accepts_both_cases_and_rejects_others() {
assert_eq!(hex_value(b'0'), Some(0));
assert_eq!(hex_value(b'9'), Some(9));
assert_eq!(hex_value(b'a'), Some(10));
assert_eq!(hex_value(b'f'), Some(15));
assert_eq!(hex_value(b'A'), Some(10));
assert_eq!(hex_value(b'F'), Some(15));
assert_eq!(hex_value(b'g'), None);
assert_eq!(hex_value(b'G'), None);
assert_eq!(hex_value(b'%'), None);
assert_eq!(hex_value(0xC3), None);
}
#[test]
fn test_hex_digit_maps_every_nibble_to_uppercase() {
let digits: String = (0u8..16).map(hex_digit).collect();
assert_eq!(digits, "0123456789ABCDEF");
}
#[test]
fn test_is_unreserved_matches_rfc_3986_set() {
for byte in 0u8..=255 {
let expected = byte.is_ascii_alphanumeric() || b"-._~".contains(&byte);
assert_eq!(is_unreserved(byte), expected, "byte {byte:#04X}");
}
}
}