1use std::collections::BTreeMap;
10
11use crate::{IdentityError, Issuer};
12
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31#[cfg_attr(feature = "serde", serde(transparent))]
32#[derive(Clone, Debug, PartialEq, Eq, Hash)]
33pub struct WorkloadId(String);
34
35impl WorkloadId {
36 pub const MAX_LEN: usize = 2048;
38
39 pub fn build(
45 trust_domain: &TrustDomain,
46 service: &str,
47 tenant_slug: &str,
48 ) -> Result<Self, IdentityError> {
49 validate_path_segment(service, "service")?;
50 validate_path_segment(tenant_slug, "tenant_slug")?;
51 let raw = format!(
52 "spiffe://{}/{}/{}",
53 trust_domain.as_str(),
54 service,
55 tenant_slug
56 );
57 if raw.len() > Self::MAX_LEN {
58 return Err(IdentityError::InvalidSpiffeId(format!(
59 "URI exceeds {} chars",
60 Self::MAX_LEN
61 )));
62 }
63 Ok(Self(raw))
64 }
65
66 pub fn parse(raw: &str) -> Result<Self, IdentityError> {
69 if raw.len() > Self::MAX_LEN {
70 return Err(IdentityError::InvalidSpiffeId(format!(
71 "URI exceeds {} chars",
72 Self::MAX_LEN
73 )));
74 }
75 let after_scheme = raw.strip_prefix("spiffe://").ok_or_else(|| {
76 IdentityError::InvalidSpiffeId(format!("missing 'spiffe://' scheme prefix: {raw}"))
77 })?;
78 if after_scheme.contains('?') || after_scheme.contains('#') {
79 return Err(IdentityError::InvalidSpiffeId(
80 "query and fragment components not permitted".to_string(),
81 ));
82 }
83 let path_start = after_scheme.find('/').unwrap_or(after_scheme.len());
84 let authority = &after_scheme[..path_start];
85 if authority.contains('@') {
86 return Err(IdentityError::InvalidSpiffeId(
87 "userinfo component not permitted".to_string(),
88 ));
89 }
90 if authority.contains(':') {
91 return Err(IdentityError::InvalidSpiffeId(
92 "port component not permitted".to_string(),
93 ));
94 }
95 TrustDomain::new(authority).map_err(|e| match e {
96 IdentityError::InvalidTrustDomain(msg) => {
97 IdentityError::InvalidSpiffeId(format!("invalid trust domain: {msg}"))
98 }
99 other => other,
100 })?;
101 if path_start < after_scheme.len() {
102 let path = &after_scheme[path_start..];
103 if !path.starts_with('/') {
104 return Err(IdentityError::InvalidSpiffeId(
105 "path must start with '/'".to_string(),
106 ));
107 }
108 for segment in path[1..].split('/') {
109 validate_path_segment(segment, "path segment")?;
110 }
111 }
112 Ok(Self(raw.to_string()))
113 }
114
115 pub fn as_str(&self) -> &str {
117 &self.0
118 }
119}
120
121impl std::fmt::Display for WorkloadId {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.write_str(&self.0)
124 }
125}
126
127#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
132#[cfg_attr(feature = "serde", serde(transparent))]
133#[derive(Clone, Debug, PartialEq, Eq, Hash)]
134pub struct TrustDomain(String);
135
136impl TrustDomain {
137 pub const MAX_LEN: usize = 255;
139
140 pub fn new(raw: &str) -> Result<Self, IdentityError> {
143 if raw.is_empty() {
144 return Err(IdentityError::InvalidTrustDomain(
145 "trust domain must not be empty".to_string(),
146 ));
147 }
148 if raw.len() > Self::MAX_LEN {
149 return Err(IdentityError::InvalidTrustDomain(format!(
150 "trust domain exceeds {} chars",
151 Self::MAX_LEN
152 )));
153 }
154 let mut chars = raw.chars();
155 let first = chars.next().expect("non-empty checked above");
156 if !first.is_ascii_alphanumeric() {
157 return Err(IdentityError::InvalidTrustDomain(format!(
158 "must start with an alphanumeric: {raw}"
159 )));
160 }
161 for c in std::iter::once(first).chain(chars) {
162 let valid = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.';
163 if !valid {
164 return Err(IdentityError::InvalidTrustDomain(format!(
165 "invalid character '{c}' in trust domain '{raw}' (expected [a-z0-9.-])"
166 )));
167 }
168 }
169 Ok(Self(raw.to_string()))
170 }
171
172 pub fn as_str(&self) -> &str {
174 &self.0
175 }
176}
177
178impl std::fmt::Display for TrustDomain {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 f.write_str(&self.0)
181 }
182}
183
184#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
190#[derive(Clone, Debug, PartialEq, Eq)]
191pub struct WorkloadPrincipal {
192 pub workload_id: WorkloadId,
194 pub trust_domain: TrustDomain,
198 pub issuer: Issuer,
200 pub tenant_id: crate::TenantId,
202 pub tenant_slug: String,
207 pub service_name: String,
209 pub attributes: BTreeMap<String, serde_json::Value>,
213}
214
215fn validate_path_segment(segment: &str, role: &str) -> Result<(), IdentityError> {
216 if segment.is_empty() {
217 return Err(IdentityError::InvalidComponent(format!(
218 "{role} must not be empty"
219 )));
220 }
221 for c in segment.chars() {
222 let valid = c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '~' || c == '-';
223 if !valid {
224 return Err(IdentityError::InvalidComponent(format!(
225 "invalid character '{c}' in {role} '{segment}' (expected [A-Za-z0-9._~-])"
226 )));
227 }
228 }
229 Ok(())
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn trust_domain_accepts_valid_forms() {
238 assert!(TrustDomain::new("gnomes.local").is_ok());
239 assert!(TrustDomain::new("gnomes.internal").is_ok());
240 assert!(TrustDomain::new("example.com").is_ok());
241 assert!(TrustDomain::new("a").is_ok());
242 assert!(TrustDomain::new("prod-1.example.com").is_ok());
243 }
244
245 #[test]
246 fn trust_domain_rejects_invalid_forms() {
247 assert!(TrustDomain::new("").is_err());
248 assert!(TrustDomain::new("UPPER.case").is_err());
249 assert!(TrustDomain::new("-leading-hyphen").is_err());
250 assert!(TrustDomain::new("has spaces").is_err());
251 assert!(TrustDomain::new("has/slash").is_err());
252 assert!(TrustDomain::new("has:port").is_err());
253 let too_long = "a".repeat(TrustDomain::MAX_LEN + 1);
254 assert!(TrustDomain::new(&too_long).is_err());
255 }
256
257 #[test]
258 fn workload_id_build_round_trips_through_parse() {
259 let trust = TrustDomain::new("gnomes.local").unwrap();
260 let wid = WorkloadId::build(&trust, "compute-worker", "ekekrantz").unwrap();
261 assert_eq!(
262 wid.as_str(),
263 "spiffe://gnomes.local/compute-worker/ekekrantz"
264 );
265 let reparsed = WorkloadId::parse(wid.as_str()).unwrap();
266 assert_eq!(wid, reparsed);
267 }
268
269 #[test]
270 fn workload_id_build_rejects_empty_service() {
271 let trust = TrustDomain::new("gnomes.local").unwrap();
272 assert!(WorkloadId::build(&trust, "", "ekekrantz").is_err());
273 }
274
275 #[test]
276 fn workload_id_build_rejects_empty_tenant_slug() {
277 let trust = TrustDomain::new("gnomes.local").unwrap();
278 assert!(WorkloadId::build(&trust, "compute-worker", "").is_err());
279 }
280
281 #[test]
282 fn workload_id_build_rejects_invalid_chars_in_segment() {
283 let trust = TrustDomain::new("gnomes.local").unwrap();
284 assert!(WorkloadId::build(&trust, "compute worker", "ekekrantz").is_err());
285 assert!(WorkloadId::build(&trust, "compute-worker", "eke/krantz").is_err());
286 assert!(WorkloadId::build(&trust, "compute-worker", "eke?krantz").is_err());
287 }
288
289 #[test]
290 fn workload_id_parse_accepts_canonical_spiffe_uri() {
291 let raw = "spiffe://gnomes.local/compute-worker/ekekrantz";
292 let parsed = WorkloadId::parse(raw).unwrap();
293 assert_eq!(parsed.as_str(), raw);
294 }
295
296 #[test]
297 fn workload_id_parse_accepts_trust_domain_only() {
298 let parsed = WorkloadId::parse("spiffe://gnomes.local").unwrap();
299 assert_eq!(parsed.as_str(), "spiffe://gnomes.local");
300 }
301
302 #[test]
303 fn workload_id_parse_rejects_non_spiffe_scheme() {
304 assert!(WorkloadId::parse("https://gnomes.local/x/y").is_err());
305 assert!(WorkloadId::parse("http://gnomes.local/x/y").is_err());
306 assert!(WorkloadId::parse("/gnomes.local/x/y").is_err());
307 }
308
309 #[test]
310 fn workload_id_parse_rejects_userinfo() {
311 assert!(WorkloadId::parse("spiffe://user@gnomes.local/x").is_err());
312 }
313
314 #[test]
315 fn workload_id_parse_rejects_port() {
316 assert!(WorkloadId::parse("spiffe://gnomes.local:8443/x").is_err());
317 }
318
319 #[test]
320 fn workload_id_parse_rejects_query_and_fragment() {
321 assert!(WorkloadId::parse("spiffe://gnomes.local/x?y=1").is_err());
322 assert!(WorkloadId::parse("spiffe://gnomes.local/x#frag").is_err());
323 }
324
325 #[test]
326 fn workload_id_parse_rejects_empty_segment() {
327 assert!(WorkloadId::parse("spiffe://gnomes.local//x").is_err());
328 assert!(WorkloadId::parse("spiffe://gnomes.local/x//y").is_err());
329 }
330
331 #[test]
332 fn workload_id_parse_rejects_over_length_uri() {
333 let trust = "gnomes.local";
334 let path: String = std::iter::repeat_n('a', WorkloadId::MAX_LEN).collect();
335 let raw = format!("spiffe://{trust}/{path}");
336 assert!(WorkloadId::parse(&raw).is_err());
337 }
338
339 #[test]
340 fn workload_id_parse_rejects_uppercase_trust_domain() {
341 assert!(WorkloadId::parse("spiffe://Gnomes.Local/x").is_err());
342 }
343
344 #[test]
345 fn workload_id_display_matches_as_str() {
346 let trust = TrustDomain::new("gnomes.local").unwrap();
347 let wid = WorkloadId::build(&trust, "feed-worker", "ekekrantz").unwrap();
348 assert_eq!(format!("{wid}"), wid.as_str());
349 }
350
351 #[cfg(feature = "serde")]
352 #[test]
353 fn workload_id_serializes_as_transparent_string() {
354 let trust = TrustDomain::new("gnomes.local").unwrap();
355 let wid = WorkloadId::build(&trust, "compute-worker", "ekekrantz").unwrap();
356 let json = serde_json::to_string(&wid).unwrap();
357 assert_eq!(json, "\"spiffe://gnomes.local/compute-worker/ekekrantz\"");
358 let back: WorkloadId = serde_json::from_str(&json).unwrap();
359 assert_eq!(wid, back);
360 }
361
362 #[cfg(feature = "serde")]
363 #[test]
364 fn trust_domain_serializes_as_transparent_string() {
365 let trust = TrustDomain::new("gnomes.local").unwrap();
366 let json = serde_json::to_string(&trust).unwrap();
367 assert_eq!(json, "\"gnomes.local\"");
368 let back: TrustDomain = serde_json::from_str(&json).unwrap();
369 assert_eq!(trust, back);
370 }
371
372 #[test]
378 fn workload_id_parse_rejects_question_mark_without_hash() {
379 let result = WorkloadId::parse("spiffe://gnomes.local/x?y");
380 assert!(result.is_err());
381 let err = result.unwrap_err();
382 let msg = err.to_string();
383 assert!(
387 msg.contains("query and fragment"),
388 "must reject at the early `?/#` guard, got: {msg}"
389 );
390 }
391
392 #[test]
395 fn trust_domain_display_matches_as_str() {
396 let trust = TrustDomain::new("gnomes.local").unwrap();
397 assert_eq!(format!("{trust}"), "gnomes.local");
398 }
399
400 #[test]
404 fn workload_id_build_accepts_uri_at_exact_max_len() {
405 let trust = TrustDomain::new("a").unwrap();
406 let prefix_len = "spiffe://a/svc/".len();
409 let pad = WorkloadId::MAX_LEN - prefix_len;
410 let tenant = "a".repeat(pad);
411 let result = WorkloadId::build(&trust, "svc", &tenant);
412 assert!(
413 result.is_ok(),
414 "URI of EXACTLY MAX_LEN must build successfully"
415 );
416 assert_eq!(result.unwrap().as_str().len(), WorkloadId::MAX_LEN);
417 }
418
419 #[test]
423 fn workload_id_parse_accepts_uri_at_exact_max_len() {
424 let prefix_len = "spiffe://a/svc/".len();
425 let pad = WorkloadId::MAX_LEN - prefix_len;
426 let raw = format!("spiffe://a/svc/{}", "a".repeat(pad));
427 assert_eq!(raw.len(), WorkloadId::MAX_LEN);
428 let result = WorkloadId::parse(&raw);
429 assert!(
430 result.is_ok(),
431 "URI of EXACTLY MAX_LEN must parse successfully"
432 );
433 }
434}