atproto_oauth/
scopes.rs

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