auths_keri/capability.rs
1//! Validated capability identifiers — the atomic unit of authorization in Auths.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6/// Well-known capability string: permission to sign commits.
7pub const SIGN_COMMIT: &str = "sign_commit";
8/// Well-known capability string: permission to sign releases.
9pub const SIGN_RELEASE: &str = "sign_release";
10/// Well-known capability string: permission to add/remove organization members.
11pub const MANAGE_MEMBERS: &str = "manage_members";
12/// Well-known capability string: permission to rotate keys for an identity.
13pub const ROTATE_KEYS: &str = "rotate_keys";
14
15/// Error type for capability parsing and validation.
16#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
17pub enum CapabilityError {
18 /// The capability string is empty.
19 #[error("capability is empty")]
20 Empty,
21 /// The capability string exceeds the maximum length.
22 #[error("capability exceeds 64 chars: {0}")]
23 TooLong(usize),
24 /// The capability string contains invalid characters.
25 #[error("invalid characters in capability '{0}': only alphanumeric, ':', '-', '_' allowed")]
26 InvalidChars(String),
27 /// The capability uses the reserved 'auths:' namespace.
28 #[error(
29 "reserved namespace 'auths:' — use well-known constructors or choose a different prefix"
30 )]
31 ReservedNamespace,
32 /// The capability uses a reserved infrastructure namespace prefix.
33 #[error("the '{0}' prefix is reserved for infrastructure capabilities")]
34 ReservedInfraNamespace(String),
35}
36
37/// A validated capability identifier.
38///
39/// Capabilities are the atomic unit of authorization in Auths.
40/// They follow a namespace convention:
41///
42/// - Well-known capabilities: `sign_commit`, `sign_release`, `manage_members`, `rotate_keys`
43/// - Custom capabilities: any valid string (alphanumeric + `:` + `-` + `_`, max 64 chars)
44///
45/// The `auths:` prefix is reserved for future well-known capabilities and cannot be
46/// used in custom capabilities created via `parse()`.
47///
48/// # Examples
49///
50/// ```
51/// use auths_keri::Capability;
52///
53/// // Well-known capabilities
54/// let cap = Capability::sign_commit();
55/// assert_eq!(cap.as_str(), "sign_commit");
56///
57/// // Custom capabilities
58/// let custom = Capability::parse("acme:deploy").unwrap();
59/// assert_eq!(custom.as_str(), "acme:deploy");
60///
61/// // Reserved namespace is rejected
62/// assert!(Capability::parse("auths:custom").is_err());
63/// ```
64#[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 /// Maximum length for capability strings.
71 pub const MAX_LEN: usize = 64;
72
73 /// Reserved namespace prefix for Auths well-known capabilities.
74 const RESERVED_PREFIX: &'static str = "auths:";
75
76 /// Reserved infrastructure capability namespace prefixes.
77 const RESERVED_INFRA_PREFIXES: &'static [&'static str] =
78 &["compute:", "network:", "storage:", "runtime:", "env:"];
79
80 // ========================================================================
81 // Well-known capability constructors
82 // ========================================================================
83
84 /// Creates the `sign_commit` capability.
85 ///
86 /// Grants permission to sign commits.
87 #[inline]
88 pub fn sign_commit() -> Self {
89 Self(SIGN_COMMIT.to_string())
90 }
91
92 /// Creates the `sign_release` capability.
93 ///
94 /// Grants permission to sign releases.
95 #[inline]
96 pub fn sign_release() -> Self {
97 Self(SIGN_RELEASE.to_string())
98 }
99
100 /// Creates the `manage_members` capability.
101 ///
102 /// Grants permission to add/remove members in an organization.
103 #[inline]
104 pub fn manage_members() -> Self {
105 Self(MANAGE_MEMBERS.to_string())
106 }
107
108 /// Creates the `rotate_keys` capability.
109 ///
110 /// Grants permission to rotate keys for an identity.
111 #[inline]
112 pub fn rotate_keys() -> Self {
113 Self(ROTATE_KEYS.to_string())
114 }
115
116 // ========================================================================
117 // Parsing and validation
118 // ========================================================================
119
120 /// Parses and validates a capability string.
121 ///
122 /// This is the primary way to create custom capabilities. The input is
123 /// trimmed and lowercased to produce a canonical form.
124 ///
125 /// # Validation Rules
126 ///
127 /// - Non-empty
128 /// - Maximum 64 characters
129 /// - Only alphanumeric characters, colons (`:`), hyphens (`-`), and underscores (`_`)
130 /// - Cannot start with `auths:` (reserved namespace)
131 ///
132 /// # Examples
133 ///
134 /// ```
135 /// use auths_keri::Capability;
136 ///
137 /// // Valid custom capabilities
138 /// assert!(Capability::parse("deploy").is_ok());
139 /// assert!(Capability::parse("acme:deploy").is_ok());
140 /// assert!(Capability::parse("org:team:action").is_ok());
141 ///
142 /// // Invalid capabilities
143 /// assert!(Capability::parse("").is_err()); // empty
144 /// assert!(Capability::parse("has space").is_err()); // invalid char
145 /// assert!(Capability::parse("auths:custom").is_err()); // reserved namespace
146 /// ```
147 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 // ========================================================================
175 // Accessors
176 // ========================================================================
177
178 /// Returns the canonical string representation of this capability.
179 ///
180 /// This is the authoritative string form used for comparison, display,
181 /// and serialization.
182 #[inline]
183 pub fn as_str(&self) -> &str {
184 &self.0
185 }
186
187 /// Returns `true` if this is a well-known Auths capability.
188 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 /// Returns the namespace portion of the capability (before first colon), if any.
196 pub fn namespace(&self) -> Option<&str> {
197 self.0.split(':').next().filter(|_| self.0.contains(':'))
198 }
199
200 // ========================================================================
201 // Capability-claim codec — the single grammar for an ACDC `a.capability`
202 // ========================================================================
203
204 /// Separator between capabilities packed into one `a.capability` claim string.
205 ///
206 /// A capability identifier can never contain a comma ([`Capability::parse`]
207 /// only admits alphanumerics, `:`, `-`, `_`), so the comma is an unambiguous
208 /// delimiter for the multi-capability claim.
209 const CLAIM_SEPARATOR: char = ',';
210
211 /// Encode a set of capabilities into the single `a.capability` claim string.
212 ///
213 /// This is the one source of truth for the on-wire grammar: capabilities are
214 /// joined by [`Self::CLAIM_SEPARATOR`]. The issuer writes the claim with this;
215 /// every reader decodes it with [`Self::parse_claim`] — they cannot disagree.
216 ///
217 /// # Examples
218 ///
219 /// ```
220 /// use auths_keri::Capability;
221 /// let caps = [Capability::parse("fs:read").unwrap(), Capability::parse("fs:write").unwrap()];
222 /// assert_eq!(Capability::join_claim(&caps), "fs:read,fs:write");
223 /// ```
224 pub fn join_claim(capabilities: &[Capability]) -> String {
225 capabilities
226 .iter()
227 .map(Capability::as_str)
228 .collect::<Vec<_>>()
229 .join(&Self::CLAIM_SEPARATOR.to_string())
230 }
231
232 /// Decode an `a.capability` claim string into its capabilities.
233 ///
234 /// The inverse of [`Self::join_claim`]: splits on [`Self::CLAIM_SEPARATOR`] and
235 /// parses each segment. Returns `Err` if any segment is not a valid capability,
236 /// so an issuer/verifier grammar mismatch fails closed rather than silently
237 /// admitting a malformed claim. An empty claim yields no capabilities.
238 ///
239 /// # Examples
240 ///
241 /// ```
242 /// use auths_keri::Capability;
243 /// let caps = Capability::parse_claim("fs:read,fs:write").unwrap();
244 /// assert_eq!(caps.len(), 2);
245 /// assert!(Capability::parse_claim("").unwrap().is_empty());
246 /// ```
247 pub fn parse_claim(claim: &str) -> Result<Vec<Capability>, CapabilityError> {
248 if claim.is_empty() {
249 return Ok(Vec::new());
250 }
251 claim
252 .split(Self::CLAIM_SEPARATOR)
253 .map(Capability::parse)
254 .collect()
255 }
256}
257
258impl fmt::Display for Capability {
259 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260 f.write_str(&self.0)
261 }
262}
263
264/// The well-known resource name of the call-count usage cap (`calls:<N>`).
265///
266/// A capability of the form `calls:<N>` (or `calls<=<N>`) is not an opaque
267/// presence token — it is a *quantitative* predicate bounding how many times the
268/// credential may be exercised. [`UsageCap::from_capability`] recognizes it; the
269/// verifier enforces the bound against a monotonic usage record so the `(N+1)`-th
270/// use is unverifiable rather than merely logged.
271const USAGE_CAP_RESOURCE: &str = "calls";
272
273/// A quantitative usage bound parsed from a [`Capability`].
274///
275/// Capabilities are normally opaque presence tokens (`sign_commit`, `acme:deploy`):
276/// holding the credential grants the action, with no notion of "how many times".
277/// A *quantitative* capability instead bounds a measured resource. The first such
278/// resource is the call count: `calls:<N>` means "at most `N` exercises of this
279/// credential". The bound rides in the capability claim, which is part of the ACDC
280/// SAID, so it cannot be edited without breaking the credential.
281///
282/// The verifier consumes a monotonic usage record alongside the credential: a
283/// presentation whose observed count has reached the cap is rejected with a
284/// distinct cap-exceeded verdict, and a presentation replaying an earlier (lower)
285/// count than the highest already observed is rejected as a rolled-back counter.
286///
287/// # Examples
288///
289/// ```
290/// use auths_keri::{Capability, UsageCap};
291///
292/// let cap = Capability::parse("calls:3").unwrap();
293/// assert_eq!(UsageCap::from_capability(&cap), Some(UsageCap::calls(3)));
294///
295/// // A presence token carries no quantitative bound.
296/// let sign = Capability::sign_commit();
297/// assert_eq!(UsageCap::from_capability(&sign), None);
298/// ```
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
300pub struct UsageCap {
301 /// The maximum number of calls this credential admits.
302 max_calls: u64,
303}
304
305impl UsageCap {
306 /// Construct a call-count cap admitting at most `max_calls` exercises.
307 #[inline]
308 pub const fn calls(max_calls: u64) -> Self {
309 Self { max_calls }
310 }
311
312 /// The maximum number of calls this credential admits.
313 #[inline]
314 pub const fn max_calls(self) -> u64 {
315 self.max_calls
316 }
317
318 /// Parse the quantitative usage bound carried by a capability, if any.
319 ///
320 /// Recognizes the call-count grammar `calls:<N>` and the comparison spelling
321 /// `calls<=<N>`, where `<N>` is a non-negative decimal integer. Any other
322 /// capability (a presence token, a different resource) carries no bound and
323 /// yields `None`. A `calls` resource with a missing or non-numeric bound also
324 /// yields `None` — the credential then has no enforceable quantitative cap and
325 /// is treated as an ordinary (unbounded) capability, never silently zero.
326 pub fn from_capability(cap: &Capability) -> Option<Self> {
327 Self::from_claim_segment(cap.as_str())
328 }
329
330 /// The first quantitative usage bound among a set of capabilities, if any.
331 ///
332 /// A credential carries at most one call-count cap; this returns the first one
333 /// found so the verifier can enforce it regardless of where it sits among the
334 /// granted capabilities.
335 pub fn from_capabilities(caps: &[Capability]) -> Option<Self> {
336 caps.iter().find_map(Self::from_capability)
337 }
338
339 /// Parse one capability-claim segment as a usage bound.
340 ///
341 /// The `calls<=<N>` spelling is accepted even though [`Capability::parse`] does
342 /// not admit `<`/`=` (so it never reaches here through a parsed capability) —
343 /// recognizing both spellings keeps the predicate grammar stable if a future
344 /// capability charset admits the comparison operator.
345 fn from_claim_segment(segment: &str) -> Option<Self> {
346 let bound = segment
347 .strip_prefix(&format!("{USAGE_CAP_RESOURCE}:"))
348 .or_else(|| segment.strip_prefix(&format!("{USAGE_CAP_RESOURCE}<=")))?;
349 bound.parse::<u64>().ok().map(Self::calls)
350 }
351
352 /// Whether a capability *intends* to be a quantitative usage cap.
353 ///
354 /// True for any capability addressing the reserved quantitative resource — the
355 /// `calls:` / `calls<=` prefix — regardless of whether its bound parses. This is
356 /// the discriminator that separates "a `calls`-resource predicate" (which must
357 /// carry a valid numeric bound) from an ordinary presence token that happens to
358 /// be unrecognized. An issuer uses it to reject a `calls`-prefixed capability
359 /// whose bound does not parse, instead of silently minting it as an uncapped
360 /// opaque string.
361 fn segment_targets_usage_resource(segment: &str) -> bool {
362 segment.starts_with(&format!("{USAGE_CAP_RESOURCE}:"))
363 || segment.starts_with(&format!("{USAGE_CAP_RESOURCE}<="))
364 }
365
366 /// Whether `cap` is a MALFORMED quantitative usage predicate.
367 ///
368 /// `true` iff the capability targets the reserved `calls` usage resource (the
369 /// `calls:` / `calls<=` prefix) but its bound does NOT parse to a valid
370 /// non-negative call count — e.g. `calls:`, `calls:abc`, `calls:-1`. Such a
371 /// capability looks like a budget but enforces none: [`Self::from_capability`]
372 /// yields `None`, so the credential would verify at any count. An issuer must
373 /// refuse it rather than mint a cap that is silently no cap.
374 ///
375 /// A well-formed cap (`calls:3`) is **not** malformed; a presence token
376 /// (`sign_commit`, `acme:deploy`) is **not** malformed (it targets no usage
377 /// resource); only a `calls`-resource capability with an unparseable bound is.
378 ///
379 /// # Examples
380 ///
381 /// ```
382 /// use auths_keri::{Capability, UsageCap};
383 ///
384 /// assert!(UsageCap::is_malformed_quant_predicate(&Capability::parse("calls:abc").unwrap()));
385 /// assert!(UsageCap::is_malformed_quant_predicate(&Capability::parse("calls:").unwrap()));
386 /// assert!(!UsageCap::is_malformed_quant_predicate(&Capability::parse("calls:3").unwrap()));
387 /// assert!(!UsageCap::is_malformed_quant_predicate(&Capability::sign_commit()));
388 /// ```
389 pub fn is_malformed_quant_predicate(cap: &Capability) -> bool {
390 let segment = cap.as_str();
391 Self::segment_targets_usage_resource(segment) && Self::from_claim_segment(segment).is_none()
392 }
393}
394
395impl fmt::Display for UsageCap {
396 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397 write!(f, "{USAGE_CAP_RESOURCE}:{}", self.max_calls)
398 }
399}
400
401impl TryFrom<String> for Capability {
402 type Error = CapabilityError;
403
404 fn try_from(s: String) -> Result<Self, Self::Error> {
405 let canonical = s.trim().to_lowercase();
406
407 if canonical.is_empty() {
408 return Err(CapabilityError::Empty);
409 }
410 if canonical.len() > Self::MAX_LEN {
411 return Err(CapabilityError::TooLong(canonical.len()));
412 }
413 if !canonical
414 .chars()
415 .all(|c| c.is_alphanumeric() || c == ':' || c == '-' || c == '_')
416 {
417 return Err(CapabilityError::InvalidChars(canonical));
418 }
419
420 // During deserialization, allow well-known capabilities and auths: prefix
421 // This ensures backward compatibility with existing attestations
422 Ok(Self(canonical))
423 }
424}
425
426impl std::str::FromStr for Capability {
427 type Err = CapabilityError;
428
429 /// Parses a capability string with CLI-friendly alias resolution.
430 ///
431 /// Normalizes the input (trim, lowercase, replace hyphens with underscores)
432 /// and matches well-known capabilities before falling through to
433 /// `Capability::parse()` for custom capability validation.
434 ///
435 /// Args:
436 /// * `s`: The capability string (e.g., "sign_commit", "Sign-Commit").
437 ///
438 /// Usage:
439 /// ```
440 /// use auths_keri::Capability;
441 /// let cap: Capability = "sign_commit".parse().unwrap();
442 /// assert_eq!(cap.as_str(), "sign_commit");
443 /// ```
444 fn from_str(s: &str) -> Result<Self, Self::Err> {
445 let normalized = s.trim().to_lowercase().replace('-', "_");
446 match normalized.as_str() {
447 "sign_commit" | "signcommit" => Ok(Capability::sign_commit()),
448 "sign_release" | "signrelease" => Ok(Capability::sign_release()),
449 "manage_members" | "managemembers" => Ok(Capability::manage_members()),
450 "rotate_keys" | "rotatekeys" => Ok(Capability::rotate_keys()),
451 _ => Capability::parse(&normalized),
452 }
453 }
454}
455
456impl From<Capability> for String {
457 fn from(cap: Capability) -> Self {
458 cap.0
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 // ========================================================================
467 // Capability serialization tests
468 // ========================================================================
469
470 #[test]
471 fn capability_serializes_to_snake_case() {
472 assert_eq!(
473 serde_json::to_string(&Capability::sign_commit()).unwrap(),
474 r#""sign_commit""#
475 );
476 assert_eq!(
477 serde_json::to_string(&Capability::sign_release()).unwrap(),
478 r#""sign_release""#
479 );
480 assert_eq!(
481 serde_json::to_string(&Capability::manage_members()).unwrap(),
482 r#""manage_members""#
483 );
484 assert_eq!(
485 serde_json::to_string(&Capability::rotate_keys()).unwrap(),
486 r#""rotate_keys""#
487 );
488 }
489
490 #[test]
491 fn capability_deserializes_from_snake_case() {
492 assert_eq!(
493 serde_json::from_str::<Capability>(r#""sign_commit""#).unwrap(),
494 Capability::sign_commit()
495 );
496 assert_eq!(
497 serde_json::from_str::<Capability>(r#""sign_release""#).unwrap(),
498 Capability::sign_release()
499 );
500 assert_eq!(
501 serde_json::from_str::<Capability>(r#""manage_members""#).unwrap(),
502 Capability::manage_members()
503 );
504 assert_eq!(
505 serde_json::from_str::<Capability>(r#""rotate_keys""#).unwrap(),
506 Capability::rotate_keys()
507 );
508 }
509
510 #[test]
511 fn capability_custom_serializes_as_string() {
512 let cap = Capability::parse("acme:deploy").unwrap();
513 assert_eq!(serde_json::to_string(&cap).unwrap(), r#""acme:deploy""#);
514 }
515
516 #[test]
517 fn capability_custom_deserializes_unknown_strings() {
518 // Unknown strings become custom capabilities
519 let cap: Capability = serde_json::from_str(r#""custom-capability""#).unwrap();
520 assert_eq!(cap, Capability::parse("custom-capability").unwrap());
521 }
522
523 // ========================================================================
524 // Capability parse() validation tests
525 // ========================================================================
526
527 #[test]
528 fn capability_parse_accepts_valid_strings() {
529 assert!(Capability::parse("deploy").is_ok());
530 assert!(Capability::parse("acme:deploy").is_ok());
531 assert!(Capability::parse("my-custom-cap").is_ok());
532 assert!(Capability::parse("org:team:action").is_ok());
533 assert!(Capability::parse("with_underscore").is_ok()); // underscore allowed
534 }
535
536 #[test]
537 fn capability_parse_rejects_invalid_strings() {
538 // Empty
539 assert!(matches!(Capability::parse(""), Err(CapabilityError::Empty)));
540
541 // Too long
542 assert!(matches!(
543 Capability::parse(&"a".repeat(65)),
544 Err(CapabilityError::TooLong(65))
545 ));
546
547 // Invalid characters
548 assert!(matches!(
549 Capability::parse("has spaces"),
550 Err(CapabilityError::InvalidChars(_))
551 ));
552 assert!(matches!(
553 Capability::parse("has.dot"),
554 Err(CapabilityError::InvalidChars(_))
555 ));
556 }
557
558 #[test]
559 fn capability_parse_rejects_reserved_namespace() {
560 assert!(matches!(
561 Capability::parse("auths:custom"),
562 Err(CapabilityError::ReservedNamespace)
563 ));
564 assert!(matches!(
565 Capability::parse("auths:sign_commit"),
566 Err(CapabilityError::ReservedNamespace)
567 ));
568 }
569
570 #[test]
571 fn capability_parse_accepts_role_markers() {
572 // The org delegation layer encodes role markers ("role:admin") inside
573 // capability vecs; "role:" is not a reserved prefix and must round-trip.
574 let cap = Capability::parse("role:admin").unwrap();
575 assert_eq!(cap.as_str(), "role:admin");
576
577 let json = serde_json::to_string(&cap).unwrap();
578 let roundtrip: Capability = serde_json::from_str(&json).unwrap();
579 assert_eq!(cap, roundtrip);
580 }
581
582 #[test]
583 fn capability_parse_normalizes_to_lowercase() {
584 let cap = Capability::parse("DEPLOY").unwrap();
585 assert_eq!(cap.as_str(), "deploy");
586
587 let cap = Capability::parse("ACME:Deploy").unwrap();
588 assert_eq!(cap.as_str(), "acme:deploy");
589 }
590
591 #[test]
592 fn capability_parse_trims_whitespace() {
593 let cap = Capability::parse(" deploy ").unwrap();
594 assert_eq!(cap.as_str(), "deploy");
595 }
596
597 // ========================================================================
598 // Capability equality and hashing tests
599 // ========================================================================
600
601 #[test]
602 fn capability_is_hashable() {
603 use std::collections::HashSet;
604 let mut set = HashSet::new();
605 set.insert(Capability::sign_commit());
606 set.insert(Capability::sign_release());
607 set.insert(Capability::parse("test").unwrap());
608 assert_eq!(set.len(), 3);
609 assert!(set.contains(&Capability::sign_commit()));
610 }
611
612 #[test]
613 fn capability_equality_with_different_construction_paths() {
614 // Well-known constructor equals deserialized
615 let from_constructor = Capability::sign_commit();
616 let from_deser: Capability = serde_json::from_str(r#""sign_commit""#).unwrap();
617 assert_eq!(from_constructor, from_deser);
618
619 // Parse equals deserialized for custom capabilities
620 let from_parse = Capability::parse("acme:deploy").unwrap();
621 let from_deser: Capability = serde_json::from_str(r#""acme:deploy""#).unwrap();
622 assert_eq!(from_parse, from_deser);
623 }
624
625 // ========================================================================
626 // Capability display and accessor tests
627 // ========================================================================
628
629 #[test]
630 fn capability_display_matches_canonical_form() {
631 assert_eq!(Capability::sign_commit().to_string(), "sign_commit");
632 assert_eq!(Capability::sign_release().to_string(), "sign_release");
633 assert_eq!(Capability::manage_members().to_string(), "manage_members");
634 assert_eq!(Capability::rotate_keys().to_string(), "rotate_keys");
635 assert_eq!(
636 Capability::parse("acme:deploy").unwrap().to_string(),
637 "acme:deploy"
638 );
639 }
640
641 #[test]
642 fn capability_as_str_returns_canonical_form() {
643 assert_eq!(Capability::sign_commit().as_str(), "sign_commit");
644 assert_eq!(Capability::sign_release().as_str(), "sign_release");
645 assert_eq!(Capability::manage_members().as_str(), "manage_members");
646 assert_eq!(Capability::rotate_keys().as_str(), "rotate_keys");
647 assert_eq!(
648 Capability::parse("acme:deploy").unwrap().as_str(),
649 "acme:deploy"
650 );
651 }
652
653 #[test]
654 fn capability_is_well_known() {
655 assert!(Capability::sign_commit().is_well_known());
656 assert!(Capability::sign_release().is_well_known());
657 assert!(Capability::manage_members().is_well_known());
658 assert!(Capability::rotate_keys().is_well_known());
659 assert!(!Capability::parse("custom").unwrap().is_well_known());
660 }
661
662 #[test]
663 fn capability_namespace() {
664 assert_eq!(
665 Capability::parse("acme:deploy").unwrap().namespace(),
666 Some("acme")
667 );
668 assert_eq!(
669 Capability::parse("org:team:action").unwrap().namespace(),
670 Some("org")
671 );
672 assert_eq!(Capability::parse("deploy").unwrap().namespace(), None);
673 }
674
675 // ========================================================================
676 // Capability vec serialization tests
677 // ========================================================================
678
679 #[test]
680 fn capability_vec_serializes_as_array() {
681 let caps = vec![Capability::sign_commit(), Capability::sign_release()];
682 let json = serde_json::to_string(&caps).unwrap();
683 assert_eq!(json, r#"["sign_commit","sign_release"]"#);
684 }
685
686 #[test]
687 fn capability_vec_deserializes_from_array() {
688 let json = r#"["sign_commit","manage_members","custom-cap"]"#;
689 let caps: Vec<Capability> = serde_json::from_str(json).unwrap();
690 assert_eq!(caps.len(), 3);
691 assert_eq!(caps[0], Capability::sign_commit());
692 assert_eq!(caps[1], Capability::manage_members());
693 assert_eq!(caps[2], Capability::parse("custom-cap").unwrap());
694 }
695
696 // ========================================================================
697 // Capability-claim codec tests — issuer and verifier share one grammar
698 // ========================================================================
699
700 #[test]
701 fn claim_codec_roundtrips_multi_capability() {
702 let caps = vec![
703 Capability::parse("fs:read").unwrap(),
704 Capability::parse("fs:write").unwrap(),
705 ];
706 let claim = Capability::join_claim(&caps);
707 assert_eq!(claim, "fs:read,fs:write");
708 assert_eq!(Capability::parse_claim(&claim).unwrap(), caps);
709 }
710
711 #[test]
712 fn claim_codec_roundtrips_single_capability() {
713 let caps = vec![Capability::sign_commit()];
714 let claim = Capability::join_claim(&caps);
715 assert_eq!(claim, "sign_commit");
716 assert_eq!(Capability::parse_claim(&claim).unwrap(), caps);
717 }
718
719 #[test]
720 fn parse_claim_empty_is_no_capabilities() {
721 assert!(Capability::parse_claim("").unwrap().is_empty());
722 }
723
724 #[test]
725 fn parse_claim_rejects_malformed_segment() {
726 // A segment with an invalid char fails closed rather than silently dropping.
727 assert!(matches!(
728 Capability::parse_claim("fs:read,has space"),
729 Err(CapabilityError::InvalidChars(_))
730 ));
731 }
732
733 #[test]
734 fn join_claim_of_empty_is_empty_string() {
735 assert_eq!(Capability::join_claim(&[]), "");
736 }
737
738 // ========================================================================
739 // UsageCap — quantitative capability predicate tests
740 // ========================================================================
741
742 #[test]
743 fn usage_cap_parses_colon_grammar() {
744 let cap = Capability::parse("calls:3").unwrap();
745 assert_eq!(UsageCap::from_capability(&cap), Some(UsageCap::calls(3)));
746 assert_eq!(UsageCap::from_capability(&cap).unwrap().max_calls(), 3);
747 }
748
749 #[test]
750 fn usage_cap_parses_comparison_grammar() {
751 // `calls<=5` does not pass Capability::parse (charset), but the segment
752 // parser recognizes the comparison spelling directly.
753 assert_eq!(
754 UsageCap::from_claim_segment("calls<=5"),
755 Some(UsageCap::calls(5))
756 );
757 }
758
759 #[test]
760 fn usage_cap_zero_is_a_real_bound() {
761 let cap = Capability::parse("calls:0").unwrap();
762 assert_eq!(UsageCap::from_capability(&cap), Some(UsageCap::calls(0)));
763 }
764
765 #[test]
766 fn presence_token_carries_no_usage_cap() {
767 assert_eq!(UsageCap::from_capability(&Capability::sign_commit()), None);
768 let deploy = Capability::parse("acme:deploy").unwrap();
769 assert_eq!(UsageCap::from_capability(&deploy), None);
770 }
771
772 #[test]
773 fn calls_resource_with_non_numeric_bound_is_not_a_cap() {
774 // `calls:abc` is a valid capability string but not an enforceable bound —
775 // it must not silently become a zero cap.
776 let cap = Capability::parse("calls:abc").unwrap();
777 assert_eq!(UsageCap::from_capability(&cap), None);
778 }
779
780 #[test]
781 fn usage_cap_found_among_many_capabilities() {
782 let caps = vec![
783 Capability::sign_commit(),
784 Capability::parse("calls:7").unwrap(),
785 Capability::parse("acme:deploy").unwrap(),
786 ];
787 assert_eq!(UsageCap::from_capabilities(&caps), Some(UsageCap::calls(7)));
788 }
789
790 #[test]
791 fn usage_cap_displays_canonical_grammar() {
792 assert_eq!(UsageCap::calls(3).to_string(), "calls:3");
793 }
794
795 #[test]
796 fn malformed_quant_predicate_flags_unparseable_calls_bounds() {
797 // A `calls`-resource capability whose bound does not parse is malformed: it
798 // looks like a budget but enforces none.
799 for bad in ["calls:", "calls:abc"] {
800 let cap = Capability::parse(bad).unwrap();
801 assert_eq!(
802 UsageCap::from_capability(&cap),
803 None,
804 "{bad} carries no cap"
805 );
806 assert!(
807 UsageCap::is_malformed_quant_predicate(&cap),
808 "{bad} must be flagged malformed"
809 );
810 }
811 // `calls:-1` normalizes to `calls:_1` (a `-` is admitted, not a sign); its
812 // bound `_1` does not parse, so it is malformed too.
813 let neg: Capability = "calls:-1".parse().unwrap();
814 assert!(UsageCap::is_malformed_quant_predicate(&neg));
815 }
816
817 #[test]
818 fn malformed_quant_predicate_passes_wellformed_and_presence_tokens() {
819 // A well-formed cap is not malformed.
820 assert!(!UsageCap::is_malformed_quant_predicate(
821 &Capability::parse("calls:3").unwrap()
822 ));
823 // Zero is a real bound, not malformed.
824 assert!(!UsageCap::is_malformed_quant_predicate(
825 &Capability::parse("calls:0").unwrap()
826 ));
827 // Presence tokens target no usage resource — never malformed.
828 assert!(!UsageCap::is_malformed_quant_predicate(
829 &Capability::sign_commit()
830 ));
831 assert!(!UsageCap::is_malformed_quant_predicate(
832 &Capability::parse("acme:deploy").unwrap()
833 ));
834 // A capability that merely *contains* "calls" but does not target the
835 // resource prefix is not a quantitative predicate.
836 assert!(!UsageCap::is_malformed_quant_predicate(
837 &Capability::parse("recalls:thing").unwrap()
838 ));
839 }
840
841 // ========================================================================
842 // Serde roundtrip tests (critical for backward compat)
843 // ========================================================================
844
845 #[test]
846 fn capability_serde_roundtrip_well_known() {
847 let caps = vec![
848 Capability::sign_commit(),
849 Capability::sign_release(),
850 Capability::manage_members(),
851 Capability::rotate_keys(),
852 ];
853 for cap in caps {
854 let json = serde_json::to_string(&cap).unwrap();
855 let roundtrip: Capability = serde_json::from_str(&json).unwrap();
856 assert_eq!(cap, roundtrip);
857 }
858 }
859
860 #[test]
861 fn capability_serde_roundtrip_custom() {
862 let caps = vec![
863 Capability::parse("deploy").unwrap(),
864 Capability::parse("acme:deploy").unwrap(),
865 Capability::parse("org:team:action").unwrap(),
866 ];
867 for cap in caps {
868 let json = serde_json::to_string(&cap).unwrap();
869 let roundtrip: Capability = serde_json::from_str(&json).unwrap();
870 assert_eq!(cap, roundtrip);
871 }
872 }
873}