Skip to main content

sofie_http/
urlencoded.rs

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/// Unreserved bytes and `/` as they are, every other byte as `%XX`.
29#[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
42/// A path keeps `+` as it is; `None` when the segment does not decode to UTF-8.
43pub(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    // Postgres refuses NUL in text, and no form field means one; tab and line breaks stay.
50    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}