Skip to main content

sofie_values/web/
url.rs

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