ssh_cli/domain/
http_url.rs1#![forbid(unsafe_code)]
6
7use super::error::DomainError;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use std::fmt;
10use url::Url;
11
12#[repr(transparent)]
14#[derive(Clone, Debug, PartialEq, Eq, Hash)]
15pub struct HttpsUrl(Url);
16
17#[repr(transparent)]
19#[derive(Clone, Debug, PartialEq, Eq, Hash)]
20pub struct AcmeOrderUrl(HttpsUrl);
21
22impl HttpsUrl {
23 pub fn try_new(raw: impl AsRef<str>) -> Result<Self, DomainError> {
25 let s = raw.as_ref().trim();
26 if s.is_empty() {
27 return Err(DomainError::new("https_url", "URL must not be empty"));
28 }
29 let url = Url::parse(s).map_err(|e| DomainError::new("https_url", e.to_string()))?;
30 validate_https(&url)?;
31 Ok(Self(url))
32 }
33
34 #[must_use]
36 pub fn as_url(&self) -> &Url {
37 &self.0
38 }
39
40 #[must_use]
42 pub fn as_str(&self) -> &str {
43 self.0.as_str()
44 }
45
46 #[must_use]
48 pub fn into_inner(self) -> Url {
49 self.0
50 }
51}
52
53fn validate_https(url: &Url) -> Result<(), DomainError> {
54 match url.scheme() {
55 "https" => {}
56 "http" => {
57 return Err(DomainError::new(
59 "https_url",
60 "only https scheme is allowed",
61 ));
62 }
63 "data" | "javascript" => {
64 return Err(DomainError::new(
65 "https_url",
66 format!("scheme '{}' is forbidden", url.scheme()),
67 ));
68 }
69 other => {
70 return Err(DomainError::new(
71 "https_url",
72 format!("unsupported scheme '{other}' (expected https)"),
73 ));
74 }
75 }
76 if url.host_str().map(str::is_empty).unwrap_or(true) {
77 return Err(DomainError::new("https_url", "URL host must not be empty"));
78 }
79 if url.cannot_be_a_base() {
80 return Err(DomainError::new(
81 "https_url",
82 "URL must be a hierarchical base URL",
83 ));
84 }
85 Ok(())
86}
87
88impl fmt::Display for HttpsUrl {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 f.write_str(self.as_str())
91 }
92}
93
94impl AsRef<str> for HttpsUrl {
95 fn as_ref(&self) -> &str {
96 self.as_str()
97 }
98}
99
100impl Serialize for HttpsUrl {
101 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
102 s.serialize_str(self.as_str())
103 }
104}
105
106impl<'de> Deserialize<'de> for HttpsUrl {
107 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
108 let s = String::deserialize(d)?;
109 Self::try_new(s).map_err(serde::de::Error::custom)
110 }
111}
112
113impl TryFrom<String> for HttpsUrl {
114 type Error = DomainError;
115
116 fn try_from(value: String) -> Result<Self, Self::Error> {
117 Self::try_new(value)
118 }
119}
120
121impl TryFrom<&str> for HttpsUrl {
122 type Error = DomainError;
123
124 fn try_from(value: &str) -> Result<Self, Self::Error> {
125 Self::try_new(value)
126 }
127}
128
129impl AcmeOrderUrl {
130 pub fn try_new(raw: impl AsRef<str>) -> Result<Self, DomainError> {
132 HttpsUrl::try_new(raw)
133 .map(Self)
134 .map_err(|e| DomainError::new("acme_order_url", e.message))
135 }
136
137 #[must_use]
139 pub fn from_https(url: HttpsUrl) -> Self {
140 Self(url)
141 }
142
143 #[must_use]
145 pub fn as_str(&self) -> &str {
146 self.0.as_str()
147 }
148
149 #[must_use]
151 pub fn as_url(&self) -> &Url {
152 self.0.as_url()
153 }
154
155 #[must_use]
157 pub fn to_string_owned(&self) -> String {
158 self.as_str().to_owned()
159 }
160}
161
162impl fmt::Display for AcmeOrderUrl {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 f.write_str(self.as_str())
165 }
166}
167
168impl Serialize for AcmeOrderUrl {
169 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
170 s.serialize_str(self.as_str())
171 }
172}
173
174impl<'de> Deserialize<'de> for AcmeOrderUrl {
175 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
176 let s = String::deserialize(d)?;
177 Self::try_new(s).map_err(serde::de::Error::custom)
178 }
179}
180
181impl TryFrom<String> for AcmeOrderUrl {
182 type Error = DomainError;
183
184 fn try_from(value: String) -> Result<Self, Self::Error> {
185 Self::try_new(value)
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn accepts_https() {
195 let u = HttpsUrl::try_new("https://acme-v02.api.letsencrypt.org/directory").unwrap();
196 assert_eq!(u.as_url().scheme(), "https");
197 assert!(u.as_url().host_str().is_some());
198 }
199
200 #[test]
201 fn rejects_http_data_js() {
202 assert!(HttpsUrl::try_new("http://example.com").is_err());
203 assert!(HttpsUrl::try_new("data:text/plain,hi").is_err());
204 assert!(HttpsUrl::try_new("javascript:alert(1)").is_err());
205 assert!(HttpsUrl::try_new("not a url").is_err());
206 }
207
208 #[test]
209 fn acme_order_serde() {
210 let u = AcmeOrderUrl::try_new("https://example.com/acme/order/1").unwrap();
211 let j = serde_json::to_string(&u).unwrap();
212 let back: AcmeOrderUrl = serde_json::from_str(&j).unwrap();
213 assert_eq!(u, back);
214 }
215}