oauth_as/scope.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (C) 2026 Matthew Jackson
3
4//! Access-token scope, mirrored from RFC 6749 section 3.3: a scope is a space-delimited set of
5//! case-sensitive tokens, each drawn from `%x21 / %x23-5B / %x5D-7E` (printable ASCII minus space,
6//! double quote, and backslash).
7
8use std::fmt;
9
10use serde::de::Error as _;
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12
13/// One scope token, charset-validated at construction.
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub struct Scope(String);
16
17/// The rejection for a malformed scope token (empty, or a byte outside the RFC 6749 section 3.3
18/// charset).
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct InvalidScopeToken(pub String);
21
22impl fmt::Display for InvalidScopeToken {
23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24 write!(f, "invalid scope token {:?}", self.0)
25 }
26}
27
28impl std::error::Error for InvalidScopeToken {}
29
30fn scope_char_ok(b: u8) -> bool {
31 b == 0x21 || (0x23..=0x5B).contains(&b) || (0x5D..=0x7E).contains(&b)
32}
33
34impl Scope {
35 /// Validate and wrap one scope token.
36 pub fn new(token: impl Into<String>) -> Result<Self, InvalidScopeToken> {
37 let token = token.into();
38 if token.is_empty() || !token.bytes().all(scope_char_ok) {
39 return Err(InvalidScopeToken(token));
40 }
41 Ok(Scope(token))
42 }
43
44 /// The token text.
45 pub fn as_str(&self) -> &str {
46 &self.0
47 }
48}
49
50impl fmt::Display for Scope {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 f.write_str(&self.0)
53 }
54}
55
56/// An ordered, deduplicated set of scope tokens. The wire form (both directions) is the RFC's
57/// space-delimited string; ordering here is lexicographic so serialization is deterministic.
58///
59/// # Why a sorted `Vec` and not a `BTreeSet`
60///
61/// This was a `BTreeSet<Scope>` through 0.9.1, and the INVARIANT is unchanged: the tokens are held
62/// sorted and deduplicated, so `Display`, `Serialize`, `PartialEq` and `is_subset` all answer
63/// exactly what the tree answered. What changed is the code the invariant costs.
64///
65/// The set this type actually holds is tiny. A scope set is the `scope` parameter of one request or
66/// the `allowed_scopes` of one client: single digits of tokens, each a handful of bytes. A B-tree
67/// is machinery for a set that is large enough for the log factor to pay for the node bookkeeping,
68/// and at these sizes it never does — it allocates a whole node to hold one token, and every
69/// operation on it links a distinct instantiation of `BTreeMap`'s insert, clone and comparison
70/// paths into the binary.
71///
72/// MEASURED 2026-08-13 by `scripts/size-report.sh` on the `default` row (aarch64-apple-darwin,
73/// rustc 1.97.0): 15,394 bytes, taking the row from 234,623 to 219,229. That is not the tree's
74/// insert alone; it is every `BTreeMap` instantiation this type forced into a linked binary —
75/// insert with its node splitting, `clone_subtree`, `PartialEq::eq`, `is_subset`'s range descent,
76/// the iterators and the drop glue — none of which a host that only ever holds five scope tokens
77/// was getting anything for. It is the same trade, for the same reason, as the one `crate::store`'s
78/// barrier list records: an ordered container whose reads were a linear scan anyway does not need a
79/// tree to be one.
80///
81/// It is also a much smaller heap footprint per stored record, which is the part that scales with
82/// a deployment rather than with the binary. A `BTreeSet` allocates a whole leaf node the moment it
83/// holds anything, and that node is the same size for one token as for eleven. MEASURED with
84/// `tests/support/alloc.rs`, `ScopeSet::parse`:
85///
86/// | tokens | before | after |
87/// |--------|-----------------|---------------|
88/// | 1 | 2 allocs, 284 B | 2 allocs, 28 B |
89/// | 3 | 4 allocs, 294 B | 4 allocs, 86 B |
90///
91/// Same allocation COUNT — the vector is sized once from the token count, so it does not trade
92/// bytes for calls — and 90% fewer bytes at the size a real `scope` parameter actually is. Every
93/// `Client`, `IssuedToken`, `AuthorizationCodeRecord`, `RefreshTokenRecord`, `DeviceGrant` and
94/// consent record in a store was carrying one of those 256-byte nodes to hold a word or two.
95#[derive(Debug, Clone, PartialEq, Eq, Default)]
96pub struct ScopeSet(Vec<Scope>);
97
98impl ScopeSet {
99 /// The empty set (serializes to the empty string; hosts normally omit the parameter instead).
100 pub fn empty() -> Self {
101 ScopeSet(Vec::new())
102 }
103
104 /// Establish the type's invariant on a freshly built vector: sorted lexicographically, with
105 /// duplicates removed. Every constructor ends here, so there is one place the invariant is
106 /// made true and one place to read to check that it is.
107 fn sorted(mut tokens: Vec<Scope>) -> Self {
108 tokens.sort_unstable();
109 tokens.dedup();
110 ScopeSet(tokens)
111 }
112
113 /// Parse a space-delimited scope string. Repeated whitespace is tolerated; each token is
114 /// charset-validated.
115 ///
116 /// # There is NO cap on the token count, and that is a decision with a cost
117 ///
118 /// MEASURED by `benches/scaling.rs`, both implementations on the same machine in the same
119 /// session, 2026-08-13:
120 ///
121 /// | tokens | `BTreeSet` (through 0.9.1) | sorted `Vec` |
122 /// |--------|---------------------------|--------------|
123 /// | 1 | 32.0 ns | 39.0 ns |
124 /// | 10 | 330 ns | 340 ns |
125 /// | 100 | 6.05 us | 4.41 us |
126 /// | 1000 | 81.09 us | 48.68 us |
127 ///
128 /// The seven nanoseconds at one token are the pre-pass that COUNTS the tokens, and they are what
129 /// buys the single correctly-sized allocation; from a hundred tokens up the sort is the faster
130 /// structure by a wide margin. The growth is n log n either way, so this is not the accidental
131 /// quadratic that [`crate::server::MAX_RESOURCE_INDICATORS`] exists to bound; it is a
132 /// straightforward "how big may the parameter be" question, and reaching the top of that range
133 /// takes roughly ten kilobytes of `scope`, which a host's own request-size limit is the right
134 /// place to refuse.
135 ///
136 /// A cap here was considered and NOT taken, because it cannot be expressed without a breaking
137 /// change that is out of proportion to the problem: [`InvalidScopeToken`] is a tuple struct
138 /// with a public field, so it cannot gain a "too many" variant, and this same function is the
139 /// [`serde::Deserialize`] implementation for every persisted record that carries a scope, as
140 /// well as the constructor a host uses for its own `allowed_scopes`. A limit applied here would
141 /// therefore be a limit on what a deployment may REGISTER and on what it can read back out of
142 /// its own store, which is a different and much larger decision than bounding a request.
143 ///
144 /// If a bound is wanted, the place for it is the wire boundary, alongside the other request
145 /// caps, and it needs an error type this one cannot currently express.
146 pub fn parse(s: &str) -> Result<Self, InvalidScopeToken> {
147 // Counted first so the vector is allocated ONCE at the right size. The count is a scan of a
148 // string already in cache and is what keeps a parse at one allocation plus one `String` per
149 // token, which `tests/allocation.rs` pins.
150 let count = s.split(' ').filter(|t| !t.is_empty()).count();
151 let mut tokens = Vec::with_capacity(count);
152 for tok in s.split(' ').filter(|t| !t.is_empty()) {
153 tokens.push(Scope::new(tok)?);
154 }
155 Ok(ScopeSet::sorted(tokens))
156 }
157
158 /// Build from tokens, validating each.
159 pub fn from_tokens<I, T>(tokens: I) -> Result<Self, InvalidScopeToken>
160 where
161 I: IntoIterator<Item = T>,
162 T: Into<String>,
163 {
164 let iter = tokens.into_iter();
165 let mut out = Vec::with_capacity(iter.size_hint().0);
166 for t in iter {
167 out.push(Scope::new(t)?);
168 }
169 Ok(ScopeSet::sorted(out))
170 }
171
172 /// True when every token in `self` is also in `other`.
173 ///
174 /// A merge walk over two sorted, deduplicated slices: linear in the two lengths, with no
175 /// allocation and no tree descent. Same answer the `BTreeSet` gave.
176 pub fn is_subset(&self, other: &ScopeSet) -> bool {
177 let mut theirs = other.0.iter();
178 'mine: for mine in &self.0 {
179 for other in theirs.by_ref() {
180 match other.cmp(mine) {
181 std::cmp::Ordering::Less => continue,
182 std::cmp::Ordering::Equal => continue 'mine,
183 // `other` has passed `mine` in sort order, so `mine` is not in `other`.
184 std::cmp::Ordering::Greater => return false,
185 }
186 }
187 return false;
188 }
189 true
190 }
191
192 /// True when the set holds no tokens.
193 pub fn is_empty(&self) -> bool {
194 self.0.is_empty()
195 }
196
197 /// Number of tokens.
198 pub fn len(&self) -> usize {
199 self.0.len()
200 }
201
202 /// Membership test.
203 pub fn contains(&self, token: &str) -> bool {
204 self.0.iter().any(|s| s.as_str() == token)
205 }
206
207 /// Iterate tokens in lexicographic order.
208 pub fn iter(&self) -> impl Iterator<Item = &Scope> {
209 self.0.iter()
210 }
211}
212
213impl fmt::Display for ScopeSet {
214 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215 let mut first = true;
216 for s in &self.0 {
217 if !first {
218 f.write_str(" ")?;
219 }
220 first = false;
221 f.write_str(s.as_str())?;
222 }
223 Ok(())
224 }
225}
226
227impl Serialize for ScopeSet {
228 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
229 serializer.serialize_str(&self.to_string())
230 }
231}
232
233impl<'de> Deserialize<'de> for ScopeSet {
234 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
235 let s = String::deserialize(deserializer)?;
236 ScopeSet::parse(&s).map_err(D::Error::custom)
237 }
238}
239
240#[cfg(test)]
241#[path = "tests/scope.rs"]
242mod tests;