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