didbot_scope/scope.rs
1//! One parsed scope atom: what it is, what it admits, and how it prints.
2
3use std::collections::BTreeSet;
4use std::fmt;
5
6use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
7
8use crate::read::read;
9use crate::{
10 AccountAction, AccountAttr, Action, ActionSet, IdentityAttr, MimePattern, NsidPattern,
11 ScopeParseError, Transition,
12};
13
14/// One parsed scope atom.
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
16pub enum Scope {
17 /// The base `atproto` scope every grant carries.
18 Atproto,
19 /// A legacy `transition:*` scope.
20 Transition(Transition),
21 /// `repo:<collection>[?action=...]`.
22 Repo {
23 /// The collection this scope covers, exact or wildcard.
24 collection: NsidPattern,
25 /// Which write actions are covered.
26 actions: ActionSet,
27 },
28 /// `rpc:<lxm>[?aud=...]`.
29 Rpc {
30 /// The XRPC method this scope covers, exact or wildcard.
31 method: NsidPattern,
32 /// The audience this scope is bound to, percent-decoded: a DID, a
33 /// DID and a service fragment, or `*` for every audience.
34 aud: Option<String>,
35 },
36 /// `blob:<mime>` or `blob?accept=<mime>[&accept=<mime>…]`.
37 Blob {
38 /// The MIME types this scope covers, exact or wildcard. Sorted,
39 /// deduplicated, and never empty — see [`Scope::blob`].
40 accept: Vec<MimePattern>,
41 },
42 /// `identity:<attr>`. This server never grants one; see
43 /// `plan/scope-policy.md`.
44 Identity {
45 /// The part of the account's identity this scope names.
46 attr: IdentityAttr,
47 },
48 /// `account:<attr>[?action=...]`. Never granted either.
49 Account {
50 /// The part of the account's hosting this scope names.
51 attr: AccountAttr,
52 /// How much control over it this scope grants.
53 action: AccountAction,
54 },
55 /// `include:<nsid>[?aud=...]` — a permission set. As an atom it admits
56 /// only itself; [`crate::Include`] reads what it grants.
57 Include {
58 /// The set's name, percent-decoded. [`crate::Include::new`] says
59 /// whether it names a set at all.
60 nsid: String,
61 /// The audience the set's `inheritAud` permissions take,
62 /// percent-decoded.
63 aud: Option<String>,
64 },
65 /// An atom this grammar cannot read as any scope above: an unknown kind,
66 /// such as `space:*`, or a value its kind does not define, such as
67 /// `account:status`. Kept as written, it admits only itself.
68 Unknown(String),
69}
70
71impl Scope {
72 /// Parses an atom that names one scope, such as
73 /// `repo:app.bsky.feed.post?action=create`, whatever its form's warnings.
74 ///
75 /// An atom that lists several collections or methods names a scope for
76 /// each, and [`ScopeSet::parse`](crate::ScopeSet::parse) is what reads
77 /// it. So does an atom this grammar cannot read, which
78 /// [`ScopeSet::parse`](crate::ScopeSet::parse) keeps as
79 /// [`Scope::Unknown`] and this refuses.
80 pub fn parse(atom: &str) -> Result<Self, ScopeParseError> {
81 let mut scopes = read(atom)?.scopes;
82 match scopes.len() {
83 1 => Ok(scopes.remove(0)),
84 _ => Err(ScopeParseError::SeveralScopes(atom.to_owned())),
85 }
86 }
87
88 /// Does `self` admit every request that `other` admits?
89 ///
90 /// Reflexive and used both directions by [`ScopeSet::intersect`](crate::ScopeSet::intersect): a
91 /// ceiling contains a requested scope, or a requested scope is itself a
92 /// (possibly equal) subset of the ceiling's scope.
93 pub fn contains(&self, other: &Scope) -> bool {
94 if self == other {
95 return true;
96 }
97 match (self, other) {
98 (Scope::Transition(t), _) => t.covers(other),
99 (
100 Scope::Repo {
101 collection: c1,
102 actions: a1,
103 },
104 Scope::Repo {
105 collection: c2,
106 actions: a2,
107 },
108 ) => c1.contains(c2) && a1.contains(a2),
109 (
110 Scope::Rpc {
111 method: m1,
112 aud: aud1,
113 },
114 Scope::Rpc {
115 method: m2,
116 aud: aud2,
117 },
118 ) => m1.contains(m2) && aud_contains(aud1, aud2),
119 (Scope::Blob { accept: a1 }, Scope::Blob { accept: a2 }) => a2
120 .iter()
121 .all(|wanted| a1.iter().any(|held| held.contains(wanted))),
122 (Scope::Identity { attr: a1 }, Scope::Identity { attr: a2 }) => a1.includes(*a2),
123 (
124 Scope::Account {
125 attr: a1,
126 action: x1,
127 },
128 Scope::Account {
129 attr: a2,
130 action: x2,
131 },
132 ) => a1 == a2 && x1.includes(*x2),
133 _ => false,
134 }
135 }
136
137 /// The most specific scope both `self` and `other` admit in common, if
138 /// their admitted requests overlap at all.
139 ///
140 /// `None` is a real refusal, not an error: it means the two scopes name
141 /// disjoint capabilities (a `repo:` ceiling for one collection against a
142 /// request for a different one, say), and the caller — `scope-policy`'s
143 /// ceiling check — is expected to report that by name.
144 pub fn intersect(&self, other: &Scope) -> Option<Scope> {
145 if self.contains(other) {
146 return Some(other.clone());
147 }
148 if other.contains(self) {
149 return Some(self.clone());
150 }
151 match (self, other) {
152 (
153 Scope::Repo {
154 collection: c1,
155 actions: a1,
156 },
157 Scope::Repo {
158 collection: c2,
159 actions: a2,
160 },
161 ) => Some(Scope::Repo {
162 collection: c1.intersect(c2)?,
163 actions: a1.intersect(a2)?,
164 }),
165 (
166 Scope::Rpc {
167 method: m1,
168 aud: aud1,
169 },
170 Scope::Rpc {
171 method: m2,
172 aud: aud2,
173 },
174 ) => {
175 let aud = intersect_aud(aud1, aud2)?;
176 Some(Scope::Rpc {
177 method: m1.intersect(m2)?,
178 aud,
179 })
180 }
181 (Scope::Blob { accept: a1 }, Scope::Blob { accept: a2 }) => {
182 let overlap: Vec<MimePattern> = a1
183 .iter()
184 .flat_map(|held| a2.iter().filter_map(|wanted| held.intersect(wanted)))
185 .collect();
186 if overlap.is_empty() {
187 return None;
188 }
189 Some(Scope::blob(overlap))
190 }
191 _ => None,
192 }
193 }
194
195 /// The `repo:` scope one record write needs: `action` on exactly
196 /// `collection`.
197 pub fn repo_write(collection: &str, action: Action) -> Scope {
198 Scope::Repo {
199 collection: NsidPattern::Exact(collection.to_owned()),
200 actions: ActionSet::Only(BTreeSet::from([action])),
201 }
202 }
203
204 /// A `blob:` scope over `accept`, canonical: sorted, deduplicated, and
205 /// with any pattern another one already covers dropped, so
206 /// `blob?accept=image/*&accept=image/png` is `blob:image/*`.
207 ///
208 /// # Panics
209 ///
210 /// If `accept` is empty. Every caller builds it from something.
211 #[must_use]
212 pub fn blob(mut accept: Vec<MimePattern>) -> Scope {
213 assert!(!accept.is_empty(), "a blob scope accepts something");
214 accept.sort();
215 accept.dedup();
216 let covered = |mime: &MimePattern| {
217 accept
218 .iter()
219 .any(|other| other != mime && other.contains(mime))
220 };
221 let keep: Vec<MimePattern> = accept.iter().filter(|m| !covered(m)).cloned().collect();
222 Scope::Blob { accept: keep }
223 }
224
225 /// The `blob:` scope one upload needs: the type it declared.
226 ///
227 /// The type is taken up to any `;` — `image/png; charset=binary` is an
228 /// upload of `image/png` — and one that is not `type/subtype` at all is
229 /// taken as `application/octet-stream`, which is what an unreadable
230 /// content type means anyway.
231 #[must_use]
232 pub fn blob_upload(mime_type: &str) -> Scope {
233 let named = mime_type.split(';').next().unwrap_or_default().trim();
234 let mime = MimePattern::parse(named)
235 .unwrap_or_else(|_| MimePattern::parse("application/octet-stream").expect("a literal"));
236 Scope::blob(vec![mime])
237 }
238
239 /// The variant this atom is, for [`ScopeSet::narrow`](crate::ScopeSet::narrow)'s same-kind rule.
240 ///
241 /// Not `PartialEq` on the whole scope and not a public type: the only
242 /// question asked of it is whether two atoms are the same *kind* of
243 /// capability, and giving that a name is cheaper than matching on eight
244 /// variants at the one call site.
245 pub(crate) fn kind(&self) -> u8 {
246 match self {
247 Scope::Atproto => 0,
248 Scope::Transition(_) => 1,
249 Scope::Repo { .. } => 2,
250 Scope::Rpc { .. } => 3,
251 Scope::Blob { .. } => 4,
252 Scope::Identity { .. } => 5,
253 Scope::Account { .. } => 6,
254 Scope::Include { .. } => 7,
255 Scope::Unknown(_) => 8,
256 }
257 }
258}
259
260/// The `aud` that names every audience, as the permission spec writes it.
261pub(crate) const ANY_AUD: &str = "*";
262
263/// What a printed positional part percent-encodes: what a scope token may
264/// not hold, `?`, which would begin the parameters, and `%` itself, so a
265/// value's own escapes survive.
266const POSITIONAL: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'%').add(b'?').add(b'\\');
267
268/// What a printed parameter value percent-encodes: the same, and the
269/// characters the parameter syntax reserves. `+` is among them because a
270/// form decoder reads it as a space.
271const VALUE: &AsciiSet = &POSITIONAL.add(b'#').add(b'&').add(b'+');
272
273fn aud_contains(a: &Option<String>, b: &Option<String>) -> bool {
274 match (a, b) {
275 (None, _) => true,
276 (Some(_), None) => false,
277 (Some(a), Some(b)) => a == ANY_AUD || a == b,
278 }
279}
280
281fn intersect_aud(a: &Option<String>, b: &Option<String>) -> Option<Option<String>> {
282 match (a, b) {
283 (None, x) | (x, None) => Some(x.clone()),
284 (Some(a), Some(b)) if a == b => Some(Some(a.clone())),
285 (Some(any), Some(x)) | (Some(x), Some(any)) if any == ANY_AUD => Some(Some(x.clone())),
286 _ => None,
287 }
288}
289
290impl fmt::Display for Scope {
291 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292 match self {
293 Scope::Atproto => write!(f, "atproto"),
294 Scope::Transition(t) => write!(f, "{t}"),
295 Scope::Repo {
296 collection,
297 actions,
298 } => write!(f, "repo:{collection}{actions}"),
299 Scope::Rpc { method, aud } => {
300 write!(f, "rpc:{method}")?;
301 if let Some(aud) = aud {
302 write!(f, "?aud={}", utf8_percent_encode(aud, VALUE))?;
303 }
304 Ok(())
305 }
306 Scope::Blob { accept } => match accept.as_slice() {
307 [only] => write!(
308 f,
309 "blob:{}",
310 utf8_percent_encode(&only.to_string(), POSITIONAL)
311 ),
312 many => {
313 write!(f, "blob")?;
314 let mut sep = '?';
315 for mime in many {
316 let mime = mime.to_string();
317 write!(f, "{sep}accept={}", utf8_percent_encode(&mime, VALUE))?;
318 sep = '&';
319 }
320 Ok(())
321 }
322 },
323 Scope::Identity { attr } => write!(f, "identity:{attr}"),
324 Scope::Account { attr, action } => {
325 write!(f, "account:{attr}")?;
326 if *action == AccountAction::Manage {
327 write!(f, "?action=manage")?;
328 }
329 Ok(())
330 }
331 Scope::Include { nsid, aud } => {
332 write!(f, "include:{}", utf8_percent_encode(nsid, POSITIONAL))?;
333 if let Some(aud) = aud {
334 write!(f, "?aud={}", utf8_percent_encode(aud, VALUE))?;
335 }
336 Ok(())
337 }
338 Scope::Unknown(atom) => f.write_str(atom),
339 }
340 }
341}