warg_client/
registry_url.rs

1use crate::storage::RegistryDomain;
2use anyhow::{anyhow, bail, Context, Result};
3use reqwest::IntoUrl;
4use url::{Host, Url};
5
6/// The base URL of a registry server.
7// Note: The inner Url always has a scheme and host.
8#[derive(Clone, Eq, PartialEq)]
9pub struct RegistryUrl(Url);
10
11impl RegistryUrl {
12    /// Parses and validates the given URL into a [`RegistryUrl`].
13    pub fn new(url: impl IntoUrl) -> Result<Self> {
14        // Default to a HTTPS scheme if none is provided
15        let mut url: Url = if !url.as_str().contains("://") {
16            Url::parse(&format!("https://{url}", url = url.as_str()))
17                .context("failed to parse registry server URL")?
18        } else {
19            url.into_url()
20                .context("failed to parse registry server URL")?
21        };
22
23        match url.scheme() {
24            "https" => {}
25            "http" => {
26                // Only allow HTTP connections to loopback
27                match url
28                    .host()
29                    .ok_or_else(|| anyhow!("expected a host for URL `{url}`"))?
30                {
31                    Host::Domain(d) => {
32                        if d != "localhost" {
33                            bail!("an unsecured connection is not permitted to `{d}`");
34                        }
35                    }
36                    Host::Ipv4(ip) => {
37                        if !ip.is_loopback() {
38                            bail!("an unsecured connection is not permitted to address `{ip}`");
39                        }
40                    }
41                    Host::Ipv6(ip) => {
42                        if !ip.is_loopback() {
43                            bail!("an unsecured connection is not permitted to address `{ip}`");
44                        }
45                    }
46                }
47            }
48            _ => bail!("expected a HTTPS scheme for URL `{url}`"),
49        }
50
51        // Normalize by appending a '/' if missing
52        if !url.path().ends_with('/') {
53            url.set_path(&(url.path().to_string() + "/"));
54        }
55
56        Ok(Self(url))
57    }
58
59    /// Returns a mostly-human-readable string that identifies the registry and
60    /// contains only the characters `[0-9a-zA-Z-._]`. This string is
61    /// appropriate to use with external systems that can't accept arbitrary
62    /// URLs such as file system paths.
63    pub fn safe_label(&self) -> String {
64        // Host
65        let mut label = match self.0.host().unwrap() {
66            Host::Domain(domain) => domain.to_string(),
67            Host::Ipv4(ip) => ip.to_string(),
68            Host::Ipv6(ip) => format!("ipv6_{ip}").replace(':', "."),
69        };
70        // Port (if not the scheme default)
71        if let Some(port) = self.0.port() {
72            label += &format!("-{port}");
73        }
74        // Path (if not empty)
75        let path = self.0.path().trim_matches('/');
76        if !path.is_empty() {
77            label += "_";
78            // The path is already urlencoded; we just need to replace a few chars.
79            for ch in path.chars() {
80                match ch {
81                    '/' => label += "_",
82                    '%' => label += ".",
83                    '*' => label += ".2A",
84                    '.' => label += ".2E",
85                    '_' => label += ".5F",
86                    oth => label.push(oth),
87                }
88            }
89        }
90        label
91    }
92
93    /// Returns `RegistryDomain` from the `RegistryUrl`.
94    pub fn registry_domain(&self) -> RegistryDomain {
95        RegistryDomain::new(self.safe_label())
96    }
97
98    pub(crate) fn into_url(self) -> Url {
99        self.0
100    }
101
102    pub(crate) fn join(&self, path: &str) -> String {
103        // Url::join can only fail if the base is relative or if the result is
104        // very large (>4GB), neither of which should be possible in this lib.
105        self.0.join(path).unwrap().to_string()
106    }
107}
108
109impl std::str::FromStr for RegistryUrl {
110    type Err = anyhow::Error;
111
112    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
113        Self::new(s)
114    }
115}
116
117impl std::fmt::Display for RegistryUrl {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        write!(f, "{}", self.0)
120    }
121}
122
123impl std::fmt::Debug for RegistryUrl {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.debug_tuple("RegistryUrl")
126            .field(&self.0.as_str())
127            .finish()
128    }
129}
130
131impl From<RegistryUrl> for Url {
132    fn from(value: RegistryUrl) -> Self {
133        value.into_url()
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn must_parse(input: &str) -> RegistryUrl {
142        RegistryUrl::new(input)
143            .unwrap_or_else(|err| panic!("failed to parse valid input {input:?}: {err:?}"))
144    }
145
146    #[test]
147    fn new_valid() {
148        for (input, expected) in [
149            ("bare-host", "https://bare-host/"),
150            ("https://warg.io", "https://warg.io/"),
151            ("https://warg.io/with/path", "https://warg.io/with/path/"),
152            ("http://localhost", "http://localhost/"),
153            ("http://127.0.0.1", "http://127.0.0.1/"),
154            ("http://[::1]", "http://[::1]/"),
155            ("http://localhost:8080", "http://localhost:8080/"),
156            ("https://unchanged/", "https://unchanged/"),
157        ] {
158            assert_eq!(
159                must_parse(input).to_string(),
160                expected,
161                "incorrect output for input {input:?}"
162            )
163        }
164    }
165
166    #[test]
167    fn new_invalid() {
168        for input in [
169            "invalid:url",
170            "bad://scheme",
171            "http://insecure-domain",
172            "http://6.6.6.6/insecure/ipv4",
173            "http://[abcd::1234]/insecure/ipv6",
174        ] {
175            let res = RegistryUrl::new(input);
176            assert!(
177                res.is_err(),
178                "input {input:?} should have failed; got {res:?}"
179            );
180        }
181    }
182
183    #[test]
184    fn safe_label_works() {
185        for (input, expected) in [
186            ("warg.io", "warg.io"),
187            ("http://localhost:80", "localhost"),
188            ("example.com/with/path", "example.com_with_path"),
189            ("port:1234", "port-1234"),
190            ("port:1234/with/path", "port-1234_with_path"),
191            ("https://1.2.3.4:1234/1234", "1.2.3.4-1234_1234"),
192            ("https://[abcd::1234]:5678", "ipv6_abcd..1234-5678"),
193            ("syms/splat*dot.lowdash_", "syms_splat.2Adot.2Elowdash.5F"),
194            ("☃︎/☃︎", "xn--n3h_.E2.98.83.EF.B8.8E"), // punycode host + percent-encoded path
195        ] {
196            let url = must_parse(input);
197            assert_eq!(url.safe_label(), expected);
198        }
199    }
200}