agent_capability_token/
scope.rs1use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11use crate::error::{ActError, ActResult};
12
13pub const WILDCARD: &str = "*";
15
16pub const SCOPE_SEGMENTS: usize = 3;
18
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub struct Scope {
41 raw: String,
42 first_colon: usize,
44 second_colon: usize,
45}
46
47impl Scope {
48 pub fn parse(value: impl Into<String>) -> ActResult<Self> {
55 let raw = value.into();
56
57 let mut colons = raw.match_indices(':').map(|(i, _)| i);
58 let (Some(first_colon), Some(second_colon)) = (colons.next(), colons.next()) else {
59 return Err(ActError::MalformedScope {
60 value: raw,
61 reason: "expected three segments in service:resource:action form",
62 });
63 };
64 if colons.next().is_some() {
65 return Err(ActError::MalformedScope {
66 value: raw,
67 reason: "expected exactly three segments, found more",
68 });
69 }
70
71 let candidate = Self {
72 first_colon,
73 second_colon,
74 raw,
75 };
76 if candidate.service().is_empty()
77 || candidate.resource().is_empty()
78 || candidate.action().is_empty()
79 {
80 return Err(ActError::MalformedScope {
81 value: candidate.raw,
82 reason: "no segment may be empty",
83 });
84 }
85
86 Ok(candidate)
87 }
88
89 #[must_use]
91 pub fn service(&self) -> &str {
92 &self.raw[..self.first_colon]
93 }
94
95 #[must_use]
97 pub fn resource(&self) -> &str {
98 &self.raw[self.first_colon + 1..self.second_colon]
99 }
100
101 #[must_use]
103 pub fn action(&self) -> &str {
104 &self.raw[self.second_colon + 1..]
105 }
106
107 #[must_use]
109 pub fn as_str(&self) -> &str {
110 &self.raw
111 }
112
113 #[must_use]
120 pub fn covers(&self, requested: &Self) -> bool {
121 fn segment_covers(granted: &str, requested: &str) -> bool {
122 granted == WILDCARD || granted == requested
123 }
124
125 segment_covers(self.service(), requested.service())
126 && segment_covers(self.resource(), requested.resource())
127 && segment_covers(self.action(), requested.action())
128 }
129}
130
131impl std::str::FromStr for Scope {
132 type Err = ActError;
133
134 fn from_str(s: &str) -> Result<Self, Self::Err> {
135 Self::parse(s)
136 }
137}
138
139impl std::fmt::Display for Scope {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 f.write_str(&self.raw)
142 }
143}
144
145impl Serialize for Scope {
146 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
147 serializer.serialize_str(&self.raw)
148 }
149}
150
151impl<'de> Deserialize<'de> for Scope {
152 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
153 let raw = String::deserialize(deserializer)?;
154 Self::parse(raw).map_err(serde::de::Error::custom)
155 }
156}
157
158#[cfg(test)]
159#[allow(clippy::expect_used)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn parses_three_segments() -> ActResult<()> {
165 let scope = Scope::parse("tools:calendar:invoke")?;
166 assert_eq!(scope.service(), "tools");
167 assert_eq!(scope.resource(), "calendar");
168 assert_eq!(scope.action(), "invoke");
169 assert_eq!(scope.as_str(), "tools:calendar:invoke");
170 Ok(())
171 }
172
173 #[test]
174 fn rejects_two_segment_scopes() {
175 let error = Scope::parse("tools:invoke").expect_err("two segments must be rejected");
177 assert!(matches!(error, ActError::MalformedScope { .. }));
178 }
179
180 #[test]
181 fn rejects_four_segments_and_empty_segments() {
182 assert!(Scope::parse("a:b:c:d").is_err());
183 assert!(Scope::parse("a::c").is_err());
184 assert!(Scope::parse(":b:c").is_err());
185 assert!(Scope::parse("a:b:").is_err());
186 }
187
188 #[test]
189 fn wildcard_grant_covers_specific_request() -> ActResult<()> {
190 let granted = Scope::parse("runtime:platform:*")?;
191 assert!(granted.covers(&Scope::parse("runtime:platform:spawn")?));
192 assert!(!granted.covers(&Scope::parse("runtime:network:spawn")?));
193 Ok(())
194 }
195
196 #[test]
197 fn requested_wildcard_does_not_widen_authority() -> ActResult<()> {
198 let granted = Scope::parse("tools:calendar:invoke")?;
201 assert!(!granted.covers(&Scope::parse("tools:calendar:*")?));
202 assert!(!granted.covers(&Scope::parse("tools:*:invoke")?));
203 Ok(())
204 }
205
206 #[test]
207 fn serializes_as_a_plain_string() -> ActResult<()> {
208 let scope = Scope::parse("tools:calendar:invoke")?;
209 let mut buf = Vec::new();
210 ciborium::into_writer(&scope, &mut buf).map_err(|e| ActError::Encode(e.to_string()))?;
211
212 let value: ciborium::Value = ciborium::from_reader(buf.as_slice())
213 .map_err(|e| ActError::ClaimsDecode(e.to_string()))?;
214 assert!(value.is_text(), "scope must encode as text, got {value:?}");
215 Ok(())
216 }
217}