Skip to main content

sofie_values/web/
url.rs

1use std::fmt;
2
3use http::Uri;
4use http::uri::PathAndQuery;
5use sofie_core::{Error, Result};
6
7const MAXIMUM_LENGTH: usize = 2048;
8
9#[derive(Clone, Debug, PartialEq, Eq, Hash)]
10pub struct Url {
11    text: String,
12}
13
14impl Url {
15    pub fn parse(text: &str) -> Result<Self> {
16        if text.len() > MAXIMUM_LENGTH {
17            return Err(invalid_url("must have at most 2048 characters"));
18        }
19        // `Uri` drops a fragment silently, so it is checked apart.
20        let (address, fragment) = match text.split_once('#') {
21            Some((address, fragment)) => (address, Some(fragment)),
22            None => (text, None),
23        };
24        if fragment.is_some_and(|fragment| !is_fragment(fragment)) {
25            return Err(invalid_url(
26                "must not have spaces or a second # in its fragment",
27            ));
28        }
29        let uri: Uri = address
30            .parse()
31            .map_err(|_| invalid_url("must be an address such as https://example.com"))?;
32        let scheme = uri
33            .scheme_str()
34            .map(str::to_ascii_lowercase)
35            .filter(|scheme| matches!(scheme.as_str(), "http" | "https"))
36            .ok_or_else(|| invalid_url("must start with http:// or https://"))?;
37        let authority = uri
38            .authority()
39            .filter(|authority| !authority.host().is_empty())
40            .ok_or_else(|| invalid_url("must name a host"))?
41            .as_str();
42        if authority.contains('@') {
43            return Err(invalid_url("must not carry a user name or password"));
44        }
45        let path = uri.path_and_query().map_or("/", PathAndQuery::as_str);
46        let mut text = format!("{scheme}://{}{path}", authority.to_ascii_lowercase());
47        if let Some(fragment) = fragment {
48            text.push('#');
49            text.push_str(fragment);
50        }
51        Ok(Self { text })
52    }
53
54    #[must_use]
55    pub fn as_str(&self) -> &str {
56        &self.text
57    }
58}
59
60fn is_fragment(fragment: &str) -> bool {
61    fragment
62        .bytes()
63        .all(|byte| byte.is_ascii_graphic() && byte != b'#')
64}
65
66fn invalid_url(message: &str) -> Error {
67    Error::invalid("url", message)
68}
69
70impl fmt::Display for Url {
71    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72        formatter.write_str(&self.text)
73    }
74}
75
76sofie_core::text_value!(Url, text);
77
78crate::present::present_text!(Url, crate::Input::typed(&[("type", "url")]), Exact);
79
80#[cfg(test)]
81mod tests {
82    use sofie_core::SqlValue;
83
84    use super::*;
85    use crate::testing::{assert_refused, round_trip};
86
87    #[test]
88    fn accepts_absolute_web_addresses() {
89        for (text, normalized) in [
90            ("https://example.com", "https://example.com/"),
91            (
92                "HTTPS://Example.COM/Shop?Page=2",
93                "https://example.com/Shop?Page=2",
94            ),
95            ("http://localhost:8080/a/b", "http://localhost:8080/a/b"),
96            (
97                "https://example.com/docs#Install",
98                "https://example.com/docs#Install",
99            ),
100            ("https://[::1]:443/", "https://[::1]:443/"),
101        ] {
102            assert_eq!(Url::parse(text).unwrap().as_str(), normalized, "{text}");
103        }
104    }
105
106    #[test]
107    fn refuses_other_schemes_relative_addresses_and_credentials() {
108        let too_long = format!("https://example.com/{}", "a".repeat(2048));
109        assert_refused(
110            Url::parse,
111            &[
112                "",
113                "example.com",
114                "/relative/path",
115                "ftp://example.com/file",
116                "javascript:alert(1)",
117                "mailto:jane@example.com",
118                "https://jane:secret@example.com/",
119                "https://example.com/a b",
120                "https://example.com/#a#b",
121                "https://example.com/#a b",
122                "https:///path",
123                "https://exämple.com/",
124                &too_long,
125            ],
126        );
127    }
128
129    #[test]
130    fn round_trips_through_a_text_column_and_json() {
131        let url = Url::parse("https://example.com/shop?page=2#top").unwrap();
132        assert_eq!(
133            round_trip(&url),
134            vec![SqlValue::Text("https://example.com/shop?page=2#top".into())]
135        );
136        let json = serde_json::to_value(&url).unwrap();
137        assert_eq!(serde_json::from_value::<Url>(json).unwrap(), url);
138        assert_eq!(url.to_string(), url.as_str());
139    }
140}