1use serde::{Deserialize, Serialize};
4use std::fmt;
5
6pub const SIGN_COMMIT: &str = "sign_commit";
8pub const SIGN_RELEASE: &str = "sign_release";
10pub const MANAGE_MEMBERS: &str = "manage_members";
12pub const ROTATE_KEYS: &str = "rotate_keys";
14
15#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
17pub enum CapabilityError {
18 #[error("capability is empty")]
20 Empty,
21 #[error("capability exceeds 64 chars: {0}")]
23 TooLong(usize),
24 #[error("invalid characters in capability '{0}': only alphanumeric, ':', '-', '_' allowed")]
26 InvalidChars(String),
27 #[error(
29 "reserved namespace 'auths:' — use well-known constructors or choose a different prefix"
30 )]
31 ReservedNamespace,
32 #[error("the '{0}' prefix is reserved for infrastructure capabilities")]
34 ReservedInfraNamespace(String),
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66#[serde(try_from = "String", into = "String")]
67pub struct Capability(String);
68
69impl Capability {
70 pub const MAX_LEN: usize = 64;
72
73 const RESERVED_PREFIX: &'static str = "auths:";
75
76 const RESERVED_INFRA_PREFIXES: &'static [&'static str] =
78 &["compute:", "network:", "storage:", "runtime:", "env:"];
79
80 #[inline]
88 pub fn sign_commit() -> Self {
89 Self(SIGN_COMMIT.to_string())
90 }
91
92 #[inline]
96 pub fn sign_release() -> Self {
97 Self(SIGN_RELEASE.to_string())
98 }
99
100 #[inline]
104 pub fn manage_members() -> Self {
105 Self(MANAGE_MEMBERS.to_string())
106 }
107
108 #[inline]
112 pub fn rotate_keys() -> Self {
113 Self(ROTATE_KEYS.to_string())
114 }
115
116 pub fn parse(raw: &str) -> Result<Self, CapabilityError> {
148 let canonical = raw.trim().to_lowercase();
149
150 if canonical.is_empty() {
151 return Err(CapabilityError::Empty);
152 }
153 if canonical.len() > Self::MAX_LEN {
154 return Err(CapabilityError::TooLong(canonical.len()));
155 }
156 if !canonical
157 .chars()
158 .all(|c| c.is_alphanumeric() || c == ':' || c == '-' || c == '_')
159 {
160 return Err(CapabilityError::InvalidChars(canonical));
161 }
162 if canonical.starts_with(Self::RESERVED_PREFIX) {
163 return Err(CapabilityError::ReservedNamespace);
164 }
165 for prefix in Self::RESERVED_INFRA_PREFIXES {
166 if canonical.starts_with(prefix) {
167 return Err(CapabilityError::ReservedInfraNamespace(prefix.to_string()));
168 }
169 }
170
171 Ok(Self(canonical))
172 }
173
174 #[inline]
183 pub fn as_str(&self) -> &str {
184 &self.0
185 }
186
187 pub fn is_well_known(&self) -> bool {
189 matches!(
190 self.0.as_str(),
191 SIGN_COMMIT | SIGN_RELEASE | MANAGE_MEMBERS | ROTATE_KEYS
192 )
193 }
194
195 pub fn namespace(&self) -> Option<&str> {
197 self.0.split(':').next().filter(|_| self.0.contains(':'))
198 }
199}
200
201impl fmt::Display for Capability {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 f.write_str(&self.0)
204 }
205}
206
207impl TryFrom<String> for Capability {
208 type Error = CapabilityError;
209
210 fn try_from(s: String) -> Result<Self, Self::Error> {
211 let canonical = s.trim().to_lowercase();
212
213 if canonical.is_empty() {
214 return Err(CapabilityError::Empty);
215 }
216 if canonical.len() > Self::MAX_LEN {
217 return Err(CapabilityError::TooLong(canonical.len()));
218 }
219 if !canonical
220 .chars()
221 .all(|c| c.is_alphanumeric() || c == ':' || c == '-' || c == '_')
222 {
223 return Err(CapabilityError::InvalidChars(canonical));
224 }
225
226 Ok(Self(canonical))
229 }
230}
231
232impl std::str::FromStr for Capability {
233 type Err = CapabilityError;
234
235 fn from_str(s: &str) -> Result<Self, Self::Err> {
251 let normalized = s.trim().to_lowercase().replace('-', "_");
252 match normalized.as_str() {
253 "sign_commit" | "signcommit" => Ok(Capability::sign_commit()),
254 "sign_release" | "signrelease" => Ok(Capability::sign_release()),
255 "manage_members" | "managemembers" => Ok(Capability::manage_members()),
256 "rotate_keys" | "rotatekeys" => Ok(Capability::rotate_keys()),
257 _ => Capability::parse(&normalized),
258 }
259 }
260}
261
262impl From<Capability> for String {
263 fn from(cap: Capability) -> Self {
264 cap.0
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
277 fn capability_serializes_to_snake_case() {
278 assert_eq!(
279 serde_json::to_string(&Capability::sign_commit()).unwrap(),
280 r#""sign_commit""#
281 );
282 assert_eq!(
283 serde_json::to_string(&Capability::sign_release()).unwrap(),
284 r#""sign_release""#
285 );
286 assert_eq!(
287 serde_json::to_string(&Capability::manage_members()).unwrap(),
288 r#""manage_members""#
289 );
290 assert_eq!(
291 serde_json::to_string(&Capability::rotate_keys()).unwrap(),
292 r#""rotate_keys""#
293 );
294 }
295
296 #[test]
297 fn capability_deserializes_from_snake_case() {
298 assert_eq!(
299 serde_json::from_str::<Capability>(r#""sign_commit""#).unwrap(),
300 Capability::sign_commit()
301 );
302 assert_eq!(
303 serde_json::from_str::<Capability>(r#""sign_release""#).unwrap(),
304 Capability::sign_release()
305 );
306 assert_eq!(
307 serde_json::from_str::<Capability>(r#""manage_members""#).unwrap(),
308 Capability::manage_members()
309 );
310 assert_eq!(
311 serde_json::from_str::<Capability>(r#""rotate_keys""#).unwrap(),
312 Capability::rotate_keys()
313 );
314 }
315
316 #[test]
317 fn capability_custom_serializes_as_string() {
318 let cap = Capability::parse("acme:deploy").unwrap();
319 assert_eq!(serde_json::to_string(&cap).unwrap(), r#""acme:deploy""#);
320 }
321
322 #[test]
323 fn capability_custom_deserializes_unknown_strings() {
324 let cap: Capability = serde_json::from_str(r#""custom-capability""#).unwrap();
326 assert_eq!(cap, Capability::parse("custom-capability").unwrap());
327 }
328
329 #[test]
334 fn capability_parse_accepts_valid_strings() {
335 assert!(Capability::parse("deploy").is_ok());
336 assert!(Capability::parse("acme:deploy").is_ok());
337 assert!(Capability::parse("my-custom-cap").is_ok());
338 assert!(Capability::parse("org:team:action").is_ok());
339 assert!(Capability::parse("with_underscore").is_ok()); }
341
342 #[test]
343 fn capability_parse_rejects_invalid_strings() {
344 assert!(matches!(Capability::parse(""), Err(CapabilityError::Empty)));
346
347 assert!(matches!(
349 Capability::parse(&"a".repeat(65)),
350 Err(CapabilityError::TooLong(65))
351 ));
352
353 assert!(matches!(
355 Capability::parse("has spaces"),
356 Err(CapabilityError::InvalidChars(_))
357 ));
358 assert!(matches!(
359 Capability::parse("has.dot"),
360 Err(CapabilityError::InvalidChars(_))
361 ));
362 }
363
364 #[test]
365 fn capability_parse_rejects_reserved_namespace() {
366 assert!(matches!(
367 Capability::parse("auths:custom"),
368 Err(CapabilityError::ReservedNamespace)
369 ));
370 assert!(matches!(
371 Capability::parse("auths:sign_commit"),
372 Err(CapabilityError::ReservedNamespace)
373 ));
374 }
375
376 #[test]
377 fn capability_parse_accepts_role_markers() {
378 let cap = Capability::parse("role:admin").unwrap();
381 assert_eq!(cap.as_str(), "role:admin");
382
383 let json = serde_json::to_string(&cap).unwrap();
384 let roundtrip: Capability = serde_json::from_str(&json).unwrap();
385 assert_eq!(cap, roundtrip);
386 }
387
388 #[test]
389 fn capability_parse_normalizes_to_lowercase() {
390 let cap = Capability::parse("DEPLOY").unwrap();
391 assert_eq!(cap.as_str(), "deploy");
392
393 let cap = Capability::parse("ACME:Deploy").unwrap();
394 assert_eq!(cap.as_str(), "acme:deploy");
395 }
396
397 #[test]
398 fn capability_parse_trims_whitespace() {
399 let cap = Capability::parse(" deploy ").unwrap();
400 assert_eq!(cap.as_str(), "deploy");
401 }
402
403 #[test]
408 fn capability_is_hashable() {
409 use std::collections::HashSet;
410 let mut set = HashSet::new();
411 set.insert(Capability::sign_commit());
412 set.insert(Capability::sign_release());
413 set.insert(Capability::parse("test").unwrap());
414 assert_eq!(set.len(), 3);
415 assert!(set.contains(&Capability::sign_commit()));
416 }
417
418 #[test]
419 fn capability_equality_with_different_construction_paths() {
420 let from_constructor = Capability::sign_commit();
422 let from_deser: Capability = serde_json::from_str(r#""sign_commit""#).unwrap();
423 assert_eq!(from_constructor, from_deser);
424
425 let from_parse = Capability::parse("acme:deploy").unwrap();
427 let from_deser: Capability = serde_json::from_str(r#""acme:deploy""#).unwrap();
428 assert_eq!(from_parse, from_deser);
429 }
430
431 #[test]
436 fn capability_display_matches_canonical_form() {
437 assert_eq!(Capability::sign_commit().to_string(), "sign_commit");
438 assert_eq!(Capability::sign_release().to_string(), "sign_release");
439 assert_eq!(Capability::manage_members().to_string(), "manage_members");
440 assert_eq!(Capability::rotate_keys().to_string(), "rotate_keys");
441 assert_eq!(
442 Capability::parse("acme:deploy").unwrap().to_string(),
443 "acme:deploy"
444 );
445 }
446
447 #[test]
448 fn capability_as_str_returns_canonical_form() {
449 assert_eq!(Capability::sign_commit().as_str(), "sign_commit");
450 assert_eq!(Capability::sign_release().as_str(), "sign_release");
451 assert_eq!(Capability::manage_members().as_str(), "manage_members");
452 assert_eq!(Capability::rotate_keys().as_str(), "rotate_keys");
453 assert_eq!(
454 Capability::parse("acme:deploy").unwrap().as_str(),
455 "acme:deploy"
456 );
457 }
458
459 #[test]
460 fn capability_is_well_known() {
461 assert!(Capability::sign_commit().is_well_known());
462 assert!(Capability::sign_release().is_well_known());
463 assert!(Capability::manage_members().is_well_known());
464 assert!(Capability::rotate_keys().is_well_known());
465 assert!(!Capability::parse("custom").unwrap().is_well_known());
466 }
467
468 #[test]
469 fn capability_namespace() {
470 assert_eq!(
471 Capability::parse("acme:deploy").unwrap().namespace(),
472 Some("acme")
473 );
474 assert_eq!(
475 Capability::parse("org:team:action").unwrap().namespace(),
476 Some("org")
477 );
478 assert_eq!(Capability::parse("deploy").unwrap().namespace(), None);
479 }
480
481 #[test]
486 fn capability_vec_serializes_as_array() {
487 let caps = vec![Capability::sign_commit(), Capability::sign_release()];
488 let json = serde_json::to_string(&caps).unwrap();
489 assert_eq!(json, r#"["sign_commit","sign_release"]"#);
490 }
491
492 #[test]
493 fn capability_vec_deserializes_from_array() {
494 let json = r#"["sign_commit","manage_members","custom-cap"]"#;
495 let caps: Vec<Capability> = serde_json::from_str(json).unwrap();
496 assert_eq!(caps.len(), 3);
497 assert_eq!(caps[0], Capability::sign_commit());
498 assert_eq!(caps[1], Capability::manage_members());
499 assert_eq!(caps[2], Capability::parse("custom-cap").unwrap());
500 }
501
502 #[test]
507 fn capability_serde_roundtrip_well_known() {
508 let caps = vec![
509 Capability::sign_commit(),
510 Capability::sign_release(),
511 Capability::manage_members(),
512 Capability::rotate_keys(),
513 ];
514 for cap in caps {
515 let json = serde_json::to_string(&cap).unwrap();
516 let roundtrip: Capability = serde_json::from_str(&json).unwrap();
517 assert_eq!(cap, roundtrip);
518 }
519 }
520
521 #[test]
522 fn capability_serde_roundtrip_custom() {
523 let caps = vec![
524 Capability::parse("deploy").unwrap(),
525 Capability::parse("acme:deploy").unwrap(),
526 Capability::parse("org:team:action").unwrap(),
527 ];
528 for cap in caps {
529 let json = serde_json::to_string(&cap).unwrap();
530 let roundtrip: Capability = serde_json::from_str(&json).unwrap();
531 assert_eq!(cap, roundtrip);
532 }
533 }
534}