1//! AT Protocol OAuth scopes module
2//! Derived from https://tangled.org/@smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs
3//!
4//! This module provides comprehensive support for AT Protocol OAuth scopes,
5//! including parsing, serialization, normalization, and permission checking.
6//!
7//! Scopes in AT Protocol follow a prefix-based format with optional query parameters:
8//! - `account`: Access to account information (email, repo, status)
9//! - `identity`: Access to identity information (handle)
10//! - `blob`: Access to blob operations with mime type constraints
11//! - `repo`: Repository operations with collection and action constraints
12//! - `rpc`: RPC method access with lexicon and audience constraints
13//! - `atproto`: Required scope to indicate that other AT Protocol scopes will be used
14//! - `transition`: Migration operations (generic or email)
15//!
16//! Standard OpenID Connect scopes (no suffixes or query parameters):
17//! - `openid`: Required for OpenID Connect authentication
18//! - `profile`: Access to user profile information
19//! - `email`: Access to user email address
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::fmt;
23use std::str::FromStr;
24
25use jacquard_common::types::did::Did;
26use jacquard_common::types::nsid::Nsid;
27use jacquard_common::types::string::AtStrError;
28use jacquard_common::{CowStr, IntoStatic};
29use serde::de::Visitor;
30use serde::{Deserialize, Serialize};
31use smol_str::{SmolStr, ToSmolStr};
32
33/// Represents an AT Protocol OAuth scope
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub enum Scope<'s> {
36 /// Account scope for accessing account information
37 Account(AccountScope),
38 /// Identity scope for accessing identity information
39 Identity(IdentityScope),
40 /// Blob scope for blob operations with mime type constraints
41 Blob(BlobScope<'s>),
42 /// Repository scope for collection operations
43 Repo(RepoScope<'s>),
44 /// RPC scope for method access
45 Rpc(RpcScope<'s>),
46 /// AT Protocol scope - required to indicate that other AT Protocol scopes will be used
47 Atproto,
48 /// Transition scope for migration operations
49 Transition(TransitionScope),
50 /// OpenID Connect scope - required for OpenID Connect authentication
51 OpenId,
52 /// Profile scope - access to user profile information
53 Profile,
54 /// Email scope - access to user email address
55 Email,
56}
57
58impl Serialize for Scope<'_> {
59 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
60 where
61 S: serde::Serializer,
62 {
63 serializer.serialize_str(&self.to_string_normalized())
64 }
65}
66
67impl<'de> Deserialize<'de> for Scope<'_> {
68 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69 where
70 D: serde::Deserializer<'de>,
71 {
72 struct ScopeVisitor;
73
74 impl Visitor<'_> for ScopeVisitor {
75 type Value = Scope<'static>;
76
77 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
78 write!(formatter, "a scope string")
79 }
80 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
81 where
82 E: serde::de::Error,
83 {
84 Scope::parse(v)
85 .map(|s| s.into_static())
86 .map_err(|e| serde::de::Error::custom(format!("{:?}", e)))
87 }
88 }
89 deserializer.deserialize_str(ScopeVisitor)
90 }
91}
92
93impl IntoStatic for Scope<'_> {
94 type Output = Scope<'static>;
95
96 fn into_static(self) -> Self::Output {
97 match self {
98 Scope::Account(scope) => Scope::Account(scope),
99 Scope::Identity(scope) => Scope::Identity(scope),
100 Scope::Blob(scope) => Scope::Blob(scope.into_static()),
101 Scope::Repo(scope) => Scope::Repo(scope.into_static()),
102 Scope::Rpc(scope) => Scope::Rpc(scope.into_static()),
103 Scope::Atproto => Scope::Atproto,
104 Scope::Transition(scope) => Scope::Transition(scope),
105 Scope::OpenId => Scope::OpenId,
106 Scope::Profile => Scope::Profile,
107 Scope::Email => Scope::Email,
108 }
109 }
110}
111
112/// Account scope attributes
113#[derive(Debug, Clone, PartialEq, Eq, Hash)]
114pub struct AccountScope {
115 /// The account resource type
116 pub resource: AccountResource,
117 /// The action permission level
118 pub action: AccountAction,
119}
120
121/// Account resource types
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123pub enum AccountResource {
124 /// Email access
125 Email,
126 /// Repository access
127 Repo,
128 /// Status access
129 Status,
130}
131
132/// Account action permissions
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
134pub enum AccountAction {
135 /// Read-only access
136 Read,
137 /// Management access (includes read)
138 Manage,
139}
140
141/// Identity scope attributes
142#[derive(Debug, Clone, PartialEq, Eq, Hash)]
143pub enum IdentityScope {
144 /// Handle access
145 Handle,
146 /// All identity access (wildcard)
147 All,
148}
149
150/// Transition scope types
151#[derive(Debug, Clone, PartialEq, Eq, Hash)]
152pub enum TransitionScope {
153 /// Generic transition operations
154 Generic,
155 /// Email transition operations
156 Email,
157}
158
159/// Blob scope with mime type constraints
160#[derive(Debug, Clone, PartialEq, Eq, Hash)]
161pub struct BlobScope<'s> {
162 /// Accepted mime types
163 pub accept: BTreeSet<MimePattern<'s>>,
164}
165
166impl IntoStatic for BlobScope<'_> {
167 type Output = BlobScope<'static>;
168
169 fn into_static(self) -> Self::Output {
170 BlobScope {
171 accept: self.accept.into_iter().map(|p| p.into_static()).collect(),
172 }
173 }
174}
175
176/// MIME type pattern for blob scope
177#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
178pub enum MimePattern<'s> {
179 /// Match all types
180 All,
181 /// Match all subtypes of a type (e.g., "image/*")
182 TypeWildcard(CowStr<'s>),
183 /// Exact mime type match
184 Exact(CowStr<'s>),
185}
186
187impl IntoStatic for MimePattern<'_> {
188 type Output = MimePattern<'static>;
189
190 fn into_static(self) -> Self::Output {
191 match self {
192 MimePattern::All => MimePattern::All,
193 MimePattern::TypeWildcard(s) => MimePattern::TypeWildcard(s.into_static()),
194 MimePattern::Exact(s) => MimePattern::Exact(s.into_static()),
195 }
196 }
197}
198
199/// Repository scope with collection and action constraints
200#[derive(Debug, Clone, PartialEq, Eq, Hash)]
201pub struct RepoScope<'s> {
202 /// Collection NSID or wildcard
203 pub collection: RepoCollection<'s>,
204 /// Allowed actions
205 pub actions: BTreeSet<RepoAction>,
206}
207
208impl IntoStatic for RepoScope<'_> {
209 type Output = RepoScope<'static>;
210
211 fn into_static(self) -> Self::Output {
212 RepoScope {
213 collection: self.collection.into_static(),
214 actions: self.actions,
215 }
216 }
217}
218
219/// Repository collection identifier
220#[derive(Debug, Clone, PartialEq, Eq, Hash)]
221pub enum RepoCollection<'s> {
222 /// All collections (wildcard)
223 All,
224 /// Specific collection NSID
225 Nsid(Nsid<'s>),
226}
227
228impl IntoStatic for RepoCollection<'_> {
229 type Output = RepoCollection<'static>;
230
231 fn into_static(self) -> Self::Output {
232 match self {
233 RepoCollection::All => RepoCollection::All,
234 RepoCollection::Nsid(nsid) => RepoCollection::Nsid(nsid.into_static()),
235 }
236 }
237}
238
239/// Repository actions
240#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
241pub enum RepoAction {
242 /// Create records
243 Create,
244 /// Update records
245 Update,
246 /// Delete records
247 Delete,
248}
249
250/// RPC scope with lexicon method and audience constraints
251#[derive(Debug, Clone, PartialEq, Eq, Hash)]
252pub struct RpcScope<'s> {
253 /// Lexicon methods (NSIDs or wildcard)
254 pub lxm: BTreeSet<RpcLexicon<'s>>,
255 /// Audiences (DIDs or wildcard)
256 pub aud: BTreeSet<RpcAudience<'s>>,
257}
258
259impl IntoStatic for RpcScope<'_> {
260 type Output = RpcScope<'static>;
261
262 fn into_static(self) -> Self::Output {
263 RpcScope {
264 lxm: self.lxm.into_iter().map(|s| s.into_static()).collect(),
265 aud: self.aud.into_iter().map(|s| s.into_static()).collect(),
266 }
267 }
268}
269
270/// RPC lexicon identifier
271#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
272pub enum RpcLexicon<'s> {
273 /// All lexicons (wildcard)
274 All,
275 /// Specific lexicon NSID
276 Nsid(Nsid<'s>),
277}
278
279impl IntoStatic for RpcLexicon<'_> {
280 type Output = RpcLexicon<'static>;
281
282 fn into_static(self) -> Self::Output {
283 match self {
284 RpcLexicon::All => RpcLexicon::All,
285 RpcLexicon::Nsid(nsid) => RpcLexicon::Nsid(nsid.into_static()),
286 }
287 }
288}
289
290/// RPC audience identifier
291#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
292pub enum RpcAudience<'s> {
293 /// All audiences (wildcard)
294 All,
295 /// Specific DID
296 Did(Did<'s>),
297}
298
299impl IntoStatic for RpcAudience<'_> {
300 type Output = RpcAudience<'static>;
301
302 fn into_static(self) -> Self::Output {
303 match self {
304 RpcAudience::All => RpcAudience::All,
305 RpcAudience::Did(did) => RpcAudience::Did(did.into_static()),
306 }
307 }
308}
309
310impl<'s> Scope<'s> {
311 /// Parse multiple space-separated scopes from a string
312 ///
313 /// # Examples
314 /// ```
315 /// # use jacquard_oauth::scopes::Scope;
316 /// let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
317 /// assert_eq!(scopes.len(), 2);
318 /// ```
319 pub fn parse_multiple(s: &'s str) -> Result<Vec<Self>, ParseError> {
320 if s.trim().is_empty() {
321 return Ok(Vec::new());
322 }
323
324 let mut scopes = Vec::new();
325 for scope_str in s.split_whitespace() {
326 scopes.push(Self::parse(scope_str)?);
327 }
328
329 Ok(scopes)
330 }
331
332 /// Parse multiple space-separated scopes and return the minimal set needed
333 ///
334 /// This method removes duplicate scopes and scopes that are already granted
335 /// by other scopes in the list, returning only the minimal set of scopes needed.
336 ///
337 /// # Examples
338 /// ```
339 /// # use jacquard_oauth::scopes::Scope;
340 /// // repo:* grants repo:foo.bar, so only repo:* is kept
341 /// let scopes = Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap();
342 /// assert_eq!(scopes.len(), 2); // atproto and repo:*
343 /// ```
344 pub fn parse_multiple_reduced(s: &'s str) -> Result<Vec<Self>, ParseError> {
345 let all_scopes = Self::parse_multiple(s)?;
346
347 if all_scopes.is_empty() {
348 return Ok(Vec::new());
349 }
350
351 let mut result: Vec<Self> = Vec::new();
352
353 for scope in all_scopes {
354 // Check if this scope is already granted by something in the result
355 let mut is_granted = false;
356 for existing in &result {
357 if existing.grants(&scope) && existing != &scope {
358 is_granted = true;
359 break;
360 }
361 }
362
363 if is_granted {
364 continue; // Skip this scope, it's already covered
365 }
366
367 // Check if this scope grants any existing scopes in the result
368 let mut indices_to_remove = Vec::new();
369 for (i, existing) in result.iter().enumerate() {
370 if scope.grants(existing) && &scope != existing {
371 indices_to_remove.push(i);
372 }
373 }
374
375 // Remove scopes that are granted by the new scope (in reverse order to maintain indices)
376 for i in indices_to_remove.into_iter().rev() {
377 result.remove(i);
378 }
379
380 // Add the new scope if it's not a duplicate
381 if !result.contains(&scope) {
382 result.push(scope);
383 }
384 }
385
386 Ok(result)
387 }
388
389 /// Serialize a list of scopes into a space-separated OAuth scopes string
390 ///
391 /// The scopes are sorted alphabetically by their string representation to ensure
392 /// consistent output regardless of input order.
393 ///
394 /// # Examples
395 /// ```
396 /// # use jacquard_oauth::scopes::Scope;
397 /// let scopes = vec![
398 /// Scope::parse("repo:*").unwrap(),
399 /// Scope::parse("atproto").unwrap(),
400 /// Scope::parse("account:email").unwrap(),
401 /// ];
402 /// let result = Scope::serialize_multiple(&scopes);
403 /// assert_eq!(result, "account:email atproto repo:*");
404 /// ```
405 pub fn serialize_multiple(scopes: &[Self]) -> CowStr<'static> {
406 if scopes.is_empty() {
407 return CowStr::default();
408 }
409
410 let mut serialized: Vec<String> = scopes
411 .iter()
412 .map(|scope| scope.to_string_normalized())
413 .collect();
414
415 serialized.sort();
416 serialized.join(" ").into()
417 }
418
419 /// Remove a scope from a list of scopes
420 ///
421 /// Returns a new vector with all instances of the specified scope removed.
422 /// If the scope doesn't exist in the list, returns a copy of the original list.
423 ///
424 /// # Examples
425 /// ```
426 /// # use jacquard_oauth::scopes::Scope;
427 /// let scopes = vec![
428 /// Scope::parse("repo:*").unwrap(),
429 /// Scope::parse("atproto").unwrap(),
430 /// Scope::parse("account:email").unwrap(),
431 /// ];
432 /// let to_remove = Scope::parse("atproto").unwrap();
433 /// let result = Scope::remove_scope(&scopes, &to_remove);
434 /// assert_eq!(result.len(), 2);
435 /// assert!(!result.contains(&to_remove));
436 /// ```
437 pub fn remove_scope(scopes: &[Self], scope_to_remove: &Self) -> Vec<Self> {
438 scopes
439 .iter()
440 .filter(|s| *s != scope_to_remove)
441 .cloned()
442 .collect()
443 }
444
445 /// Parse a scope from a string
446 pub fn parse(s: &'s str) -> Result<Self, ParseError> {
447 // Determine the prefix first by checking for known prefixes
448 let prefixes = [
449 "account",
450 "identity",
451 "blob",
452 "repo",
453 "rpc",
454 "atproto",
455 "transition",
456 "openid",
457 "profile",
458 "email",
459 ];
460 let mut found_prefix = None;
461 let mut suffix = None;
462
463 for prefix in &prefixes {
464 if let Some(remainder) = s.strip_prefix(prefix)
465 && (remainder.is_empty()
466 || remainder.starts_with(':')
467 || remainder.starts_with('?'))
468 {
469 found_prefix = Some(*prefix);
470 if let Some(stripped) = remainder.strip_prefix(':') {
471 suffix = Some(stripped);
472 } else if remainder.starts_with('?') {
473 suffix = Some(remainder);
474 } else {
475 suffix = None;
476 }
477 break;
478 }
479 }
480
481 let prefix = found_prefix.ok_or_else(|| {
482 // If no known prefix found, extract what looks like a prefix for error reporting
483 let end = s.find(':').or_else(|| s.find('?')).unwrap_or(s.len());
484 ParseError::UnknownPrefix(s[..end].to_string())
485 })?;
486
487 match prefix {
488 "account" => Self::parse_account(suffix),
489 "identity" => Self::parse_identity(suffix),
490 "blob" => Self::parse_blob(suffix),
491 "repo" => Self::parse_repo(suffix),
492 "rpc" => Self::parse_rpc(suffix),
493 "atproto" => Self::parse_atproto(suffix),
494 "transition" => Self::parse_transition(suffix),
495 "openid" => Self::parse_openid(suffix),
496 "profile" => Self::parse_profile(suffix),
497 "email" => Self::parse_email(suffix),
498 _ => Err(ParseError::UnknownPrefix(prefix.to_string())),
499 }
500 }
501
502 fn parse_account(suffix: Option<&'s str>) -> Result<Self, ParseError> {
503 let (resource_str, params) = match suffix {
504 Some(s) => {
505 if let Some(pos) = s.find('?') {
506 (&s[..pos], Some(&s[pos + 1..]))
507 } else {
508 (s, None)
509 }
510 }
511 None => return Err(ParseError::MissingResource),
512 };
513
514 let resource = match resource_str {
515 "email" => AccountResource::Email,
516 "repo" => AccountResource::Repo,
517 "status" => AccountResource::Status,
518 _ => return Err(ParseError::InvalidResource(resource_str.to_string())),
519 };
520
521 let action = if let Some(params) = params {
522 let parsed_params = parse_query_string(params);
523 match parsed_params
524 .get("action")
525 .and_then(|v| v.first())
526 .map(|s| s.as_ref())
527 {
528 Some("read") => AccountAction::Read,
529 Some("manage") => AccountAction::Manage,
530 Some(other) => return Err(ParseError::InvalidAction(other.to_string())),
531 None => AccountAction::Read,
532 }
533 } else {
534 AccountAction::Read
535 };
536
537 Ok(Scope::Account(AccountScope { resource, action }))
538 }
539
540 fn parse_identity(suffix: Option<&'s str>) -> Result<Self, ParseError> {
541 let scope = match suffix {
542 Some("handle") => IdentityScope::Handle,
543 Some("*") => IdentityScope::All,
544 Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
545 None => return Err(ParseError::MissingResource),
546 };
547
548 Ok(Scope::Identity(scope))
549 }
550
551 fn parse_blob(suffix: Option<&'s str>) -> Result<Self, ParseError> {
552 let mut accept = BTreeSet::new();
553
554 match suffix {
555 Some(s) if s.starts_with('?') => {
556 let params = parse_query_string(&s[1..]);
557 if let Some(values) = params.get("accept") {
558 for value in values {
559 accept.insert(MimePattern::from_str(value)?);
560 }
561 }
562 }
563 Some(s) => {
564 accept.insert(MimePattern::from_str(s)?);
565 }
566 None => {
567 accept.insert(MimePattern::All);
568 }
569 }
570
571 if accept.is_empty() {
572 accept.insert(MimePattern::All);
573 }
574
575 Ok(Scope::Blob(BlobScope { accept }))
576 }
577
578 fn parse_repo(suffix: Option<&'s str>) -> Result<Self, ParseError> {
579 let (collection_str, params) = match suffix {
580 Some(s) => {
581 if let Some(pos) = s.find('?') {
582 (Some(&s[..pos]), Some(&s[pos + 1..]))
583 } else {
584 (Some(s), None)
585 }
586 }
587 None => (None, None),
588 };
589
590 let collection = match collection_str {
591 Some("*") | None => RepoCollection::All,
592 Some(nsid) => RepoCollection::Nsid(Nsid::new(nsid)?),
593 };
594
595 let mut actions = BTreeSet::new();
596 if let Some(params) = params {
597 let parsed_params = parse_query_string(params);
598 if let Some(values) = parsed_params.get("action") {
599 for value in values {
600 match value.as_ref() {
601 "create" => {
602 actions.insert(RepoAction::Create);
603 }
604 "update" => {
605 actions.insert(RepoAction::Update);
606 }
607 "delete" => {
608 actions.insert(RepoAction::Delete);
609 }
610 "*" => {
611 actions.insert(RepoAction::Create);
612 actions.insert(RepoAction::Update);
613 actions.insert(RepoAction::Delete);
614 }
615 other => return Err(ParseError::InvalidAction(other.to_string())),
616 }
617 }
618 }
619 }
620
621 if actions.is_empty() {
622 actions.insert(RepoAction::Create);
623 actions.insert(RepoAction::Update);
624 actions.insert(RepoAction::Delete);
625 }
626
627 Ok(Scope::Repo(RepoScope {
628 collection,
629 actions,
630 }))
631 }
632
633 fn parse_rpc(suffix: Option<&'s str>) -> Result<Self, ParseError> {
634 let mut lxm = BTreeSet::new();
635 let mut aud = BTreeSet::new();
636
637 match suffix {
638 Some("*") => {
639 lxm.insert(RpcLexicon::All);
640 aud.insert(RpcAudience::All);
641 }
642 Some(s) if s.starts_with('?') => {
643 let params = parse_query_string(&s[1..]);
644
645 if let Some(values) = params.get("lxm") {
646 for value in values {
647 if value.as_ref() == "*" {
648 lxm.insert(RpcLexicon::All);
649 } else {
650 lxm.insert(RpcLexicon::Nsid(Nsid::new(value)?.into_static()));
651 }
652 }
653 }
654
655 if let Some(values) = params.get("aud") {
656 for value in values {
657 if value.as_ref() == "*" {
658 aud.insert(RpcAudience::All);
659 } else {
660 aud.insert(RpcAudience::Did(Did::new(value)?.into_static()));
661 }
662 }
663 }
664 }
665 Some(s) => {
666 // Check if there's a query string in the suffix
667 if let Some(pos) = s.find('?') {
668 let nsid = &s[..pos];
669 let params = parse_query_string(&s[pos + 1..]);
670
671 lxm.insert(RpcLexicon::Nsid(Nsid::new(nsid)?.into_static()));
672
673 if let Some(values) = params.get("aud") {
674 for value in values {
675 if value.as_ref() == "*" {
676 aud.insert(RpcAudience::All);
677 } else {
678 aud.insert(RpcAudience::Did(Did::new(value)?.into_static()));
679 }
680 }
681 }
682 } else {
683 lxm.insert(RpcLexicon::Nsid(Nsid::new(s)?.into_static()));
684 }
685 }
686 None => {}
687 }
688
689 if lxm.is_empty() {
690 lxm.insert(RpcLexicon::All);
691 }
692 if aud.is_empty() {
693 aud.insert(RpcAudience::All);
694 }
695
696 Ok(Scope::Rpc(RpcScope { lxm, aud }))
697 }
698
699 fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> {
700 if suffix.is_some() {
701 return Err(ParseError::InvalidResource(
702 "atproto scope does not accept suffixes".to_string(),
703 ));
704 }
705 Ok(Scope::Atproto)
706 }
707
708 fn parse_transition(suffix: Option<&str>) -> Result<Self, ParseError> {
709 let scope = match suffix {
710 Some("generic") => TransitionScope::Generic,
711 Some("email") => TransitionScope::Email,
712 Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
713 None => return Err(ParseError::MissingResource),
714 };
715
716 Ok(Scope::Transition(scope))
717 }
718
719 fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> {
720 if suffix.is_some() {
721 return Err(ParseError::InvalidResource(
722 "openid scope does not accept suffixes".to_string(),
723 ));
724 }
725 Ok(Scope::OpenId)
726 }
727
728 fn parse_profile(suffix: Option<&str>) -> Result<Self, ParseError> {
729 if suffix.is_some() {
730 return Err(ParseError::InvalidResource(
731 "profile scope does not accept suffixes".to_string(),
732 ));
733 }
734 Ok(Scope::Profile)
735 }
736
737 fn parse_email(suffix: Option<&str>) -> Result<Self, ParseError> {
738 if suffix.is_some() {
739 return Err(ParseError::InvalidResource(
740 "email scope does not accept suffixes".to_string(),
741 ));
742 }
743 Ok(Scope::Email)
744 }
745
746 /// Convert the scope to its normalized string representation
747 pub fn to_string_normalized(&self) -> String {
748 match self {
749 Scope::Account(scope) => {
750 let resource = match scope.resource {
751 AccountResource::Email => "email",
752 AccountResource::Repo => "repo",
753 AccountResource::Status => "status",
754 };
755
756 match scope.action {
757 AccountAction::Read => format!("account:{}", resource),
758 AccountAction::Manage => format!("account:{}?action=manage", resource),
759 }
760 }
761 Scope::Identity(scope) => match scope {
762 IdentityScope::Handle => "identity:handle".to_string(),
763 IdentityScope::All => "identity:*".to_string(),
764 },
765 Scope::Blob(scope) => {
766 if scope.accept.len() == 1 {
767 if let Some(pattern) = scope.accept.iter().next() {
768 match pattern {
769 MimePattern::All => "blob:*/*".to_string(),
770 MimePattern::TypeWildcard(t) => format!("blob:{}/*", t),
771 MimePattern::Exact(mime) => format!("blob:{}", mime),
772 }
773 } else {
774 "blob:*/*".to_string()
775 }
776 } else {
777 let mut params = Vec::new();
778 for pattern in &scope.accept {
779 match pattern {
780 MimePattern::All => params.push("accept=*/*".to_string()),
781 MimePattern::TypeWildcard(t) => params.push(format!("accept={}/*", t)),
782 MimePattern::Exact(mime) => params.push(format!("accept={}", mime)),
783 }
784 }
785 params.sort();
786 format!("blob?{}", params.join("&"))
787 }
788 }
789 Scope::Repo(scope) => {
790 let collection = match &scope.collection {
791 RepoCollection::All => "*",
792 RepoCollection::Nsid(nsid) => nsid,
793 };
794
795 if scope.actions.len() == 3 {
796 format!("repo:{}", collection)
797 } else {
798 let mut params = Vec::new();
799 for action in &scope.actions {
800 match action {
801 RepoAction::Create => params.push("action=create"),
802 RepoAction::Update => params.push("action=update"),
803 RepoAction::Delete => params.push("action=delete"),
804 }
805 }
806 format!("repo:{}?{}", collection, params.join("&"))
807 }
808 }
809 Scope::Rpc(scope) => {
810 if scope.lxm.len() == 1
811 && scope.lxm.contains(&RpcLexicon::All)
812 && scope.aud.len() == 1
813 && scope.aud.contains(&RpcAudience::All)
814 {
815 "rpc:*".to_string()
816 } else if scope.lxm.len() == 1
817 && scope.aud.len() == 1
818 && scope.aud.contains(&RpcAudience::All)
819 {
820 if let Some(lxm) = scope.lxm.iter().next() {
821 match lxm {
822 RpcLexicon::All => "rpc:*".to_string(),
823 RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid),
824 }
825 } else {
826 "rpc:*".to_string()
827 }
828 } else {
829 let mut params = Vec::new();
830
831 for lxm in &scope.lxm {
832 match lxm {
833 RpcLexicon::All => params.push("lxm=*".to_string()),
834 RpcLexicon::Nsid(nsid) => params.push(format!("lxm={}", nsid)),
835 }
836 }
837
838 for aud in &scope.aud {
839 match aud {
840 RpcAudience::All => params.push("aud=*".to_string()),
841 RpcAudience::Did(did) => params.push(format!("aud={}", did)),
842 }
843 }
844
845 params.sort();
846
847 if params.is_empty() {
848 "rpc:*".to_string()
849 } else {
850 format!("rpc?{}", params.join("&"))
851 }
852 }
853 }
854 Scope::Atproto => "atproto".to_string(),
855 Scope::Transition(scope) => match scope {
856 TransitionScope::Generic => "transition:generic".to_string(),
857 TransitionScope::Email => "transition:email".to_string(),
858 },
859 Scope::OpenId => "openid".to_string(),
860 Scope::Profile => "profile".to_string(),
861 Scope::Email => "email".to_string(),
862 }
863 }
864
865 /// Check if this scope grants the permissions of another scope
866 pub fn grants(&self, other: &Scope) -> bool {
867 match (self, other) {
868 // Atproto only grants itself (it's a required scope, not a permission grant)
869 (Scope::Atproto, Scope::Atproto) => true,
870 (Scope::Atproto, _) => false,
871 // Nothing else grants atproto
872 (_, Scope::Atproto) => false,
873 // Transition scopes only grant themselves
874 (Scope::Transition(a), Scope::Transition(b)) => a == b,
875 // Other scopes don't grant transition scopes
876 (_, Scope::Transition(_)) => false,
877 (Scope::Transition(_), _) => false,
878 // OpenID Connect scopes only grant themselves
879 (Scope::OpenId, Scope::OpenId) => true,
880 (Scope::OpenId, _) => false,
881 (_, Scope::OpenId) => false,
882 (Scope::Profile, Scope::Profile) => true,
883 (Scope::Profile, _) => false,
884 (_, Scope::Profile) => false,
885 (Scope::Email, Scope::Email) => true,
886 (Scope::Email, _) => false,
887 (_, Scope::Email) => false,
888 (Scope::Account(a), Scope::Account(b)) => {
889 a.resource == b.resource
890 && matches!(
891 (a.action, b.action),
892 (AccountAction::Manage, _) | (AccountAction::Read, AccountAction::Read)
893 )
894 }
895 (Scope::Identity(a), Scope::Identity(b)) => matches!(
896 (a, b),
897 (IdentityScope::All, _) | (IdentityScope::Handle, IdentityScope::Handle)
898 ),
899 (Scope::Blob(a), Scope::Blob(b)) => {
900 for b_pattern in &b.accept {
901 let mut granted = false;
902 for a_pattern in &a.accept {
903 if a_pattern.grants(b_pattern) {
904 granted = true;
905 break;
906 }
907 }
908 if !granted {
909 return false;
910 }
911 }
912 true
913 }
914 (Scope::Repo(a), Scope::Repo(b)) => {
915 let collection_match = match (&a.collection, &b.collection) {
916 (RepoCollection::All, _) => true,
917 (RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => {
918 a_nsid == b_nsid
919 }
920 _ => false,
921 };
922
923 if !collection_match {
924 return false;
925 }
926
927 b.actions.is_subset(&a.actions) || a.actions.len() == 3
928 }
929 (Scope::Rpc(a), Scope::Rpc(b)) => {
930 let lxm_match = if a.lxm.contains(&RpcLexicon::All) {
931 true
932 } else {
933 b.lxm.iter().all(|b_lxm| match b_lxm {
934 RpcLexicon::All => false,
935 RpcLexicon::Nsid(_) => a.lxm.contains(b_lxm),
936 })
937 };
938
939 let aud_match = if a.aud.contains(&RpcAudience::All) {
940 true
941 } else {
942 b.aud.iter().all(|b_aud| match b_aud {
943 RpcAudience::All => false,
944 RpcAudience::Did(_) => a.aud.contains(b_aud),
945 })
946 };
947
948 lxm_match && aud_match
949 }
950 _ => false,
951 }
952 }
953}
954
955impl MimePattern<'_> {
956 fn grants(&self, other: &MimePattern) -> bool {
957 match (self, other) {
958 (MimePattern::All, _) => true,
959 (MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => {
960 a_type == b_type
961 }
962 (MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => {
963 b_mime.starts_with(&format!("{}/", a_type))
964 }
965 (MimePattern::Exact(a), MimePattern::Exact(b)) => a == b,
966 _ => false,
967 }
968 }
969}
970
971impl FromStr for MimePattern<'_> {
972 type Err = ParseError;
973
974 fn from_str(s: &str) -> Result<Self, Self::Err> {
975 if s == "*/*" {
976 Ok(MimePattern::All)
977 } else if let Some(stripped) = s.strip_suffix("/*") {
978 Ok(MimePattern::TypeWildcard(CowStr::Owned(
979 stripped.to_smolstr(),
980 )))
981 } else if s.contains('/') {
982 Ok(MimePattern::Exact(CowStr::Owned(s.to_smolstr())))
983 } else {
984 Err(ParseError::InvalidMimeType(s.to_string()))
985 }
986 }
987}
988
989impl FromStr for Scope<'_> {
990 type Err = ParseError;
991
992 fn from_str(s: &str) -> Result<Scope<'static>, Self::Err> {
993 match Scope::parse(s) {
994 Ok(parsed) => Ok(parsed.into_static()),
995 Err(e) => Err(e),
996 }
997 }
998}
999
1000impl fmt::Display for Scope<'_> {
1001 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1002 write!(f, "{}", self.to_string_normalized())
1003 }
1004}
1005
1006/// Parse a query string into a map of keys to lists of values
1007fn parse_query_string(query: &str) -> BTreeMap<SmolStr, Vec<CowStr<'static>>> {
1008 let mut params = BTreeMap::new();
1009
1010 for pair in query.split('&') {
1011 if let Some(pos) = pair.find('=') {
1012 let key = &pair[..pos];
1013 let value = &pair[pos + 1..];
1014 params
1015 .entry(key.to_smolstr())
1016 .or_insert_with(Vec::new)
1017 .push(CowStr::Owned(value.to_smolstr()));
1018 }
1019 }
1020
1021 params
1022}
1023
1024/// Error type for scope parsing
1025#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)]
1026pub enum ParseError {
1027 /// Unknown scope prefix
1028 UnknownPrefix(String),
1029 /// Missing required resource
1030 MissingResource,
1031 /// Invalid resource type
1032 InvalidResource(String),
1033 /// Invalid action type
1034 InvalidAction(String),
1035 /// Invalid MIME type
1036 InvalidMimeType(String),
1037 ParseError(#[from] AtStrError),
1038}
1039
1040impl fmt::Display for ParseError {
1041 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1042 match self {
1043 ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix),
1044 ParseError::MissingResource => write!(f, "Missing required resource"),
1045 ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource),
1046 ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action),
1047 ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime),
1048 ParseError::ParseError(err) => write!(f, "Parse error: {}", err),
1049 }
1050 }
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055 use super::*;
1056
1057 #[test]
1058 fn test_account_scope_parsing() {
1059 let scope = Scope::parse("account:email").unwrap();
1060 assert_eq!(
1061 scope,
1062 Scope::Account(AccountScope {
1063 resource: AccountResource::Email,
1064 action: AccountAction::Read,
1065 })
1066 );
1067
1068 let scope = Scope::parse("account:repo?action=manage").unwrap();
1069 assert_eq!(
1070 scope,
1071 Scope::Account(AccountScope {
1072 resource: AccountResource::Repo,
1073 action: AccountAction::Manage,
1074 })
1075 );
1076
1077 let scope = Scope::parse("account:status?action=read").unwrap();
1078 assert_eq!(
1079 scope,
1080 Scope::Account(AccountScope {
1081 resource: AccountResource::Status,
1082 action: AccountAction::Read,
1083 })
1084 );
1085 }
1086
1087 #[test]
1088 fn test_identity_scope_parsing() {
1089 let scope = Scope::parse("identity:handle").unwrap();
1090 assert_eq!(scope, Scope::Identity(IdentityScope::Handle));
1091
1092 let scope = Scope::parse("identity:*").unwrap();
1093 assert_eq!(scope, Scope::Identity(IdentityScope::All));
1094 }
1095
1096 #[test]
1097 fn test_blob_scope_parsing() {
1098 let scope = Scope::parse("blob:*/*").unwrap();
1099 let mut accept = BTreeSet::new();
1100 accept.insert(MimePattern::All);
1101 assert_eq!(scope, Scope::Blob(BlobScope { accept }));
1102
1103 let scope = Scope::parse("blob:image/png").unwrap();
1104 let mut accept = BTreeSet::new();
1105 accept.insert(MimePattern::Exact(CowStr::new_static("image/png")));
1106 assert_eq!(scope, Scope::Blob(BlobScope { accept }));
1107
1108 let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap();
1109 let mut accept = BTreeSet::new();
1110 accept.insert(MimePattern::Exact(CowStr::new_static("image/png")));
1111 accept.insert(MimePattern::Exact(CowStr::new_static("image/jpeg")));
1112 assert_eq!(scope, Scope::Blob(BlobScope { accept }));
1113
1114 let scope = Scope::parse("blob:image/*").unwrap();
1115 let mut accept = BTreeSet::new();
1116 accept.insert(MimePattern::TypeWildcard(CowStr::new_static("image")));
1117 assert_eq!(scope, Scope::Blob(BlobScope { accept }));
1118 }
1119
1120 #[test]
1121 fn test_repo_scope_parsing() {
1122 let scope = Scope::parse("repo:*?action=create").unwrap();
1123 let mut actions = BTreeSet::new();
1124 actions.insert(RepoAction::Create);
1125 assert_eq!(
1126 scope,
1127 Scope::Repo(RepoScope {
1128 collection: RepoCollection::All,
1129 actions,
1130 })
1131 );
1132
1133 let scope = Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap();
1134 let mut actions = BTreeSet::new();
1135 actions.insert(RepoAction::Create);
1136 actions.insert(RepoAction::Update);
1137 assert_eq!(
1138 scope,
1139 Scope::Repo(RepoScope {
1140 collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
1141 actions,
1142 })
1143 );
1144
1145 let scope = Scope::parse("repo:app.bsky.feed.post").unwrap();
1146 let mut actions = BTreeSet::new();
1147 actions.insert(RepoAction::Create);
1148 actions.insert(RepoAction::Update);
1149 actions.insert(RepoAction::Delete);
1150 assert_eq!(
1151 scope,
1152 Scope::Repo(RepoScope {
1153 collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
1154 actions,
1155 })
1156 );
1157 }
1158
1159 #[test]
1160 fn test_rpc_scope_parsing() {
1161 let scope = Scope::parse("rpc:*").unwrap();
1162 let mut lxm = BTreeSet::new();
1163 let mut aud = BTreeSet::new();
1164 lxm.insert(RpcLexicon::All);
1165 aud.insert(RpcAudience::All);
1166 assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1167
1168 let scope = Scope::parse("rpc:com.example.service").unwrap();
1169 let mut lxm = BTreeSet::new();
1170 let mut aud = BTreeSet::new();
1171 lxm.insert(RpcLexicon::Nsid(
1172 Nsid::new_static("com.example.service").unwrap(),
1173 ));
1174 aud.insert(RpcAudience::All);
1175 assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1176
1177 let scope =
1178 Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap();
1179 let mut lxm = BTreeSet::new();
1180 let mut aud = BTreeSet::new();
1181 lxm.insert(RpcLexicon::Nsid(
1182 Nsid::new_static("com.example.service").unwrap(),
1183 ));
1184 aud.insert(RpcAudience::Did(
1185 Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(),
1186 ));
1187 assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1188
1189 let scope =
1190 Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g")
1191 .unwrap();
1192 let mut lxm = BTreeSet::new();
1193 let mut aud = BTreeSet::new();
1194 lxm.insert(RpcLexicon::Nsid(
1195 Nsid::new_static("com.example.method1").unwrap(),
1196 ));
1197 lxm.insert(RpcLexicon::Nsid(
1198 Nsid::new_static("com.example.method2").unwrap(),
1199 ));
1200 aud.insert(RpcAudience::Did(
1201 Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(),
1202 ));
1203 assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1204 }
1205
1206 #[test]
1207 fn test_scope_normalization() {
1208 let tests = vec![
1209 ("account:email", "account:email"),
1210 ("account:email?action=read", "account:email"),
1211 ("account:email?action=manage", "account:email?action=manage"),
1212 ("blob:image/png", "blob:image/png"),
1213 (
1214 "blob?accept=image/jpeg&accept=image/png",
1215 "blob?accept=image/jpeg&accept=image/png",
1216 ),
1217 ("repo:app.bsky.feed.post", "repo:app.bsky.feed.post"),
1218 (
1219 "repo:app.bsky.feed.post?action=create",
1220 "repo:app.bsky.feed.post?action=create",
1221 ),
1222 ("rpc:*", "rpc:*"),
1223 ];
1224
1225 for (input, expected) in tests {
1226 let scope = Scope::parse(input).unwrap();
1227 assert_eq!(scope.to_string_normalized(), expected);
1228 }
1229 }
1230
1231 #[test]
1232 fn test_account_scope_grants() {
1233 let manage = Scope::parse("account:email?action=manage").unwrap();
1234 let read = Scope::parse("account:email?action=read").unwrap();
1235 let other_read = Scope::parse("account:repo?action=read").unwrap();
1236
1237 assert!(manage.grants(&read));
1238 assert!(manage.grants(&manage));
1239 assert!(!read.grants(&manage));
1240 assert!(read.grants(&read));
1241 assert!(!read.grants(&other_read));
1242 }
1243
1244 #[test]
1245 fn test_identity_scope_grants() {
1246 let all = Scope::parse("identity:*").unwrap();
1247 let handle = Scope::parse("identity:handle").unwrap();
1248
1249 assert!(all.grants(&handle));
1250 assert!(all.grants(&all));
1251 assert!(!handle.grants(&all));
1252 assert!(handle.grants(&handle));
1253 }
1254
1255 #[test]
1256 fn test_blob_scope_grants() {
1257 let all = Scope::parse("blob:*/*").unwrap();
1258 let image_all = Scope::parse("blob:image/*").unwrap();
1259 let image_png = Scope::parse("blob:image/png").unwrap();
1260 let text_plain = Scope::parse("blob:text/plain").unwrap();
1261
1262 assert!(all.grants(&image_all));
1263 assert!(all.grants(&image_png));
1264 assert!(all.grants(&text_plain));
1265 assert!(image_all.grants(&image_png));
1266 assert!(!image_all.grants(&text_plain));
1267 assert!(!image_png.grants(&image_all));
1268 }
1269
1270 #[test]
1271 fn test_repo_scope_grants() {
1272 let all_all = Scope::parse("repo:*").unwrap();
1273 let all_create = Scope::parse("repo:*?action=create").unwrap();
1274 let specific_all = Scope::parse("repo:app.bsky.feed.post").unwrap();
1275 let specific_create = Scope::parse("repo:app.bsky.feed.post?action=create").unwrap();
1276 let other_create = Scope::parse("repo:pub.leaflet.publication?action=create").unwrap();
1277
1278 assert!(all_all.grants(&all_create));
1279 assert!(all_all.grants(&specific_all));
1280 assert!(all_all.grants(&specific_create));
1281 assert!(all_create.grants(&all_create));
1282 assert!(!all_create.grants(&specific_all));
1283 assert!(specific_all.grants(&specific_create));
1284 assert!(!specific_create.grants(&specific_all));
1285 assert!(!specific_create.grants(&other_create));
1286 }
1287
1288 #[test]
1289 fn test_rpc_scope_grants() {
1290 let all = Scope::parse("rpc:*").unwrap();
1291 let specific_lxm = Scope::parse("rpc:com.example.service").unwrap();
1292 let specific_both = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap();
1293
1294 assert!(all.grants(&specific_lxm));
1295 assert!(all.grants(&specific_both));
1296 assert!(specific_lxm.grants(&specific_both));
1297 assert!(!specific_both.grants(&specific_lxm));
1298 assert!(!specific_both.grants(&all));
1299 }
1300
1301 #[test]
1302 fn test_cross_scope_grants() {
1303 let account = Scope::parse("account:email").unwrap();
1304 let identity = Scope::parse("identity:handle").unwrap();
1305
1306 assert!(!account.grants(&identity));
1307 assert!(!identity.grants(&account));
1308 }
1309
1310 #[test]
1311 fn test_parse_errors() {
1312 assert!(matches!(
1313 Scope::parse("unknown:test"),
1314 Err(ParseError::UnknownPrefix(_))
1315 ));
1316
1317 assert!(matches!(
1318 Scope::parse("account"),
1319 Err(ParseError::MissingResource)
1320 ));
1321
1322 assert!(matches!(
1323 Scope::parse("account:invalid"),
1324 Err(ParseError::InvalidResource(_))
1325 ));
1326
1327 assert!(matches!(
1328 Scope::parse("account:email?action=invalid"),
1329 Err(ParseError::InvalidAction(_))
1330 ));
1331 }
1332
1333 #[test]
1334 fn test_query_parameter_sorting() {
1335 let scope =
1336 Scope::parse("blob?accept=image/png&accept=application/pdf&accept=image/jpeg").unwrap();
1337 let normalized = scope.to_string_normalized();
1338 assert!(normalized.contains("accept=application/pdf"));
1339 assert!(normalized.contains("accept=image/jpeg"));
1340 assert!(normalized.contains("accept=image/png"));
1341 let pdf_pos = normalized.find("accept=application/pdf").unwrap();
1342 let jpeg_pos = normalized.find("accept=image/jpeg").unwrap();
1343 let png_pos = normalized.find("accept=image/png").unwrap();
1344 assert!(pdf_pos < jpeg_pos);
1345 assert!(jpeg_pos < png_pos);
1346 }
1347
1348 #[test]
1349 fn test_repo_action_wildcard() {
1350 let scope = Scope::parse("repo:app.bsky.feed.post?action=*").unwrap();
1351 let mut actions = BTreeSet::new();
1352 actions.insert(RepoAction::Create);
1353 actions.insert(RepoAction::Update);
1354 actions.insert(RepoAction::Delete);
1355 assert_eq!(
1356 scope,
1357 Scope::Repo(RepoScope {
1358 collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
1359 actions,
1360 })
1361 );
1362 }
1363
1364 #[test]
1365 fn test_multiple_blob_accepts() {
1366 let scope = Scope::parse("blob?accept=image/*&accept=text/plain").unwrap();
1367 assert!(scope.grants(&Scope::parse("blob:image/png").unwrap()));
1368 assert!(scope.grants(&Scope::parse("blob:text/plain").unwrap()));
1369 assert!(!scope.grants(&Scope::parse("blob:application/json").unwrap()));
1370 }
1371
1372 #[test]
1373 fn test_rpc_default_wildcards() {
1374 let scope = Scope::parse("rpc").unwrap();
1375 let mut lxm = BTreeSet::new();
1376 let mut aud = BTreeSet::new();
1377 lxm.insert(RpcLexicon::All);
1378 aud.insert(RpcAudience::All);
1379 assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1380 }
1381
1382 #[test]
1383 fn test_atproto_scope_parsing() {
1384 let scope = Scope::parse("atproto").unwrap();
1385 assert_eq!(scope, Scope::Atproto);
1386
1387 // Atproto should not accept suffixes
1388 assert!(Scope::parse("atproto:something").is_err());
1389 assert!(Scope::parse("atproto?param=value").is_err());
1390 }
1391
1392 #[test]
1393 fn test_transition_scope_parsing() {
1394 let scope = Scope::parse("transition:generic").unwrap();
1395 assert_eq!(scope, Scope::Transition(TransitionScope::Generic));
1396
1397 let scope = Scope::parse("transition:email").unwrap();
1398 assert_eq!(scope, Scope::Transition(TransitionScope::Email));
1399
1400 // Test invalid transition types
1401 assert!(matches!(
1402 Scope::parse("transition:invalid"),
1403 Err(ParseError::InvalidResource(_))
1404 ));
1405
1406 // Test missing suffix
1407 assert!(matches!(
1408 Scope::parse("transition"),
1409 Err(ParseError::MissingResource)
1410 ));
1411
1412 // Test transition doesn't accept query parameters
1413 assert!(matches!(
1414 Scope::parse("transition:generic?param=value"),
1415 Err(ParseError::InvalidResource(_))
1416 ));
1417 }
1418
1419 #[test]
1420 fn test_atproto_scope_normalization() {
1421 let scope = Scope::parse("atproto").unwrap();
1422 assert_eq!(scope.to_string_normalized(), "atproto");
1423 }
1424
1425 #[test]
1426 fn test_transition_scope_normalization() {
1427 let tests = vec![
1428 ("transition:generic", "transition:generic"),
1429 ("transition:email", "transition:email"),
1430 ];
1431
1432 for (input, expected) in tests {
1433 let scope = Scope::parse(input).unwrap();
1434 assert_eq!(scope.to_string_normalized(), expected);
1435 }
1436 }
1437
1438 #[test]
1439 fn test_atproto_scope_grants() {
1440 let atproto = Scope::parse("atproto").unwrap();
1441 let account = Scope::parse("account:email").unwrap();
1442 let identity = Scope::parse("identity:handle").unwrap();
1443 let blob = Scope::parse("blob:image/png").unwrap();
1444 let repo = Scope::parse("repo:app.bsky.feed.post").unwrap();
1445 let rpc = Scope::parse("rpc:com.example.service").unwrap();
1446 let transition_generic = Scope::parse("transition:generic").unwrap();
1447 let transition_email = Scope::parse("transition:email").unwrap();
1448
1449 // Atproto only grants itself (it's a required scope, not a permission grant)
1450 assert!(atproto.grants(&atproto));
1451 assert!(!atproto.grants(&account));
1452 assert!(!atproto.grants(&identity));
1453 assert!(!atproto.grants(&blob));
1454 assert!(!atproto.grants(&repo));
1455 assert!(!atproto.grants(&rpc));
1456 assert!(!atproto.grants(&transition_generic));
1457 assert!(!atproto.grants(&transition_email));
1458
1459 // Nothing else grants atproto
1460 assert!(!account.grants(&atproto));
1461 assert!(!identity.grants(&atproto));
1462 assert!(!blob.grants(&atproto));
1463 assert!(!repo.grants(&atproto));
1464 assert!(!rpc.grants(&atproto));
1465 assert!(!transition_generic.grants(&atproto));
1466 assert!(!transition_email.grants(&atproto));
1467 }
1468
1469 #[test]
1470 fn test_transition_scope_grants() {
1471 let transition_generic = Scope::parse("transition:generic").unwrap();
1472 let transition_email = Scope::parse("transition:email").unwrap();
1473 let account = Scope::parse("account:email").unwrap();
1474
1475 // Transition scopes only grant themselves
1476 assert!(transition_generic.grants(&transition_generic));
1477 assert!(transition_email.grants(&transition_email));
1478 assert!(!transition_generic.grants(&transition_email));
1479 assert!(!transition_email.grants(&transition_generic));
1480
1481 // Transition scopes don't grant other scope types
1482 assert!(!transition_generic.grants(&account));
1483 assert!(!transition_email.grants(&account));
1484
1485 // Other scopes don't grant transition scopes
1486 assert!(!account.grants(&transition_generic));
1487 assert!(!account.grants(&transition_email));
1488 }
1489
1490 #[test]
1491 fn test_parse_multiple() {
1492 // Test parsing multiple scopes
1493 let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
1494 assert_eq!(scopes.len(), 2);
1495 assert_eq!(scopes[0], Scope::Atproto);
1496 assert_eq!(
1497 scopes[1],
1498 Scope::Repo(RepoScope {
1499 collection: RepoCollection::All,
1500 actions: {
1501 let mut actions = BTreeSet::new();
1502 actions.insert(RepoAction::Create);
1503 actions.insert(RepoAction::Update);
1504 actions.insert(RepoAction::Delete);
1505 actions
1506 }
1507 })
1508 );
1509
1510 // Test with more scopes
1511 let scopes = Scope::parse_multiple("account:email identity:handle blob:image/png").unwrap();
1512 assert_eq!(scopes.len(), 3);
1513 assert!(matches!(scopes[0], Scope::Account(_)));
1514 assert!(matches!(scopes[1], Scope::Identity(_)));
1515 assert!(matches!(scopes[2], Scope::Blob(_)));
1516
1517 // Test with complex scopes
1518 let scopes = Scope::parse_multiple(
1519 "account:email?action=manage repo:app.bsky.feed.post?action=create transition:email",
1520 )
1521 .unwrap();
1522 assert_eq!(scopes.len(), 3);
1523
1524 // Test empty string
1525 let scopes = Scope::parse_multiple("").unwrap();
1526 assert_eq!(scopes.len(), 0);
1527
1528 // Test whitespace only
1529 let scopes = Scope::parse_multiple(" ").unwrap();
1530 assert_eq!(scopes.len(), 0);
1531
1532 // Test with extra whitespace
1533 let scopes = Scope::parse_multiple(" atproto repo:* ").unwrap();
1534 assert_eq!(scopes.len(), 2);
1535
1536 // Test single scope
1537 let scopes = Scope::parse_multiple("atproto").unwrap();
1538 assert_eq!(scopes.len(), 1);
1539 assert_eq!(scopes[0], Scope::Atproto);
1540
1541 // Test error propagation
1542 assert!(Scope::parse_multiple("atproto invalid:scope").is_err());
1543 assert!(Scope::parse_multiple("account:invalid repo:*").is_err());
1544 }
1545
1546 #[test]
1547 fn test_parse_multiple_reduced() {
1548 // Test repo scope reduction - wildcard grants specific
1549 let scopes =
1550 Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap();
1551 assert_eq!(scopes.len(), 2);
1552 assert!(scopes.contains(&Scope::Atproto));
1553 assert!(scopes.contains(&Scope::Repo(RepoScope {
1554 collection: RepoCollection::All,
1555 actions: {
1556 let mut actions = BTreeSet::new();
1557 actions.insert(RepoAction::Create);
1558 actions.insert(RepoAction::Update);
1559 actions.insert(RepoAction::Delete);
1560 actions
1561 }
1562 })));
1563
1564 // Test reverse order - should get same result
1565 let scopes =
1566 Scope::parse_multiple_reduced("atproto repo:* repo:app.bsky.feed.post").unwrap();
1567 assert_eq!(scopes.len(), 2);
1568 assert!(scopes.contains(&Scope::Atproto));
1569 assert!(scopes.contains(&Scope::Repo(RepoScope {
1570 collection: RepoCollection::All,
1571 actions: {
1572 let mut actions = BTreeSet::new();
1573 actions.insert(RepoAction::Create);
1574 actions.insert(RepoAction::Update);
1575 actions.insert(RepoAction::Delete);
1576 actions
1577 }
1578 })));
1579
1580 // Test account scope reduction - manage grants read
1581 let scopes =
1582 Scope::parse_multiple_reduced("account:email account:email?action=manage").unwrap();
1583 assert_eq!(scopes.len(), 1);
1584 assert_eq!(
1585 scopes[0],
1586 Scope::Account(AccountScope {
1587 resource: AccountResource::Email,
1588 action: AccountAction::Manage,
1589 })
1590 );
1591
1592 // Test identity scope reduction - wildcard grants specific
1593 let scopes = Scope::parse_multiple_reduced("identity:handle identity:*").unwrap();
1594 assert_eq!(scopes.len(), 1);
1595 assert_eq!(scopes[0], Scope::Identity(IdentityScope::All));
1596
1597 // Test blob scope reduction - wildcard grants specific
1598 let scopes = Scope::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*").unwrap();
1599 assert_eq!(scopes.len(), 1);
1600 let mut accept = BTreeSet::new();
1601 accept.insert(MimePattern::All);
1602 assert_eq!(scopes[0], Scope::Blob(BlobScope { accept }));
1603
1604 // Test no reduction needed - different scope types
1605 let scopes =
1606 Scope::parse_multiple_reduced("account:email identity:handle blob:image/png").unwrap();
1607 assert_eq!(scopes.len(), 3);
1608
1609 // Test repo action reduction
1610 let scopes = Scope::parse_multiple_reduced(
1611 "repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post",
1612 )
1613 .unwrap();
1614 assert_eq!(scopes.len(), 1);
1615 assert_eq!(
1616 scopes[0],
1617 Scope::Repo(RepoScope {
1618 collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()),
1619 actions: {
1620 let mut actions = BTreeSet::new();
1621 actions.insert(RepoAction::Create);
1622 actions.insert(RepoAction::Update);
1623 actions.insert(RepoAction::Delete);
1624 actions
1625 }
1626 })
1627 );
1628
1629 // Test RPC scope reduction
1630 let scopes = Scope::parse_multiple_reduced(
1631 "rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*",
1632 )
1633 .unwrap();
1634 assert_eq!(scopes.len(), 1);
1635 assert_eq!(
1636 scopes[0],
1637 Scope::Rpc(RpcScope {
1638 lxm: {
1639 let mut lxm = BTreeSet::new();
1640 lxm.insert(RpcLexicon::All);
1641 lxm
1642 },
1643 aud: {
1644 let mut aud = BTreeSet::new();
1645 aud.insert(RpcAudience::All);
1646 aud
1647 }
1648 })
1649 );
1650
1651 // Test duplicate removal
1652 let scopes = Scope::parse_multiple_reduced("atproto atproto atproto").unwrap();
1653 assert_eq!(scopes.len(), 1);
1654 assert_eq!(scopes[0], Scope::Atproto);
1655
1656 // Test transition scopes - only grant themselves
1657 let scopes = Scope::parse_multiple_reduced("transition:generic transition:email").unwrap();
1658 assert_eq!(scopes.len(), 2);
1659 assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic)));
1660 assert!(scopes.contains(&Scope::Transition(TransitionScope::Email)));
1661
1662 // Test empty input
1663 let scopes = Scope::parse_multiple_reduced("").unwrap();
1664 assert_eq!(scopes.len(), 0);
1665
1666 // Test complex scenario with multiple reductions
1667 let scopes = Scope::parse_multiple_reduced(
1668 "account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle"
1669 ).unwrap();
1670 assert_eq!(scopes.len(), 3);
1671 // Should have: account:email?action=manage, account:repo, identity:*
1672 assert!(scopes.contains(&Scope::Account(AccountScope {
1673 resource: AccountResource::Email,
1674 action: AccountAction::Manage,
1675 })));
1676 assert!(scopes.contains(&Scope::Account(AccountScope {
1677 resource: AccountResource::Repo,
1678 action: AccountAction::Read,
1679 })));
1680 assert!(scopes.contains(&Scope::Identity(IdentityScope::All)));
1681
1682 // Test that atproto doesn't grant other scopes (per recent change)
1683 let scopes = Scope::parse_multiple_reduced("atproto account:email repo:*").unwrap();
1684 assert_eq!(scopes.len(), 3);
1685 assert!(scopes.contains(&Scope::Atproto));
1686 assert!(scopes.contains(&Scope::Account(AccountScope {
1687 resource: AccountResource::Email,
1688 action: AccountAction::Read,
1689 })));
1690 assert!(scopes.contains(&Scope::Repo(RepoScope {
1691 collection: RepoCollection::All,
1692 actions: {
1693 let mut actions = BTreeSet::new();
1694 actions.insert(RepoAction::Create);
1695 actions.insert(RepoAction::Update);
1696 actions.insert(RepoAction::Delete);
1697 actions
1698 }
1699 })));
1700 }
1701
1702 #[test]
1703 fn test_openid_connect_scope_parsing() {
1704 // Test OpenID scope
1705 let scope = Scope::parse("openid").unwrap();
1706 assert_eq!(scope, Scope::OpenId);
1707
1708 // Test Profile scope
1709 let scope = Scope::parse("profile").unwrap();
1710 assert_eq!(scope, Scope::Profile);
1711
1712 // Test Email scope
1713 let scope = Scope::parse("email").unwrap();
1714 assert_eq!(scope, Scope::Email);
1715
1716 // Test that they don't accept suffixes
1717 assert!(Scope::parse("openid:something").is_err());
1718 assert!(Scope::parse("profile:something").is_err());
1719 assert!(Scope::parse("email:something").is_err());
1720
1721 // Test that they don't accept query parameters
1722 assert!(Scope::parse("openid?param=value").is_err());
1723 assert!(Scope::parse("profile?param=value").is_err());
1724 assert!(Scope::parse("email?param=value").is_err());
1725 }
1726
1727 #[test]
1728 fn test_openid_connect_scope_normalization() {
1729 let scope = Scope::parse("openid").unwrap();
1730 assert_eq!(scope.to_string_normalized(), "openid");
1731
1732 let scope = Scope::parse("profile").unwrap();
1733 assert_eq!(scope.to_string_normalized(), "profile");
1734
1735 let scope = Scope::parse("email").unwrap();
1736 assert_eq!(scope.to_string_normalized(), "email");
1737 }
1738
1739 #[test]
1740 fn test_openid_connect_scope_grants() {
1741 let openid = Scope::parse("openid").unwrap();
1742 let profile = Scope::parse("profile").unwrap();
1743 let email = Scope::parse("email").unwrap();
1744 let account = Scope::parse("account:email").unwrap();
1745
1746 // OpenID Connect scopes only grant themselves
1747 assert!(openid.grants(&openid));
1748 assert!(!openid.grants(&profile));
1749 assert!(!openid.grants(&email));
1750 assert!(!openid.grants(&account));
1751
1752 assert!(profile.grants(&profile));
1753 assert!(!profile.grants(&openid));
1754 assert!(!profile.grants(&email));
1755 assert!(!profile.grants(&account));
1756
1757 assert!(email.grants(&email));
1758 assert!(!email.grants(&openid));
1759 assert!(!email.grants(&profile));
1760 assert!(!email.grants(&account));
1761
1762 // Other scopes don't grant OpenID Connect scopes
1763 assert!(!account.grants(&openid));
1764 assert!(!account.grants(&profile));
1765 assert!(!account.grants(&email));
1766 }
1767
1768 #[test]
1769 fn test_parse_multiple_with_openid_connect() {
1770 let scopes = Scope::parse_multiple("openid profile email atproto").unwrap();
1771 assert_eq!(scopes.len(), 4);
1772 assert_eq!(scopes[0], Scope::OpenId);
1773 assert_eq!(scopes[1], Scope::Profile);
1774 assert_eq!(scopes[2], Scope::Email);
1775 assert_eq!(scopes[3], Scope::Atproto);
1776
1777 // Test with mixed scopes
1778 let scopes = Scope::parse_multiple("openid account:email profile repo:*").unwrap();
1779 assert_eq!(scopes.len(), 4);
1780 assert!(scopes.contains(&Scope::OpenId));
1781 assert!(scopes.contains(&Scope::Profile));
1782 }
1783
1784 #[test]
1785 fn test_parse_multiple_reduced_with_openid_connect() {
1786 // OpenID Connect scopes don't grant each other, so no reduction
1787 let scopes = Scope::parse_multiple_reduced("openid profile email openid").unwrap();
1788 assert_eq!(scopes.len(), 3);
1789 assert!(scopes.contains(&Scope::OpenId));
1790 assert!(scopes.contains(&Scope::Profile));
1791 assert!(scopes.contains(&Scope::Email));
1792
1793 // Mixed with other scopes
1794 let scopes = Scope::parse_multiple_reduced(
1795 "openid account:email account:email?action=manage profile",
1796 )
1797 .unwrap();
1798 assert_eq!(scopes.len(), 3);
1799 assert!(scopes.contains(&Scope::OpenId));
1800 assert!(scopes.contains(&Scope::Profile));
1801 assert!(scopes.contains(&Scope::Account(AccountScope {
1802 resource: AccountResource::Email,
1803 action: AccountAction::Manage,
1804 })));
1805 }
1806
1807 #[test]
1808 fn test_serialize_multiple() {
1809 // Test empty list
1810 let scopes: Vec<Scope> = vec![];
1811 assert_eq!(Scope::serialize_multiple(&scopes), "");
1812
1813 // Test single scope
1814 let scopes = vec![Scope::Atproto];
1815 assert_eq!(Scope::serialize_multiple(&scopes), "atproto");
1816
1817 // Test multiple scopes - should be sorted alphabetically
1818 let scopes = vec![
1819 Scope::parse("repo:*").unwrap(),
1820 Scope::Atproto,
1821 Scope::parse("account:email").unwrap(),
1822 ];
1823 assert_eq!(
1824 Scope::serialize_multiple(&scopes),
1825 "account:email atproto repo:*"
1826 );
1827
1828 // Test that sorting is consistent regardless of input order
1829 let scopes = vec![
1830 Scope::parse("identity:handle").unwrap(),
1831 Scope::parse("blob:image/png").unwrap(),
1832 Scope::parse("account:repo?action=manage").unwrap(),
1833 ];
1834 assert_eq!(
1835 Scope::serialize_multiple(&scopes),
1836 "account:repo?action=manage blob:image/png identity:handle"
1837 );
1838
1839 // Test with OpenID Connect scopes
1840 let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto];
1841 assert_eq!(
1842 Scope::serialize_multiple(&scopes),
1843 "atproto email openid profile"
1844 );
1845
1846 // Test with complex scopes including query parameters
1847 let scopes = vec![
1848 Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.method")
1849 .unwrap(),
1850 Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(),
1851 Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(),
1852 ];
1853 let result = Scope::serialize_multiple(&scopes);
1854 // The result should be sorted alphabetically
1855 // Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..."
1856 assert!(result.starts_with("blob:"));
1857 assert!(result.contains(" repo:"));
1858 assert!(
1859 result.contains("rpc?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.service")
1860 );
1861
1862 // Test with transition scopes
1863 let scopes = vec![
1864 Scope::Transition(TransitionScope::Email),
1865 Scope::Transition(TransitionScope::Generic),
1866 Scope::Atproto,
1867 ];
1868 assert_eq!(
1869 Scope::serialize_multiple(&scopes),
1870 "atproto transition:email transition:generic"
1871 );
1872
1873 // Test duplicates - they remain in the output (caller's responsibility to dedupe if needed)
1874 let scopes = vec![
1875 Scope::Atproto,
1876 Scope::Atproto,
1877 Scope::parse("account:email").unwrap(),
1878 ];
1879 assert_eq!(
1880 Scope::serialize_multiple(&scopes),
1881 "account:email atproto atproto"
1882 );
1883
1884 // Test normalization is preserved in serialization
1885 let scopes = vec![Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap()];
1886 // Should normalize query parameters alphabetically
1887 assert_eq!(
1888 Scope::serialize_multiple(&scopes),
1889 "blob?accept=image/jpeg&accept=image/png"
1890 );
1891 }
1892
1893 #[test]
1894 fn test_serialize_multiple_roundtrip() {
1895 // Test that parse_multiple and serialize_multiple are inverses (when sorted)
1896 let original = "account:email atproto blob:image/png identity:handle repo:*";
1897 let scopes = Scope::parse_multiple(original).unwrap();
1898 let serialized = Scope::serialize_multiple(&scopes);
1899 assert_eq!(serialized, original);
1900
1901 // Test with complex scopes
1902 let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*";
1903 let scopes = Scope::parse_multiple(original).unwrap();
1904 let serialized = Scope::serialize_multiple(&scopes);
1905 // Parse again to verify it's valid
1906 let reparsed = Scope::parse_multiple(&serialized).unwrap();
1907 assert_eq!(scopes, reparsed);
1908
1909 // Test with OpenID Connect scopes
1910 let original = "email openid profile";
1911 let scopes = Scope::parse_multiple(original).unwrap();
1912 let serialized = Scope::serialize_multiple(&scopes);
1913 assert_eq!(serialized, original);
1914 }
1915
1916 #[test]
1917 fn test_remove_scope() {
1918 // Test removing a scope that exists
1919 let scopes = vec![
1920 Scope::parse("repo:*").unwrap(),
1921 Scope::Atproto,
1922 Scope::parse("account:email").unwrap(),
1923 ];
1924 let to_remove = Scope::Atproto;
1925 let result = Scope::remove_scope(&scopes, &to_remove);
1926 assert_eq!(result.len(), 2);
1927 assert!(!result.contains(&to_remove));
1928 assert!(result.contains(&Scope::parse("repo:*").unwrap()));
1929 assert!(result.contains(&Scope::parse("account:email").unwrap()));
1930
1931 // Test removing a scope that doesn't exist
1932 let scopes = vec![
1933 Scope::parse("repo:*").unwrap(),
1934 Scope::parse("account:email").unwrap(),
1935 ];
1936 let to_remove = Scope::parse("identity:handle").unwrap();
1937 let result = Scope::remove_scope(&scopes, &to_remove);
1938 assert_eq!(result.len(), 2);
1939 assert_eq!(result, scopes);
1940
1941 // Test removing from empty list
1942 let scopes: Vec<Scope> = vec![];
1943 let to_remove = Scope::Atproto;
1944 let result = Scope::remove_scope(&scopes, &to_remove);
1945 assert_eq!(result.len(), 0);
1946
1947 // Test removing all instances of a duplicate scope
1948 let scopes = vec![
1949 Scope::Atproto,
1950 Scope::parse("account:email").unwrap(),
1951 Scope::Atproto,
1952 Scope::parse("repo:*").unwrap(),
1953 Scope::Atproto,
1954 ];
1955 let to_remove = Scope::Atproto;
1956 let result = Scope::remove_scope(&scopes, &to_remove);
1957 assert_eq!(result.len(), 2);
1958 assert!(!result.contains(&to_remove));
1959 assert!(result.contains(&Scope::parse("account:email").unwrap()));
1960 assert!(result.contains(&Scope::parse("repo:*").unwrap()));
1961
1962 // Test removing complex scopes with query parameters
1963 let scopes = vec![
1964 Scope::parse("account:email?action=manage").unwrap(),
1965 Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(),
1966 Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(),
1967 ];
1968 let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); // Note: normalized order
1969 let result = Scope::remove_scope(&scopes, &to_remove);
1970 assert_eq!(result.len(), 2);
1971 assert!(!result.contains(&to_remove));
1972
1973 // Test with OpenID Connect scopes
1974 let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto];
1975 let to_remove = Scope::Profile;
1976 let result = Scope::remove_scope(&scopes, &to_remove);
1977 assert_eq!(result.len(), 3);
1978 assert!(!result.contains(&to_remove));
1979 assert!(result.contains(&Scope::OpenId));
1980 assert!(result.contains(&Scope::Email));
1981 assert!(result.contains(&Scope::Atproto));
1982
1983 // Test with transition scopes
1984 let scopes = vec![
1985 Scope::Transition(TransitionScope::Generic),
1986 Scope::Transition(TransitionScope::Email),
1987 Scope::Atproto,
1988 ];
1989 let to_remove = Scope::Transition(TransitionScope::Email);
1990 let result = Scope::remove_scope(&scopes, &to_remove);
1991 assert_eq!(result.len(), 2);
1992 assert!(!result.contains(&to_remove));
1993 assert!(result.contains(&Scope::Transition(TransitionScope::Generic)));
1994 assert!(result.contains(&Scope::Atproto));
1995
1996 // Test that only exact matches are removed
1997 let scopes = vec![
1998 Scope::parse("account:email").unwrap(),
1999 Scope::parse("account:email?action=manage").unwrap(),
2000 Scope::parse("account:repo").unwrap(),
2001 ];
2002 let to_remove = Scope::parse("account:email").unwrap();
2003 let result = Scope::remove_scope(&scopes, &to_remove);
2004 assert_eq!(result.len(), 2);
2005 assert!(!result.contains(&Scope::parse("account:email").unwrap()));
2006 assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap()));
2007 assert!(result.contains(&Scope::parse("account:repo").unwrap()));
2008 }
2009}