Skip to main content

alien_core/
public_urls.rs

1use crate::error::{ErrorData, Result};
2use alien_error::AlienError;
3use std::collections::HashMap;
4use url::Url;
5
6/// Public endpoint URL overrides keyed by resource ID, then endpoint name.
7pub type PublicEndpointUrls = HashMap<String, HashMap<String, String>>;
8
9/// The URL of a resource's primary public endpoint: the first one it declares,
10/// named by `first_declared_endpoint`.
11///
12/// `urls` maps endpoint names to URLs and has no order of its own. Taking whichever
13/// entry a map yields first would choose a different endpoint each time the deployment
14/// config is loaded, so the URL a resource reports would change on its own. Without a
15/// URL for the first declared endpoint, the smallest endpoint name is used.
16pub fn primary_public_endpoint_url<'a>(
17    urls: &'a HashMap<String, String>,
18    first_declared_endpoint: Option<&str>,
19) -> Option<&'a String> {
20    first_declared_endpoint
21        .and_then(|name| urls.get(name))
22        .or_else(|| {
23            urls.iter()
24                .min_by(|(left, _), (right, _)| left.cmp(right))
25                .map(|(_, url)| url)
26        })
27}
28
29/// Parse a public endpoint assignment in `<resource-id>.<endpoint-name>=<absolute-url>` form.
30pub fn parse_public_endpoint_assignment(value: &str) -> Result<(String, String, String)> {
31    let (key, public_url) = value.split_once('=').ok_or_else(|| {
32        AlienError::new(ErrorData::PublicUrlInvalid {
33            resource_id: "<missing>".to_string(),
34            reason: "expected <resource-id>.<endpoint-name>=<absolute-url>".to_string(),
35        })
36    })?;
37    let key = key.trim();
38    let public_url = public_url.trim();
39    let (resource_id, endpoint_name) = key.split_once('.').ok_or_else(|| {
40        AlienError::new(ErrorData::PublicUrlInvalid {
41            resource_id: key.to_string(),
42            reason: "expected <resource-id>.<endpoint-name> before '='".to_string(),
43        })
44    })?;
45    validate_public_endpoint_url(resource_id, endpoint_name, public_url)?;
46    Ok((
47        resource_id.to_string(),
48        endpoint_name.to_string(),
49        public_url.to_string(),
50    ))
51}
52
53/// Validate endpoint URL overrides keyed by resource ID and endpoint name.
54pub fn validate_public_endpoint_urls(public_endpoints: &PublicEndpointUrls) -> Result<()> {
55    for (resource_id, endpoints) in public_endpoints {
56        if endpoints.is_empty() {
57            return Err(AlienError::new(ErrorData::PublicUrlInvalid {
58                resource_id: resource_id.to_string(),
59                reason: "at least one endpoint URL is required when a resource is present"
60                    .to_string(),
61            }));
62        }
63        for (endpoint_name, public_url) in endpoints {
64            validate_public_endpoint_url(resource_id, endpoint_name, public_url)?;
65        }
66    }
67    Ok(())
68}
69
70/// Validate one externally supplied endpoint URL.
71pub fn validate_public_endpoint_url(
72    resource_id: &str,
73    endpoint_name: &str,
74    public_url: &str,
75) -> Result<()> {
76    validate_key_part("resource ID", resource_id, resource_id)?;
77    validate_key_part("endpoint name", resource_id, endpoint_name)?;
78    if public_url.trim().is_empty() {
79        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
80            resource_id: resource_id.to_string(),
81            reason: format!("URL is required for endpoint '{endpoint_name}'"),
82        }));
83    }
84
85    let parsed = Url::parse(public_url).map_err(|err| {
86        AlienError::new(ErrorData::PublicUrlInvalid {
87            resource_id: resource_id.to_string(),
88            reason: format!("endpoint '{endpoint_name}' URL must be absolute: {err}"),
89        })
90    })?;
91
92    match parsed.scheme() {
93        "http" | "https" => {}
94        scheme => {
95            return Err(AlienError::new(ErrorData::PublicUrlInvalid {
96                resource_id: resource_id.to_string(),
97                reason: format!(
98                    "endpoint '{endpoint_name}' URL scheme must be http or https, got '{scheme}'"
99                ),
100            }));
101        }
102    }
103
104    let Some(host) = parsed.host_str() else {
105        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
106            resource_id: resource_id.to_string(),
107            reason: format!("endpoint '{endpoint_name}' URL must include a host"),
108        }));
109    };
110    if host.contains('*') {
111        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
112            resource_id: resource_id.to_string(),
113            reason: format!(
114                "endpoint '{endpoint_name}' URL host must be the base hostname, not a wildcard"
115            ),
116        }));
117    }
118    if parsed.query().is_some() || parsed.fragment().is_some() {
119        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
120            resource_id: resource_id.to_string(),
121            reason: format!(
122                "endpoint '{endpoint_name}' URL must not include query parameters or a fragment"
123            ),
124        }));
125    }
126    if parsed.path() != "/" {
127        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
128            resource_id: resource_id.to_string(),
129            reason: format!("endpoint '{endpoint_name}' URL path must be empty or '/'"),
130        }));
131    }
132
133    Ok(())
134}
135
136fn validate_key_part(label: &str, resource_id: &str, value: &str) -> Result<()> {
137    if value.trim().is_empty() {
138        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
139            resource_id: resource_id.to_string(),
140            reason: format!("{label} is required"),
141        }));
142    }
143    if value.trim() != value {
144        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
145            resource_id: resource_id.to_string(),
146            reason: format!("{label} must not contain leading or trailing whitespace"),
147        }));
148    }
149    Ok(())
150}
151
152/// Return the host part of an already-validated public URL.
153pub fn public_url_host(public_url: &str) -> Option<String> {
154    Url::parse(public_url)
155        .ok()
156        .and_then(|url| {
157            url.host_str()
158                .map(|host| host.trim_end_matches('.').to_string())
159        })
160        .filter(|host| !host.is_empty())
161}
162
163/// Return the effective port of an already-validated public URL.
164pub fn public_url_port(public_url: &str) -> Option<u16> {
165    Url::parse(public_url)
166        .ok()
167        .and_then(|url| url.port_or_known_default())
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn parses_public_endpoint_assignment() {
176        let (resource_id, endpoint_name, public_url) =
177            parse_public_endpoint_assignment("gateway.api=https://api.example.test")
178                .expect("assignment should parse");
179
180        assert_eq!(resource_id, "gateway");
181        assert_eq!(endpoint_name, "api");
182        assert_eq!(public_url, "https://api.example.test");
183    }
184
185    #[test]
186    fn rejects_invalid_public_endpoint_urls() {
187        for value in [
188            "gateway",
189            "gateway=https://gateway.example.test",
190            ".api=https://gateway.example.test",
191            "gateway.=https://gateway.example.test",
192            "gateway.api=",
193            "gateway.api=ftp://gateway.example.test",
194            "gateway.api=https://*.gateway.example.test",
195            "gateway.api=https://gateway.example.test/path",
196            "gateway.api=https://gateway.example.test?x=1",
197            "gateway.api=https://gateway.example.test#frag",
198        ] {
199            assert!(
200                parse_public_endpoint_assignment(value).is_err(),
201                "{value} should be invalid"
202            );
203        }
204    }
205
206    #[test]
207    fn extracts_public_url_host() {
208        assert_eq!(
209            public_url_host("https://gateway.example.test:8443"),
210            Some("gateway.example.test".to_string())
211        );
212        assert_eq!(public_url_host("not a url"), None);
213    }
214
215    #[test]
216    fn extracts_effective_public_url_port() {
217        assert_eq!(public_url_port("https://gateway.example.test"), Some(443));
218        assert_eq!(public_url_port("http://localhost:8080"), Some(8080));
219        assert_eq!(public_url_port("not a url"), None);
220    }
221
222    #[test]
223    fn primary_public_endpoint_url_follows_declaration_order() {
224        // Declared as web, admin, api.
225        let names = ["web", "admin", "api"];
226        for round in 0..64 {
227            // A fresh map per round gets its own hash seed, so iteration order varies.
228            let mut urls = HashMap::new();
229            for index in 0..names.len() {
230                let name = names[(index + round) % names.len()];
231                urls.insert(name.to_string(), format!("https://{name}.example.com"));
232            }
233            assert_eq!(
234                primary_public_endpoint_url(&urls, Some("web")).map(String::as_str),
235                Some("https://web.example.com")
236            );
237            urls.remove("web");
238            assert_eq!(
239                primary_public_endpoint_url(&urls, Some("web")).map(String::as_str),
240                Some("https://admin.example.com"),
241                "without the first declared endpoint the smallest name wins"
242            );
243        }
244    }
245}