Skip to main content

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}