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}