Skip to main content

stellar_agent_toolsets/
capability.rs

1//! Capability taxonomy and capability-set types.
2//!
3//! A [`Capability`] is a typed wallet operation a toolset may request access to.
4//! A [`CapabilitySet`] is the de-duplicated set of capabilities parsed from the
5//! `stellar-agent-capabilities` metadata key.
6
7use std::collections::BTreeSet;
8use std::fmt;
9
10use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
11
12use crate::ToolsetFormatError;
13
14/// The reserved metadata key that carries the capability manifest.
15pub(crate) const CAPABILITY_KEY: &str = "stellar-agent-capabilities";
16
17/// The reserved metadata key prefix for wallet-internal extensions.
18pub(crate) const RESERVED_PREFIX: &str = "stellar-agent-";
19
20/// The explicitly-forbidden capability token.
21pub(crate) const SIGN_TRANSACTION_TOKEN: &str = "sign-transaction";
22
23/// The `sign-payment` capability token.
24///
25/// Distinguished from the forbidden bare `sign-transaction` token: this token
26/// is DECLARABLE by a toolset.  Declaring it confers NOTHING at parse or install
27/// time — the capability is INERT until the wallet's first-invoke gate converts
28/// it into a runtime grant.
29pub(crate) const SIGN_PAYMENT_TOKEN: &str = "sign-payment";
30
31/// The `sign-rule-create` capability token (Package D, GH issue #8).
32///
33/// Same inert-at-declaration posture as [`SIGN_PAYMENT_TOKEN`]: declaring it
34/// confers nothing until the first-invoke gate converts it into a runtime
35/// grant. Gates `stellar_rule_create_commit` — installing an agent-proposed
36/// context rule on-chain.
37pub(crate) const SIGN_RULE_CREATE_TOKEN: &str = "sign-rule-create";
38
39/// A wallet capability that a toolset may declare in its manifest.
40///
41/// `#[non_exhaustive]` so that future releases can add capability kinds without
42/// a breaking change to downstream consumers that match on the enum.
43///
44/// There is deliberately **no** `SignTransaction` variant.  Signing is not
45/// grantable as a flat capability — the `sign-transaction` token in a manifest
46/// is refused with [`ToolsetFormatError::BareSignTransactionForbidden`].
47#[non_exhaustive]
48#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
49pub enum Capability {
50    /// Read the account balance of the agent's configured account.
51    ///
52    /// Maps to the `read-balance` token.
53    ReadBalance,
54
55    /// Propose a transaction for user review (but not sign or submit it).
56    ///
57    /// Maps to the `propose-transaction` token.
58    ProposeTransaction,
59
60    /// Suggest a destination address for a payment.
61    ///
62    /// Maps to the `suggest-destination` token.
63    SuggestDestination,
64
65    /// Observe a ledger event (streaming / webhook subscription).
66    ///
67    /// Maps to the `observe-event` token.
68    ObserveEvent,
69
70    /// Sign and submit a classic payment transaction (signing-adjacent; gated).
71    ///
72    /// Maps to the `sign-payment` token.
73    ///
74    /// # Inert at declaration
75    ///
76    /// Declaring `sign-payment` in a toolset manifest confers NOTHING at parse
77    /// time or install time — unlike the ungated capabilities above, which
78    /// immediately grant their matrix tool at dispatch.  This capability is
79    /// INERT until the wallet's first-invoke gate queues an out-of-band user
80    /// approval and, after the operator approves, converts it into a runtime
81    /// grant stored in the grant store.
82    ///
83    /// The first-invoke gate fires on every invocation where no current,
84    /// matching grant exists (first call, expired grant, novel destination /
85    /// asset / amount-bucket).
86    ///
87    /// Even with a current grant, the per-action payment approval fires
88    /// unconditionally for every toolset-routed payment.  `sign-payment` NEVER
89    /// replaces per-action approval; it is an additive first-invoke consent
90    /// layered before it.
91    SignPayment,
92
93    /// Read the agent's own context rules (spending-limit budgets, expiry,
94    /// signer/policy counts) via the read-only rules-observability tools.
95    ///
96    /// Maps to the `read-rules` token. Separately grantable from
97    /// `read-balance`: rule visibility and balance visibility are distinct
98    /// concerns, so a toolset must request each independently.
99    ReadRules,
100
101    /// Install an agent-proposed context rule on-chain (signing-adjacent;
102    /// gated).
103    ///
104    /// Maps to the `sign-rule-create` token.
105    ///
106    /// # Inert at declaration
107    ///
108    /// Same posture as [`Capability::SignPayment`]: declaring `sign-rule-create`
109    /// confers NOTHING at parse or install time. This capability is INERT
110    /// until the wallet's first-invoke gate queues an out-of-band operator
111    /// approval and, after the operator approves, converts it into a
112    /// runtime grant.
113    ///
114    /// Even with a current grant, the per-proposal `RuleProposalSimulated`
115    /// attestation fires unconditionally for every toolset-routed
116    /// `stellar_rule_create_commit` call. `sign-rule-create` NEVER replaces
117    /// that per-action approval; it is an additive first-invoke consent
118    /// layered before it — same relationship `sign-payment` has to the
119    /// per-action `PaymentSimulated` approval.
120    SignRuleCreate,
121}
122
123impl Capability {
124    /// Returns `true` if this capability involves access to the agent's signing
125    /// key (either for signing or key-derivation purposes).
126    ///
127    /// This predicate is the single source of truth for the install-time
128    /// attestation gate.  The gate calls this function and NEVER matches the
129    /// capability variant directly, so that a future key-touching capability
130    /// forces a compile error here until classified.
131    ///
132    /// The explicit `match` with NO wildcard arm (`_ =>`) ensures that every
133    /// future variant addition requires a conscious classification decision —
134    /// a compile error here is intentional, not accidental.  Any future
135    /// key-touching capability (such as a key-derivation variant) must be
136    /// classified as `true` when added.
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// use stellar_agent_toolsets::Capability;
142    ///
143    /// assert!(Capability::SignPayment.is_key_touching());
144    /// assert!(!Capability::ReadBalance.is_key_touching());
145    /// assert!(!Capability::ProposeTransaction.is_key_touching());
146    /// assert!(!Capability::SuggestDestination.is_key_touching());
147    /// assert!(!Capability::ObserveEvent.is_key_touching());
148    /// ```
149    #[must_use]
150    pub fn is_key_touching(self) -> bool {
151        // IMPORTANT: NO wildcard arm (`_ =>`).  Every variant must be explicitly
152        // classified.  Adding a new `Capability` variant without updating this
153        // match is a compile error, which is the desired forcing function for
154        // the attestation gate.
155        match self {
156            Self::ReadBalance => false,
157            Self::ProposeTransaction => false,
158            Self::SuggestDestination => false,
159            Self::ObserveEvent => false,
160            Self::SignPayment => true,
161            Self::ReadRules => false,
162            Self::SignRuleCreate => true,
163        }
164    }
165}
166
167impl fmt::Display for Capability {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        match self {
170            Self::ReadBalance => f.write_str("read-balance"),
171            Self::ProposeTransaction => f.write_str("propose-transaction"),
172            Self::SuggestDestination => f.write_str("suggest-destination"),
173            Self::ObserveEvent => f.write_str("observe-event"),
174            Self::SignPayment => f.write_str("sign-payment"),
175            Self::ReadRules => f.write_str("read-rules"),
176            Self::SignRuleCreate => f.write_str("sign-rule-create"),
177        }
178    }
179}
180
181/// The de-duplicated set of capabilities declared by a toolset.
182///
183/// An empty set is valid — a toolset that declares no capabilities will be
184/// refused access to every gated wallet operation at dispatch time.
185#[derive(Clone, Debug, Default, PartialEq, Eq)]
186pub struct CapabilitySet(BTreeSet<Capability>);
187
188impl CapabilitySet {
189    /// Construct an empty `CapabilitySet`.
190    #[must_use]
191    pub fn empty() -> Self {
192        Self(BTreeSet::new())
193    }
194
195    /// Returns `true` if the set contains no capabilities.
196    #[must_use]
197    pub fn is_empty(&self) -> bool {
198        self.0.is_empty()
199    }
200
201    /// Returns the number of distinct capabilities in the set.
202    #[must_use]
203    pub fn len(&self) -> usize {
204        self.0.len()
205    }
206
207    /// Returns `true` if the set contains `cap`.
208    #[must_use]
209    pub fn contains(&self, cap: Capability) -> bool {
210        self.0.contains(&cap)
211    }
212
213    /// Returns an iterator over the capabilities in sorted order.
214    pub fn iter(&self) -> impl Iterator<Item = Capability> + '_ {
215        self.0.iter().copied()
216    }
217}
218
219impl IntoIterator for CapabilitySet {
220    type Item = Capability;
221    type IntoIter = std::collections::btree_set::IntoIter<Capability>;
222
223    fn into_iter(self) -> Self::IntoIter {
224        self.0.into_iter()
225    }
226}
227
228// ── Serde impls for Capability and CapabilitySet ──────────────────────────────
229//
230// CapabilitySet serialises as a JSON array of display-token strings
231// (e.g. ["read-balance","propose-transaction"]) — human-readable and
232// forward-compatible with the #[non_exhaustive] rule.
233//
234// Deserialise semantics:
235//
236// - Routes each token through the canonical `match_capability_token` (the same
237//   function used by `parse_capability_value`), so the deserialiser and the
238//   parser cannot drift.
239// - `Ok(cap)` → insert into the set.
240// - `Err(ToolsetFormatError::UnknownCapability)` → silently skipped (forward-compat:
241//   a capability added in a future version of the binary should not break an
242//   existing stored record that was written with that capability).
243// - `Err(ToolsetFormatError::BareSignTransactionForbidden)` → serde deserialize error.
244//   A stored record that declares "sign-transaction" is structurally malformed;
245//   we reject the entire deserialise rather than silently dropping the token,
246//   which would mask a corrupt or tampered record.
247// - Other errors → serde deserialize error (e.g. invalid-charset tokens that should
248//   never appear in a well-formed record but may appear in a tampered one).
249
250impl Serialize for CapabilitySet {
251    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
252        use serde::ser::SerializeSeq;
253        let mut seq = s.serialize_seq(Some(self.0.len()))?;
254        for cap in &self.0 {
255            seq.serialize_element(&cap.to_string())?;
256        }
257        seq.end()
258    }
259}
260
261impl<'de> Deserialize<'de> for CapabilitySet {
262    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
263        struct CapSetVisitor;
264
265        impl<'de> de::Visitor<'de> for CapSetVisitor {
266            type Value = CapabilitySet;
267
268            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269                f.write_str("an array of capability token strings")
270            }
271
272            fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
273                let mut set = BTreeSet::new();
274                while let Some(token) = seq.next_element::<String>()? {
275                    // Route through the canonical token matcher so the
276                    // deserialiser and the parser cannot drift.
277                    //
278                    // Only charset-valid tokens reach match_capability_token; a
279                    // token stored in a record that contains characters outside
280                    // [a-z0-9-] is rejected below.
281                    if !token.chars().all(is_valid_token_char) {
282                        // A tampered or malformed record contains an invalid-charset
283                        // token.  Reject the entire deserialise.
284                        return Err(de::Error::custom(format!(
285                            "capability token '{token}' contains characters outside [a-z0-9-]"
286                        )));
287                    }
288
289                    match match_capability_token(&token) {
290                        Ok(cap) => {
291                            set.insert(cap);
292                        }
293                        Err(ToolsetFormatError::UnknownCapability { .. }) => {
294                            // Forward-compat: a capability added in a future binary
295                            // version was stored in this record; ignore it so the
296                            // record remains loadable on an older binary.
297                        }
298                        Err(ToolsetFormatError::BareSignTransactionForbidden) => {
299                            // A record declaring "sign-transaction" is structurally
300                            // malformed.  Reject the entire deserialise — do NOT
301                            // silently drop.  This surfaces corrupt or tampered records.
302                            return Err(de::Error::custom(
303                                "capability token 'sign-transaction' is forbidden in a \
304                                 stored record; the record is structurally malformed",
305                            ));
306                        }
307                        Err(other) => {
308                            // Any other parse error (should not occur given the charset
309                            // gate above, but handled for robustness).
310                            return Err(de::Error::custom(format!(
311                                "invalid capability token '{token}': {other}"
312                            )));
313                        }
314                    }
315                }
316                Ok(CapabilitySet(set))
317            }
318        }
319
320        d.deserialize_seq(CapSetVisitor)
321    }
322}
323
324/// Parse the `stellar-agent-capabilities` metadata value into a [`CapabilitySet`].
325///
326/// # Public test-helper
327///
328/// This function is `pub(crate)` for internal use; a thin public re-export
329/// `parse_capability_value_pub` is exposed under `#[cfg(any(test, feature = "test-helpers"))]`
330/// so tests in sibling crates can build `CapabilitySet` values without
331/// duplicating the parse logic.
332///
333/// See [`parse_capability_value_pub`].
334///
335/// ## Algorithm
336///
337/// 1. Tokenise on ASCII whitespace, dropping empty tokens.
338/// 2. For each token apply the charset gate: characters outside `[a-z0-9-]` →
339///    [`ToolsetFormatError::CapabilityTokenInvalidChar`].  This gate is applied
340///    BEFORE name-matching so that no casing variant of a recognised or forbidden
341///    token can reach the matching step.
342/// 3. After the charset gate, the token is guaranteed `[a-z0-9-]`:
343///    - `sign-transaction` → [`ToolsetFormatError::BareSignTransactionForbidden`].
344///    - A recognised taxonomy token → its [`Capability`].
345///    - Any other token → [`ToolsetFormatError::UnknownCapability`].
346/// 4. Duplicate tokens in the value deduplicate (a token appearing twice does
347///    not cause an error — only duplicate mapping KEYS cause an error).
348///
349/// # Errors
350///
351/// Returns the first error encountered in token order.
352///
353/// - [`ToolsetFormatError::CapabilityTokenInvalidChar`] — a token with a character
354///   outside `[a-z0-9-]` (catches uppercase, underscores, unicode homoglyphs,
355///   whitespace-internal, control characters).
356/// - [`ToolsetFormatError::BareSignTransactionForbidden`] — the token
357///   `sign-transaction` appears in the manifest.
358/// - [`ToolsetFormatError::UnknownCapability`] — a `[a-z0-9-]` token that is not in
359///   the recognised taxonomy.
360pub(crate) fn parse_capability_value(value: &str) -> Result<CapabilitySet, ToolsetFormatError> {
361    let mut set = BTreeSet::new();
362
363    for token in value.split_ascii_whitespace() {
364        // Step 2: charset gate — must be [a-z0-9-] only.
365        if !token.chars().all(is_valid_token_char) {
366            return Err(ToolsetFormatError::CapabilityTokenInvalidChar {
367                token: token.to_owned(),
368            });
369        }
370
371        // Step 3: name-match within [a-z0-9-] tokens.
372        let cap = match_capability_token(token)?;
373        set.insert(cap);
374    }
375
376    Ok(CapabilitySet(set))
377}
378
379/// Returns `true` if `ch` is in the valid token charset `[a-z0-9-]`.
380///
381/// This is a named predicate so the charset definition lives in one place and is
382/// shared with the `name` field validator in [`crate::parse`].
383#[inline]
384pub(crate) fn is_valid_token_char(ch: char) -> bool {
385    ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-'
386}
387
388/// Public test-helper for `parse_capability_value`.
389///
390/// Exposed under `#[cfg(any(test, feature = "test-helpers"))]` so sibling
391/// crates can build `CapabilitySet` values in tests without reimplementing
392/// the parse logic.
393///
394/// This function has the same behaviour as the internal `parse_capability_value`.
395///
396/// # Errors
397///
398/// Same as `parse_capability_value`.
399#[cfg(any(test, feature = "test-helpers"))]
400pub fn parse_capability_value_pub(value: &str) -> Result<CapabilitySet, ToolsetFormatError> {
401    parse_capability_value(value)
402}
403
404/// Match a token that has already passed the charset gate to a [`Capability`].
405///
406/// # Errors
407///
408/// - [`ToolsetFormatError::BareSignTransactionForbidden`] if `token == "sign-transaction"`.
409/// - [`ToolsetFormatError::UnknownCapability`] if the token is not in the taxonomy.
410fn match_capability_token(token: &str) -> Result<Capability, ToolsetFormatError> {
411    match token {
412        SIGN_TRANSACTION_TOKEN => Err(ToolsetFormatError::BareSignTransactionForbidden),
413        "read-balance" => Ok(Capability::ReadBalance),
414        "propose-transaction" => Ok(Capability::ProposeTransaction),
415        "suggest-destination" => Ok(Capability::SuggestDestination),
416        "observe-event" => Ok(Capability::ObserveEvent),
417        // sign-payment is declarable but INERT at parse/install time.
418        // The first-invoke gate is the sole admission control for this code path.
419        SIGN_PAYMENT_TOKEN => Ok(Capability::SignPayment),
420        "read-rules" => Ok(Capability::ReadRules),
421        // sign-rule-create is declarable but INERT at parse/install time.
422        // The first-invoke gate is the sole admission control for this code path.
423        SIGN_RULE_CREATE_TOKEN => Ok(Capability::SignRuleCreate),
424        other => Err(ToolsetFormatError::UnknownCapability {
425            token: other.to_owned(),
426        }),
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    #![allow(
433        clippy::unwrap_used,
434        clippy::expect_used,
435        reason = "test-only; panics acceptable in unit tests"
436    )]
437
438    use super::*;
439
440    // ── Happy path ────────────────────────────────────────────────────────────
441
442    #[test]
443    fn empty_value_yields_empty_set() {
444        assert!(parse_capability_value("").unwrap().is_empty());
445    }
446
447    #[test]
448    fn whitespace_only_yields_empty_set() {
449        assert!(parse_capability_value("   \t  ").unwrap().is_empty());
450    }
451
452    #[test]
453    fn all_taxonomy_tokens_parse() {
454        let set = parse_capability_value(
455            "read-balance propose-transaction suggest-destination observe-event sign-payment",
456        )
457        .unwrap();
458        assert!(set.contains(Capability::ReadBalance));
459        assert!(set.contains(Capability::ProposeTransaction));
460        assert!(set.contains(Capability::SuggestDestination));
461        assert!(set.contains(Capability::ObserveEvent));
462        assert!(set.contains(Capability::SignPayment));
463        assert_eq!(set.len(), 5);
464    }
465
466    // ── sign-payment is declarable and parses to SignPayment ─────────────────
467
468    #[test]
469    fn sign_payment_parses_to_sign_payment_capability() {
470        let set = parse_capability_value("sign-payment").unwrap();
471        assert!(set.contains(Capability::SignPayment));
472        assert_eq!(set.len(), 1);
473    }
474
475    // ── sign-rule-create is declarable and parses to SignRuleCreate ──────────
476
477    #[test]
478    fn sign_rule_create_parses_to_sign_rule_create_capability() {
479        let set = parse_capability_value("sign-rule-create").unwrap();
480        assert!(set.contains(Capability::SignRuleCreate));
481        assert_eq!(set.len(), 1);
482    }
483
484    #[test]
485    fn sign_rule_create_display_roundtrip() {
486        assert_eq!(Capability::SignRuleCreate.to_string(), "sign-rule-create");
487    }
488
489    #[test]
490    fn sign_payment_display_roundtrip() {
491        assert_eq!(Capability::SignPayment.to_string(), "sign-payment");
492    }
493
494    #[test]
495    fn duplicate_tokens_deduplicate() {
496        let set = parse_capability_value("read-balance read-balance read-balance").unwrap();
497        assert_eq!(set.len(), 1);
498    }
499
500    // ── No-bare-sign airtightness ─────────────────────────────────────────────
501
502    #[test]
503    fn bare_sign_transaction_forbidden() {
504        let err = parse_capability_value("sign-transaction").unwrap_err();
505        assert!(
506            matches!(err, ToolsetFormatError::BareSignTransactionForbidden),
507            "expected BareSignTransactionForbidden, got {err:?}"
508        );
509    }
510
511    #[test]
512    fn sign_transaction_uppercase_refused_at_charset_gate() {
513        // "Sign-Transaction" has uppercase — charset gate catches it BEFORE
514        // the name-match step, so it must be CapabilityTokenInvalidChar,
515        // never BareSignTransactionForbidden or UnknownCapability.
516        let err = parse_capability_value("Sign-Transaction").unwrap_err();
517        assert!(
518            matches!(err, ToolsetFormatError::CapabilityTokenInvalidChar { .. }),
519            "expected CapabilityTokenInvalidChar, got {err:?}"
520        );
521    }
522
523    #[test]
524    fn sign_transaction_all_caps_refused_at_charset_gate() {
525        let err = parse_capability_value("SIGN-TRANSACTION").unwrap_err();
526        assert!(
527            matches!(err, ToolsetFormatError::CapabilityTokenInvalidChar { .. }),
528            "expected CapabilityTokenInvalidChar, got {err:?}"
529        );
530    }
531
532    #[test]
533    fn sign_transaction_underscore_refused_at_charset_gate() {
534        let err = parse_capability_value("sign_transaction").unwrap_err();
535        assert!(
536            matches!(err, ToolsetFormatError::CapabilityTokenInvalidChar { .. }),
537            "expected CapabilityTokenInvalidChar, got {err:?}"
538        );
539    }
540
541    #[test]
542    fn sign_transaction_whitespace_padded_refused() {
543        // Whitespace tokenisation strips leading/trailing whitespace,
544        // so " sign-transaction " produces the exact token "sign-transaction"
545        // which then hits BareSignTransactionForbidden.
546        let err = parse_capability_value(" sign-transaction ").unwrap_err();
547        assert!(
548            matches!(err, ToolsetFormatError::BareSignTransactionForbidden),
549            "expected BareSignTransactionForbidden, got {err:?}"
550        );
551    }
552
553    #[test]
554    fn sign_transaction_unicode_homoglyph_refused_at_charset_gate() {
555        // Use Cyrillic 'ѕ' (U+0455) which looks like 's' — the charset gate must
556        // refuse it as CapabilityTokenInvalidChar.
557        let homoglyph = "ѕign-transaction"; // Cyrillic ѕ, not ASCII s
558        let err = parse_capability_value(homoglyph).unwrap_err();
559        assert!(
560            matches!(err, ToolsetFormatError::CapabilityTokenInvalidChar { .. }),
561            "expected CapabilityTokenInvalidChar, got {err:?}"
562        );
563    }
564
565    #[test]
566    fn read_balance_homoglyph_refused_at_charset_gate() {
567        // Cyrillic 'а' (U+0430) looks like ASCII 'a'
568        let homoglyph = "re\u{0430}d-balance"; // 'а' instead of 'a'
569        let err = parse_capability_value(homoglyph).unwrap_err();
570        assert!(
571            matches!(err, ToolsetFormatError::CapabilityTokenInvalidChar { .. }),
572            "expected CapabilityTokenInvalidChar, got {err:?}"
573        );
574    }
575
576    #[test]
577    fn unknown_token_refused() {
578        let err = parse_capability_value("send-xdr").unwrap_err();
579        assert!(
580            matches!(err, ToolsetFormatError::UnknownCapability { .. }),
581            "expected UnknownCapability, got {err:?}"
582        );
583    }
584
585    #[test]
586    fn tab_in_token_refused_at_charset_gate() {
587        // A token containing a tab that survived tokenisation would be caught by
588        // the charset gate.  (Tokenisation on ASCII whitespace should strip tabs,
589        // but an embedded tab in a long token could arrive here — test it.)
590        let s = "read\tbalance";
591        // split_ascii_whitespace will split on \t, producing "read" and "balance"
592        // both of which are unknown tokens (not in taxonomy).
593        let err = parse_capability_value(s).unwrap_err();
594        assert!(
595            matches!(err, ToolsetFormatError::UnknownCapability { .. }),
596            "got {err:?}"
597        );
598    }
599
600    // ── CapabilitySet Deserialize tests ──────────────────────────────────────
601
602    #[test]
603    fn deserialize_sign_transaction_is_serde_error() {
604        // A stored record with "sign-transaction" must be a deserialize error,
605        // NOT a silent drop.
606        let json = r#"["sign-transaction"]"#;
607        let result: Result<CapabilitySet, _> = serde_json::from_str(json);
608        assert!(
609            result.is_err(),
610            "deserialising sign-transaction must be a serde error, not a silent drop"
611        );
612        let msg = result.unwrap_err().to_string();
613        assert!(
614            msg.contains("sign-transaction"),
615            "error message must mention the forbidden token: {msg}"
616        );
617    }
618
619    #[test]
620    fn deserialize_known_tokens_roundtrip() {
621        // Known tokens deserialise successfully.
622        let json = r#"["read-balance","propose-transaction","suggest-destination","observe-event","sign-payment"]"#;
623        let set: CapabilitySet = serde_json::from_str(json).unwrap();
624        assert!(set.contains(Capability::ReadBalance));
625        assert!(set.contains(Capability::ProposeTransaction));
626        assert!(set.contains(Capability::SuggestDestination));
627        assert!(set.contains(Capability::ObserveEvent));
628        assert!(set.contains(Capability::SignPayment));
629        assert_eq!(set.len(), 5);
630    }
631
632    #[test]
633    fn deserialize_sign_payment_is_ok() {
634        // sign-payment is a valid capability token and must deserialise to SignPayment.
635        let json = r#"["sign-payment"]"#;
636        let set: CapabilitySet = serde_json::from_str(json).unwrap();
637        assert!(
638            set.contains(Capability::SignPayment),
639            "sign-payment must deserialise to SignPayment variant"
640        );
641    }
642
643    #[test]
644    fn deserialize_sign_rule_create_is_ok() {
645        let json = r#"["sign-rule-create"]"#;
646        let set: CapabilitySet = serde_json::from_str(json).unwrap();
647        assert!(
648            set.contains(Capability::SignRuleCreate),
649            "sign-rule-create must deserialise to SignRuleCreate variant"
650        );
651    }
652
653    #[test]
654    fn deserialize_unknown_token_silently_skipped() {
655        // Unknown tokens (future capabilities) are silently skipped so old
656        // binaries can load records written by new binaries.
657        let json = r#"["read-balance","future-unknown-cap"]"#;
658        let set: CapabilitySet = serde_json::from_str(json).unwrap();
659        assert!(set.contains(Capability::ReadBalance));
660        assert_eq!(set.len(), 1);
661    }
662
663    #[test]
664    fn deserialize_invalid_charset_token_is_serde_error() {
665        // A token with invalid chars in a stored record is a serde error.
666        let json = r#"["read-balance","UPPERCASE"]"#;
667        let result: Result<CapabilitySet, _> = serde_json::from_str(json);
668        assert!(
669            result.is_err(),
670            "deserialising a token with invalid charset must be a serde error"
671        );
672    }
673
674    #[test]
675    fn serialize_deserialize_roundtrip() {
676        let set = parse_capability_value("read-balance propose-transaction").unwrap();
677        let json = serde_json::to_string(&set).unwrap();
678        let restored: CapabilitySet = serde_json::from_str(&json).unwrap();
679        assert_eq!(set, restored);
680    }
681
682    // ── CapabilitySet API ─────────────────────────────────────────────────────
683
684    #[test]
685    fn capability_set_iter_is_sorted() {
686        let set = parse_capability_value("observe-event read-balance propose-transaction").unwrap();
687        let v: Vec<Capability> = set.iter().collect();
688        // BTreeSet order = the Ord-derived order on Capability variants
689        // (ReadBalance < ProposeTransaction < SuggestDestination < ObserveEvent < SignPayment)
690        assert_eq!(v[0], Capability::ReadBalance);
691        assert_eq!(v[1], Capability::ProposeTransaction);
692        assert_eq!(v[2], Capability::ObserveEvent);
693    }
694
695    #[test]
696    fn into_iterator_consumes_set_by_value() {
697        // Exercises the `IntoIterator for CapabilitySet` impl: consume the set
698        // by value with a `for` loop and assert all inserted capabilities are
699        // yielded exactly once.
700        let set =
701            parse_capability_value("read-balance propose-transaction suggest-destination").unwrap();
702        assert_eq!(set.len(), 3);
703
704        let mut seen = Vec::new();
705        for cap in set {
706            seen.push(cap);
707        }
708
709        // BTreeSet iteration is sorted; verify the yielded order.
710        assert_eq!(seen.len(), 3);
711        assert_eq!(seen[0], Capability::ReadBalance);
712        assert_eq!(seen[1], Capability::ProposeTransaction);
713        assert_eq!(seen[2], Capability::SuggestDestination);
714    }
715
716    #[test]
717    fn capability_display() {
718        assert_eq!(Capability::ReadBalance.to_string(), "read-balance");
719        assert_eq!(
720            Capability::ProposeTransaction.to_string(),
721            "propose-transaction"
722        );
723        assert_eq!(
724            Capability::SuggestDestination.to_string(),
725            "suggest-destination"
726        );
727        assert_eq!(Capability::ObserveEvent.to_string(), "observe-event");
728        assert_eq!(Capability::SignPayment.to_string(), "sign-payment");
729        assert_eq!(Capability::ReadRules.to_string(), "read-rules");
730        assert_eq!(Capability::SignRuleCreate.to_string(), "sign-rule-create");
731    }
732
733    // ── is_key_touching ───────────────────────────────────────────────────────
734    //
735    // Exhaustive table — every variant must be explicitly classified.
736    // Adding a variant without updating the match arm produces a compile error.
737
738    #[test]
739    fn sign_payment_is_key_touching() {
740        assert!(
741            Capability::SignPayment.is_key_touching(),
742            "SignPayment must be key-touching (accesses signing key)"
743        );
744    }
745
746    #[test]
747    fn sign_rule_create_is_key_touching() {
748        assert!(
749            Capability::SignRuleCreate.is_key_touching(),
750            "SignRuleCreate must be key-touching (installs a rule via the signing key)"
751        );
752    }
753
754    #[test]
755    fn non_signing_capabilities_are_not_key_touching() {
756        // Exhaustive check — every non-signing variant must return false.
757        // This list MUST be updated if a new variant is added.
758        let non_key_touching = [
759            Capability::ReadBalance,
760            Capability::ProposeTransaction,
761            Capability::SuggestDestination,
762            Capability::ObserveEvent,
763        ];
764        for cap in non_key_touching {
765            assert!(
766                !cap.is_key_touching(),
767                "{cap} must NOT be key-touching (does not access signing key)"
768            );
769        }
770    }
771
772    #[test]
773    fn is_key_touching_exhaustive_table() {
774        // Complete classification table.  When a new variant is added, the match
775        // arm in is_key_touching will fail to compile until classified, and this
776        // table should be updated accordingly.
777        let table: &[(Capability, bool)] = &[
778            (Capability::ReadBalance, false),
779            (Capability::ProposeTransaction, false),
780            (Capability::SuggestDestination, false),
781            (Capability::ObserveEvent, false),
782            (Capability::SignPayment, true),
783            (Capability::ReadRules, false),
784            (Capability::SignRuleCreate, true),
785        ];
786        for (cap, expected) in table {
787            assert_eq!(
788                cap.is_key_touching(),
789                *expected,
790                "is_key_touching({cap}) should be {expected}"
791            );
792        }
793    }
794}