Skip to main content

ssh_cli/domain/
http_url.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! HTTPS URL newtypes for ACME and external endpoints (G-DOM-04).
3//!
4//! SSH hosts stay as [`super::names::SshHost`] (hostname/IP), never as URLs.
5#![forbid(unsafe_code)]
6
7use super::error::DomainError;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use std::fmt;
10use url::Url;
11
12/// HTTPS URL with non-empty host (rejects `data:`, `javascript:`, non-https).
13#[repr(transparent)]
14#[derive(Clone, Debug, PartialEq, Eq, Hash)]
15pub struct HttpsUrl(Url);
16
17/// ACME order URL (distinct newtype so it cannot be mixed with generic HTTPS URLs).
18#[repr(transparent)]
19#[derive(Clone, Debug, PartialEq, Eq, Hash)]
20pub struct AcmeOrderUrl(HttpsUrl);
21
22impl HttpsUrl {
23    /// Parses and validates an absolute HTTPS URL.
24    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    /// Borrows the inner [`Url`].
35    #[must_use]
36    pub fn as_url(&self) -> &Url {
37        &self.0
38    }
39
40    /// Canonical string (`Url::as_str`).
41    #[must_use]
42    pub fn as_str(&self) -> &str {
43        self.0.as_str()
44    }
45
46    /// Consumes into [`Url`].
47    #[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            // ACME directories and order URLs must be HTTPS in production agents.
58            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    /// Parses an ACME order URL (HTTPS).
131    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    /// From an already-validated HTTPS URL.
138    #[must_use]
139    pub fn from_https(url: HttpsUrl) -> Self {
140        Self(url)
141    }
142
143    /// Canonical string for ACME client resume.
144    #[must_use]
145    pub fn as_str(&self) -> &str {
146        self.0.as_str()
147    }
148
149    /// Borrows inner URL.
150    #[must_use]
151    pub fn as_url(&self) -> &Url {
152        self.0.as_url()
153    }
154
155    /// Owned string for APIs that take `String`.
156    #[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}