alien_core/
public_urls.rs1use crate::error::{ErrorData, Result};
2use alien_error::AlienError;
3use std::collections::HashMap;
4use url::Url;
5
6pub type PublicEndpointUrls = HashMap<String, HashMap<String, String>>;
8
9pub 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
33pub 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
50pub 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
132pub 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}