1use http::HeaderMap;
2use http::header::CONTENT_TYPE;
3use sofie_core::encoding::percent;
4use sofie_core::{Error, Result};
5
6const MEDIA_TYPE: &str = "application/x-www-form-urlencoded";
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub(crate) enum Encoding {
10 Json,
11 Form,
12}
13
14impl Encoding {
15 pub(crate) fn of(headers: &HeaderMap) -> Self {
16 let media_type = headers
17 .get(CONTENT_TYPE)
18 .and_then(|value| value.to_str().ok())
19 .and_then(|value| value.split(';').next())
20 .map(str::trim);
21 match media_type {
22 Some(media_type) if media_type.eq_ignore_ascii_case(MEDIA_TYPE) => Self::Form,
23 _ => Self::Json,
24 }
25 }
26}
27
28#[must_use]
30pub fn percent_encode(text: &str) -> String {
31 percent::encode(text, is_path_byte)
32}
33
34pub(crate) fn is_path_byte(byte: u8) -> bool {
35 percent::is_unreserved(byte) || byte == b'/'
36}
37
38pub(crate) fn decode(raw: &[u8]) -> Result<String> {
39 unescaped(percent::decode_form(raw)).map_err(bad_form)
40}
41
42pub(crate) fn decode_path_segment(raw: &str) -> Option<String> {
44 unescaped(percent::decode(raw.as_bytes())).ok()
45}
46
47fn unescaped(bytes: Option<Vec<u8>>) -> std::result::Result<String, &'static str> {
48 let bytes = bytes.ok_or("a `%` is not followed by two hexadecimal digits")?;
49 let is_refused = |byte: &u8| byte.is_ascii_control() && !matches!(byte, b'\t' | b'\n' | b'\r');
51 if bytes.iter().any(is_refused) {
52 return Err("it holds a control character");
53 }
54 String::from_utf8(bytes).map_err(|_| "the form is not UTF-8")
55}
56
57pub(crate) fn bad_form(reason: &str) -> Error {
58 Error::BadRequest(format!("the form cannot be read: {reason}"))
59}
60
61#[cfg(test)]
62mod tests {
63 use http::HeaderValue;
64
65 use super::*;
66
67 #[test]
68 fn encodes_every_byte_but_unreserved_ones_and_decodes_back() {
69 let text = "/orders?page=2 & Jürgen+1@x.io";
70 let encoded = percent_encode(text);
71 assert_eq!(encoded, "/orders%3Fpage%3D2%20%26%20J%C3%BCrgen%2B1%40x.io");
72 assert_eq!(decode(encoded.as_bytes()).unwrap(), text);
73 }
74
75 #[test]
76 fn a_path_segment_decodes_escapes_but_keeps_plus() {
77 assert_eq!(
78 decode_path_segment("caf%C3%A9+bar").as_deref(),
79 Some("café+bar")
80 );
81 assert_eq!(decode_path_segment("50%"), None);
82 assert_eq!(decode_path_segment("%FF"), None);
83 }
84
85 #[test]
86 fn a_control_character_is_refused_but_tabs_and_line_breaks_are_kept() {
87 assert!(decode(b"note=a%00b").is_err());
88 assert!(decode(b"note=a%1Bb").is_err());
89 assert!(decode(b"note=a%7Fb").is_err());
90 assert_eq!(decode(b"a%09b%0D%0Ac").unwrap(), "a\tb\r\nc");
91 assert_eq!(decode_path_segment("a%00b"), None);
92 }
93
94 #[test]
95 fn only_a_form_content_type_reads_as_a_form() {
96 let headers = |value: &'static str| {
97 let mut headers = HeaderMap::new();
98 headers.insert(CONTENT_TYPE, HeaderValue::from_static(value));
99 headers
100 };
101 assert_eq!(
102 Encoding::of(&headers("application/x-www-form-urlencoded")),
103 Encoding::Form
104 );
105 assert_eq!(
106 Encoding::of(&headers("Application/X-WWW-Form-Urlencoded; charset=UTF-8")),
107 Encoding::Form
108 );
109 assert_eq!(Encoding::of(&headers("application/json")), Encoding::Json);
110 assert_eq!(
111 Encoding::of(&headers("multipart/form-data")),
112 Encoding::Json
113 );
114 assert_eq!(Encoding::of(&HeaderMap::new()), Encoding::Json);
115 }
116}