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