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/// Parse a public endpoint assignment in `<resource-id>.<endpoint-name>=<absolute-url>` form.
10pub fn parse_public_endpoint_assignment(value: &str) -> Result<(String, String, String)> {
11    let (key, public_url) = value.split_once('=').ok_or_else(|| {
12        AlienError::new(ErrorData::PublicUrlInvalid {
13            resource_id: "<missing>".to_string(),
14            reason: "expected <resource-id>.<endpoint-name>=<absolute-url>".to_string(),
15        })
16    })?;
17    let key = key.trim();
18    let public_url = public_url.trim();
19    let (resource_id, endpoint_name) = key.split_once('.').ok_or_else(|| {
20        AlienError::new(ErrorData::PublicUrlInvalid {
21            resource_id: key.to_string(),
22            reason: "expected <resource-id>.<endpoint-name> before '='".to_string(),
23        })
24    })?;
25    validate_public_endpoint_url(resource_id, endpoint_name, public_url)?;
26    Ok((
27        resource_id.to_string(),
28        endpoint_name.to_string(),
29        public_url.to_string(),
30    ))
31}
32
33/// Validate endpoint URL overrides keyed by resource ID and endpoint name.
34pub fn validate_public_endpoint_urls(public_endpoints: &PublicEndpointUrls) -> Result<()> {
35    for (resource_id, endpoints) in public_endpoints {
36        if endpoints.is_empty() {
37            return Err(AlienError::new(ErrorData::PublicUrlInvalid {
38                resource_id: resource_id.to_string(),
39                reason: "at least one endpoint URL is required when a resource is present"
40                    .to_string(),
41            }));
42        }
43        for (endpoint_name, public_url) in endpoints {
44            validate_public_endpoint_url(resource_id, endpoint_name, public_url)?;
45        }
46    }
47    Ok(())
48}
49
50/// Validate one externally supplied endpoint URL.
51pub fn validate_public_endpoint_url(
52    resource_id: &str,
53    endpoint_name: &str,
54    public_url: &str,
55) -> Result<()> {
56    validate_key_part("resource ID", resource_id, resource_id)?;
57    validate_key_part("endpoint name", resource_id, endpoint_name)?;
58    if public_url.trim().is_empty() {
59        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
60            resource_id: resource_id.to_string(),
61            reason: format!("URL is required for endpoint '{endpoint_name}'"),
62        }));
63    }
64
65    let parsed = Url::parse(public_url).map_err(|err| {
66        AlienError::new(ErrorData::PublicUrlInvalid {
67            resource_id: resource_id.to_string(),
68            reason: format!("endpoint '{endpoint_name}' URL must be absolute: {err}"),
69        })
70    })?;
71
72    match parsed.scheme() {
73        "http" | "https" => {}
74        scheme => {
75            return Err(AlienError::new(ErrorData::PublicUrlInvalid {
76                resource_id: resource_id.to_string(),
77                reason: format!(
78                    "endpoint '{endpoint_name}' URL scheme must be http or https, got '{scheme}'"
79                ),
80            }));
81        }
82    }
83
84    let Some(host) = parsed.host_str() else {
85        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
86            resource_id: resource_id.to_string(),
87            reason: format!("endpoint '{endpoint_name}' URL must include a host"),
88        }));
89    };
90    if host.contains('*') {
91        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
92            resource_id: resource_id.to_string(),
93            reason: format!(
94                "endpoint '{endpoint_name}' URL host must be the base hostname, not a wildcard"
95            ),
96        }));
97    }
98    if parsed.query().is_some() || parsed.fragment().is_some() {
99        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
100            resource_id: resource_id.to_string(),
101            reason: format!(
102                "endpoint '{endpoint_name}' URL must not include query parameters or a fragment"
103            ),
104        }));
105    }
106    if parsed.path() != "/" {
107        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
108            resource_id: resource_id.to_string(),
109            reason: format!("endpoint '{endpoint_name}' URL path must be empty or '/'"),
110        }));
111    }
112
113    Ok(())
114}
115
116fn validate_key_part(label: &str, resource_id: &str, value: &str) -> Result<()> {
117    if value.trim().is_empty() {
118        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
119            resource_id: resource_id.to_string(),
120            reason: format!("{label} is required"),
121        }));
122    }
123    if value.trim() != value {
124        return Err(AlienError::new(ErrorData::PublicUrlInvalid {
125            resource_id: resource_id.to_string(),
126            reason: format!("{label} must not contain leading or trailing whitespace"),
127        }));
128    }
129    Ok(())
130}
131
132/// Return the host part of an already-validated public URL.
133pub fn public_url_host(public_url: &str) -> Option<String> {
134    Url::parse(public_url)
135        .ok()
136        .and_then(|url| {
137            url.host_str()
138                .map(|host| host.trim_end_matches('.').to_string())
139        })
140        .filter(|host| !host.is_empty())
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn parses_public_endpoint_assignment() {
149        let (resource_id, endpoint_name, public_url) =
150            parse_public_endpoint_assignment("gateway.api=https://api.example.test")
151                .expect("assignment should parse");
152
153        assert_eq!(resource_id, "gateway");
154        assert_eq!(endpoint_name, "api");
155        assert_eq!(public_url, "https://api.example.test");
156    }
157
158    #[test]
159    fn rejects_invalid_public_endpoint_urls() {
160        for value in [
161            "gateway",
162            "gateway=https://gateway.example.test",
163            ".api=https://gateway.example.test",
164            "gateway.=https://gateway.example.test",
165            "gateway.api=",
166            "gateway.api=ftp://gateway.example.test",
167            "gateway.api=https://*.gateway.example.test",
168            "gateway.api=https://gateway.example.test/path",
169            "gateway.api=https://gateway.example.test?x=1",
170            "gateway.api=https://gateway.example.test#frag",
171        ] {
172            assert!(
173                parse_public_endpoint_assignment(value).is_err(),
174                "{value} should be invalid"
175            );
176        }
177    }
178
179    #[test]
180    fn extracts_public_url_host() {
181        assert_eq!(
182            public_url_host("https://gateway.example.test:8443"),
183            Some("gateway.example.test".to_string())
184        );
185        assert_eq!(public_url_host("not a url"), None);
186    }
187}