Skip to main content

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}