1use serde::Serialize;
33
34use crate::routing::RoutingHostname;
35
36pub const EPHEMERAL_ID_HASH_LEN: usize = 8;
41
42const RESERVED_APP_LABELS: &[&str] = &["auth", "cracha"];
45
46#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
48pub enum HostnameError {
49 #[error("invalid DNS label {label:?} for segment {segment}: {reason}")]
50 InvalidLabel {
51 segment: &'static str,
52 label: String,
53 reason: &'static str,
54 },
55 #[error("app label {0:?} is reserved for the saguão control plane")]
56 ReservedApp(String),
57}
58
59pub fn fmt_fqdn(
67 app: &str,
68 ephemeral_id: &str,
69 cluster: &str,
70 location: &str,
71 domain: &str,
72) -> Result<String, HostnameError> {
73 validate_label("app", app)?;
74 if RESERVED_APP_LABELS.contains(&app) {
75 return Err(HostnameError::ReservedApp(app.to_string()));
76 }
77 validate_label("ephemeral_id", ephemeral_id)?;
78 validate_label("cluster", cluster)?;
79 validate_label("location", location)?;
80 validate_domain("domain", domain)?;
81 Ok(format!(
82 "{app}.{ephemeral_id}.{cluster}.{location}.{domain}"
83 ))
84}
85
86pub fn fmt_fqdn_stable(
94 app: &str,
95 cluster: &str,
96 location: &str,
97 domain: &str,
98) -> Result<String, HostnameError> {
99 validate_label("app", app)?;
100 if RESERVED_APP_LABELS.contains(&app) {
101 return Err(HostnameError::ReservedApp(app.to_string()));
102 }
103 validate_label("cluster", cluster)?;
104 validate_label("location", location)?;
105 validate_domain("domain", domain)?;
106 Ok(format!("{app}.{cluster}.{location}.{domain}"))
107}
108
109pub fn ephemeral_id_from_spec<T: Serialize>(spec: &T) -> Result<String, HostnameError> {
116 let bytes = canonical_json(spec).map_err(|_| HostnameError::InvalidLabel {
117 segment: "spec",
118 label: "<unserializable>".into(),
119 reason: "spec failed to canonicalize",
120 })?;
121 Ok(short_hex_blake3(&bytes, EPHEMERAL_ID_HASH_LEN))
122}
123
124pub fn resolve_ephemeral_id<'a>(
133 hostname: &'a RoutingHostname,
134 fallback_hash: &'a str,
135) -> &'a str {
136 match &hostname.instance {
137 Some(s) if !s.is_empty() => s.as_str(),
138 _ => fallback_hash,
139 }
140}
141
142fn validate_label(segment: &'static str, label: &str) -> Result<(), HostnameError> {
145 if label.is_empty() || label.len() > 63 {
146 return Err(HostnameError::InvalidLabel {
147 segment,
148 label: label.to_string(),
149 reason: "must be 1–63 characters",
150 });
151 }
152 if label.starts_with('-') || label.ends_with('-') {
153 return Err(HostnameError::InvalidLabel {
154 segment,
155 label: label.to_string(),
156 reason: "must not start or end with a hyphen",
157 });
158 }
159 if !label
160 .chars()
161 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
162 {
163 return Err(HostnameError::InvalidLabel {
164 segment,
165 label: label.to_string(),
166 reason: "must contain only [a-z0-9-]",
167 });
168 }
169 Ok(())
170}
171
172fn validate_domain(segment: &'static str, domain: &str) -> Result<(), HostnameError> {
173 if domain.is_empty() {
174 return Err(HostnameError::InvalidLabel {
175 segment,
176 label: domain.to_string(),
177 reason: "must not be empty",
178 });
179 }
180 for piece in domain.split('.') {
182 validate_label(segment, piece)?;
183 }
184 Ok(())
185}
186
187fn canonical_json<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
188 let v = serde_json::to_value(value)?;
191 serde_json::to_vec(&v)
192}
193
194fn short_hex_blake3(bytes: &[u8], len: usize) -> String {
195 let hex = blake3::hash(bytes).to_hex().to_string();
196 hex.chars().take(len).collect()
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use serde::Deserialize;
203
204 #[test]
205 fn fmt_fqdn_per_instance() {
206 let f = fmt_fqdn("gator", "akeyless-prod", "pleme-dev", "use1", "quero.lol").unwrap();
207 assert_eq!(f, "gator.akeyless-prod.pleme-dev.use1.quero.lol");
208 }
209
210 #[test]
211 fn fmt_fqdn_stable_form() {
212 let f = fmt_fqdn_stable("gator", "pleme-dev", "use1", "quero.lol").unwrap();
213 assert_eq!(f, "gator.pleme-dev.use1.quero.lol");
214 }
215
216 #[test]
217 fn fmt_fqdn_with_multilevel_domain() {
218 let f = fmt_fqdn("api", "env-a", "rio", "us", "internal.example.com").unwrap();
219 assert_eq!(f, "api.env-a.rio.us.internal.example.com");
220 }
221
222 #[test]
223 fn reserved_app_rejected() {
224 let r = fmt_fqdn("auth", "x", "y", "z", "example.com");
225 assert!(matches!(r, Err(HostnameError::ReservedApp(_))));
226 let r = fmt_fqdn_stable("cracha", "y", "z", "example.com");
227 assert!(matches!(r, Err(HostnameError::ReservedApp(_))));
228 }
229
230 #[test]
231 fn empty_label_rejected() {
232 let r = fmt_fqdn("", "x", "y", "z", "example.com");
233 assert!(matches!(r, Err(HostnameError::InvalidLabel { segment: "app", .. })));
234 }
235
236 #[test]
237 fn too_long_label_rejected() {
238 let long = "a".repeat(64);
239 let r = fmt_fqdn(&long, "x", "y", "z", "example.com");
240 assert!(matches!(r, Err(HostnameError::InvalidLabel { .. })));
241 }
242
243 #[test]
244 fn uppercase_label_rejected() {
245 let r = fmt_fqdn("API", "x", "y", "z", "example.com");
246 assert!(matches!(r, Err(HostnameError::InvalidLabel { .. })));
247 }
248
249 #[test]
250 fn leading_hyphen_label_rejected() {
251 let r = fmt_fqdn("api", "-bad", "y", "z", "example.com");
252 assert!(matches!(r, Err(HostnameError::InvalidLabel { .. })));
253 }
254
255 #[test]
256 fn underscore_label_rejected() {
257 let r = fmt_fqdn("api", "x_y", "z", "w", "example.com");
258 assert!(matches!(r, Err(HostnameError::InvalidLabel { .. })));
259 }
260
261 #[test]
262 fn empty_domain_rejected() {
263 let r = fmt_fqdn("api", "x", "y", "z", "");
264 assert!(matches!(r, Err(HostnameError::InvalidLabel { .. })));
265 }
266
267 #[derive(Serialize, Deserialize)]
270 struct TestSpec {
271 a: u32,
272 b: String,
273 }
274
275 #[test]
276 fn ephemeral_id_is_8_hex_chars() {
277 let spec = TestSpec { a: 1, b: "x".into() };
278 let id = ephemeral_id_from_spec(&spec).unwrap();
279 assert_eq!(id.len(), EPHEMERAL_ID_HASH_LEN);
280 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
281 }
282
283 #[test]
284 fn ephemeral_id_is_deterministic() {
285 let s1 = TestSpec { a: 1, b: "x".into() };
286 let s2 = TestSpec { a: 1, b: "x".into() };
287 assert_eq!(
288 ephemeral_id_from_spec(&s1).unwrap(),
289 ephemeral_id_from_spec(&s2).unwrap()
290 );
291 }
292
293 #[test]
294 fn ephemeral_id_changes_with_spec() {
295 let s1 = TestSpec { a: 1, b: "x".into() };
296 let s2 = TestSpec { a: 2, b: "x".into() };
297 let s3 = TestSpec { a: 1, b: "y".into() };
298 let id1 = ephemeral_id_from_spec(&s1).unwrap();
299 let id2 = ephemeral_id_from_spec(&s2).unwrap();
300 let id3 = ephemeral_id_from_spec(&s3).unwrap();
301 assert_ne!(id1, id2);
302 assert_ne!(id1, id3);
303 assert_ne!(id2, id3);
304 }
305
306 #[test]
307 fn ephemeral_id_lowercase_valid_dns_label() {
308 let spec = TestSpec { a: 42, b: "anything".into() };
311 let id = ephemeral_id_from_spec(&spec).unwrap();
312 validate_label("ephemeral_id", &id).unwrap();
313 }
314
315 #[test]
318 fn resolve_named_slot_wins() {
319 let h = RoutingHostname {
320 app: "gator".into(),
321 instance: Some("akeyless-prod".into()),
322 cluster: None,
323 };
324 assert_eq!(resolve_ephemeral_id(&h, "fallback"), "akeyless-prod");
325 }
326
327 #[test]
328 fn resolve_empty_named_falls_back() {
329 let h = RoutingHostname {
330 app: "gator".into(),
331 instance: Some(String::new()),
332 cluster: None,
333 };
334 assert_eq!(resolve_ephemeral_id(&h, "abc123de"), "abc123de");
335 }
336
337 #[test]
338 fn resolve_unset_named_falls_back() {
339 let h = RoutingHostname {
340 app: "gator".into(),
341 instance: None,
342 cluster: None,
343 };
344 assert_eq!(resolve_ephemeral_id(&h, "abc123de"), "abc123de");
345 }
346
347 #[test]
350 fn end_to_end_named_and_unnamed_for_same_process() {
351 let spec = TestSpec { a: 1, b: "x".into() };
352 let hash = ephemeral_id_from_spec(&spec).unwrap();
353
354 let h_named = RoutingHostname {
355 app: "gator".into(),
356 instance: Some("akeyless-prod".into()),
357 cluster: None,
358 };
359 let h_anon = RoutingHostname {
360 app: "gateway".into(),
361 instance: None,
362 cluster: None,
363 };
364
365 let id_named = resolve_ephemeral_id(&h_named, &hash);
366 let id_anon = resolve_ephemeral_id(&h_anon, &hash);
367
368 let fqdn_named =
369 fmt_fqdn(&h_named.app, id_named, "pleme-dev", "use1", "quero.lol").unwrap();
370 let fqdn_anon =
371 fmt_fqdn(&h_anon.app, id_anon, "pleme-dev", "use1", "quero.lol").unwrap();
372
373 assert_eq!(fqdn_named, "gator.akeyless-prod.pleme-dev.use1.quero.lol");
374 assert!(fqdn_anon.starts_with("gateway."));
375 assert!(fqdn_anon.ends_with(".pleme-dev.use1.quero.lol"));
376 assert_eq!(fqdn_anon.matches('.').count(), 5);
380 }
381}