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.trim().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 s.starts_with(prefix) {
322                let remainder = &s[prefix.len()..];
323                if remainder.is_empty() || remainder.starts_with(':') || remainder.starts_with('?')
324                {
325                    found_prefix = Some(*prefix);
326                    if remainder.starts_with(':') {
327                        suffix = Some(&remainder[1..]);
328                    } else if remainder.starts_with('?') {
329                        suffix = Some(remainder);
330                    } else {
331                        suffix = None;
332                    }
333                    break;
334                }
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                    && match (a.action, b.action) {
748                        (AccountAction::Manage, _) => true,
749                        (AccountAction::Read, AccountAction::Read) => true,
750                        _ => false,
751                    }
752            }
753            (Scope::Identity(a), Scope::Identity(b)) => match (a, b) {
754                (IdentityScope::All, _) => true,
755                (IdentityScope::Handle, IdentityScope::Handle) => true,
756                _ => false,
757            },
758            (Scope::Blob(a), Scope::Blob(b)) => {
759                for b_pattern in &b.accept {
760                    let mut granted = false;
761                    for a_pattern in &a.accept {
762                        if a_pattern.grants(b_pattern) {
763                            granted = true;
764                            break;
765                        }
766                    }
767                    if !granted {
768                        return false;
769                    }
770                }
771                true
772            }
773            (Scope::Repo(a), Scope::Repo(b)) => {
774                let collection_match = match (&a.collection, &b.collection) {
775                    (RepoCollection::All, _) => true,
776                    (RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => {
777                        a_nsid == b_nsid
778                    }
779                    _ => false,
780                };
781
782                if !collection_match {
783                    return false;
784                }
785
786                b.actions.is_subset(&a.actions) || a.actions.len() == 3
787            }
788            (Scope::Rpc(a), Scope::Rpc(b)) => {
789                let lxm_match = if a.lxm.contains(&RpcLexicon::All) {
790                    true
791                } else {
792                    b.lxm.iter().all(|b_lxm| match b_lxm {
793                        RpcLexicon::All => false,
794                        RpcLexicon::Nsid(_) => a.lxm.contains(b_lxm),
795                    })
796                };
797
798                let aud_match = if a.aud.contains(&RpcAudience::All) {
799                    true
800                } else {
801                    b.aud.iter().all(|b_aud| match b_aud {
802                        RpcAudience::All => false,
803                        RpcAudience::Did(_) => a.aud.contains(b_aud),
804                    })
805                };
806
807                lxm_match && aud_match
808            }
809            _ => false,
810        }
811    }
812}
813
814impl MimePattern {
815    fn grants(&self, other: &MimePattern) -> bool {
816        match (self, other) {
817            (MimePattern::All, _) => true,
818            (MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => {
819                a_type == b_type
820            }
821            (MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => {
822                b_mime.starts_with(&format!("{}/", a_type))
823            }
824            (MimePattern::Exact(a), MimePattern::Exact(b)) => a == b,
825            _ => false,
826        }
827    }
828}
829
830impl FromStr for MimePattern {
831    type Err = ParseError;
832
833    fn from_str(s: &str) -> Result<Self, Self::Err> {
834        if s == "*/*" {
835            Ok(MimePattern::All)
836        } else if s.ends_with("/*") {
837            Ok(MimePattern::TypeWildcard(s[..s.len() - 2].to_string()))
838        } else if s.contains('/') {
839            Ok(MimePattern::Exact(s.to_string()))
840        } else {
841            Err(ParseError::InvalidMimeType(s.to_string()))
842        }
843    }
844}
845
846impl FromStr for Scope {
847    type Err = ParseError;
848
849    fn from_str(s: &str) -> Result<Self, Self::Err> {
850        Self::parse(s)
851    }
852}
853
854impl fmt::Display for Scope {
855    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
856        write!(f, "{}", self.to_string_normalized())
857    }
858}
859
860/// Parse a query string into a map of keys to lists of values
861fn parse_query_string(query: &str) -> BTreeMap<String, Vec<String>> {
862    let mut params = BTreeMap::new();
863
864    for pair in query.split('&') {
865        if let Some(pos) = pair.find('=') {
866            let key = &pair[..pos];
867            let value = &pair[pos + 1..];
868            params
869                .entry(key.to_string())
870                .or_insert_with(Vec::new)
871                .push(value.to_string());
872        }
873    }
874
875    params
876}
877
878/// Error type for scope parsing
879#[derive(Debug, Clone, PartialEq, Eq)]
880pub enum ParseError {
881    /// Unknown scope prefix
882    UnknownPrefix(String),
883    /// Missing required resource
884    MissingResource,
885    /// Invalid resource type
886    InvalidResource(String),
887    /// Invalid action type
888    InvalidAction(String),
889    /// Invalid MIME type
890    InvalidMimeType(String),
891}
892
893impl fmt::Display for ParseError {
894    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
895        match self {
896            ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix),
897            ParseError::MissingResource => write!(f, "Missing required resource"),
898            ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource),
899            ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action),
900            ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime),
901        }
902    }
903}
904
905impl std::error::Error for ParseError {}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910
911    #[test]
912    fn test_account_scope_parsing() {
913        let scope = Scope::parse("account:email").unwrap();
914        assert_eq!(
915            scope,
916            Scope::Account(AccountScope {
917                resource: AccountResource::Email,
918                action: AccountAction::Read,
919            })
920        );
921
922        let scope = Scope::parse("account:repo?action=manage").unwrap();
923        assert_eq!(
924            scope,
925            Scope::Account(AccountScope {
926                resource: AccountResource::Repo,
927                action: AccountAction::Manage,
928            })
929        );
930
931        let scope = Scope::parse("account:status?action=read").unwrap();
932        assert_eq!(
933            scope,
934            Scope::Account(AccountScope {
935                resource: AccountResource::Status,
936                action: AccountAction::Read,
937            })
938        );
939    }
940
941    #[test]
942    fn test_identity_scope_parsing() {
943        let scope = Scope::parse("identity:handle").unwrap();
944        assert_eq!(scope, Scope::Identity(IdentityScope::Handle));
945
946        let scope = Scope::parse("identity:*").unwrap();
947        assert_eq!(scope, Scope::Identity(IdentityScope::All));
948    }
949
950    #[test]
951    fn test_blob_scope_parsing() {
952        let scope = Scope::parse("blob:*/*").unwrap();
953        let mut accept = BTreeSet::new();
954        accept.insert(MimePattern::All);
955        assert_eq!(scope, Scope::Blob(BlobScope { accept }));
956
957        let scope = Scope::parse("blob:image/png").unwrap();
958        let mut accept = BTreeSet::new();
959        accept.insert(MimePattern::Exact("image/png".to_string()));
960        assert_eq!(scope, Scope::Blob(BlobScope { accept }));
961
962        let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap();
963        let mut accept = BTreeSet::new();
964        accept.insert(MimePattern::Exact("image/png".to_string()));
965        accept.insert(MimePattern::Exact("image/jpeg".to_string()));
966        assert_eq!(scope, Scope::Blob(BlobScope { accept }));
967
968        let scope = Scope::parse("blob:image/*").unwrap();
969        let mut accept = BTreeSet::new();
970        accept.insert(MimePattern::TypeWildcard("image".to_string()));
971        assert_eq!(scope, Scope::Blob(BlobScope { accept }));
972    }
973
974    #[test]
975    fn test_repo_scope_parsing() {
976        let scope = Scope::parse("repo:*?action=create").unwrap();
977        let mut actions = BTreeSet::new();
978        actions.insert(RepoAction::Create);
979        assert_eq!(
980            scope,
981            Scope::Repo(RepoScope {
982                collection: RepoCollection::All,
983                actions,
984            })
985        );
986
987        let scope = Scope::parse("repo:foo.bar?action=create&action=update").unwrap();
988        let mut actions = BTreeSet::new();
989        actions.insert(RepoAction::Create);
990        actions.insert(RepoAction::Update);
991        assert_eq!(
992            scope,
993            Scope::Repo(RepoScope {
994                collection: RepoCollection::Nsid("foo.bar".to_string()),
995                actions,
996            })
997        );
998
999        let scope = Scope::parse("repo:foo.bar").unwrap();
1000        let mut actions = BTreeSet::new();
1001        actions.insert(RepoAction::Create);
1002        actions.insert(RepoAction::Update);
1003        actions.insert(RepoAction::Delete);
1004        assert_eq!(
1005            scope,
1006            Scope::Repo(RepoScope {
1007                collection: RepoCollection::Nsid("foo.bar".to_string()),
1008                actions,
1009            })
1010        );
1011    }
1012
1013    #[test]
1014    fn test_rpc_scope_parsing() {
1015        let scope = Scope::parse("rpc:*").unwrap();
1016        let mut lxm = BTreeSet::new();
1017        let mut aud = BTreeSet::new();
1018        lxm.insert(RpcLexicon::All);
1019        aud.insert(RpcAudience::All);
1020        assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1021
1022        let scope = Scope::parse("rpc:com.example.service").unwrap();
1023        let mut lxm = BTreeSet::new();
1024        let mut aud = BTreeSet::new();
1025        lxm.insert(RpcLexicon::Nsid("com.example.service".to_string()));
1026        aud.insert(RpcAudience::All);
1027        assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1028
1029        let scope = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap();
1030        let mut lxm = BTreeSet::new();
1031        let mut aud = BTreeSet::new();
1032        lxm.insert(RpcLexicon::Nsid("com.example.service".to_string()));
1033        aud.insert(RpcAudience::Did("did:example:123".to_string()));
1034        assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1035
1036        let scope =
1037            Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:example:123")
1038                .unwrap();
1039        let mut lxm = BTreeSet::new();
1040        let mut aud = BTreeSet::new();
1041        lxm.insert(RpcLexicon::Nsid("com.example.method1".to_string()));
1042        lxm.insert(RpcLexicon::Nsid("com.example.method2".to_string()));
1043        aud.insert(RpcAudience::Did("did:example:123".to_string()));
1044        assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1045    }
1046
1047    #[test]
1048    fn test_scope_normalization() {
1049        let tests = vec![
1050            ("account:email", "account:email"),
1051            ("account:email?action=read", "account:email"),
1052            ("account:email?action=manage", "account:email?action=manage"),
1053            ("blob:image/png", "blob:image/png"),
1054            (
1055                "blob?accept=image/jpeg&accept=image/png",
1056                "blob?accept=image/jpeg&accept=image/png",
1057            ),
1058            ("repo:foo.bar", "repo:foo.bar"),
1059            ("repo:foo.bar?action=create", "repo:foo.bar?action=create"),
1060            ("rpc:*", "rpc:*"),
1061        ];
1062
1063        for (input, expected) in tests {
1064            let scope = Scope::parse(input).unwrap();
1065            assert_eq!(scope.to_string_normalized(), expected);
1066        }
1067    }
1068
1069    #[test]
1070    fn test_account_scope_grants() {
1071        let manage = Scope::parse("account:email?action=manage").unwrap();
1072        let read = Scope::parse("account:email?action=read").unwrap();
1073        let other_read = Scope::parse("account:repo?action=read").unwrap();
1074
1075        assert!(manage.grants(&read));
1076        assert!(manage.grants(&manage));
1077        assert!(!read.grants(&manage));
1078        assert!(read.grants(&read));
1079        assert!(!read.grants(&other_read));
1080    }
1081
1082    #[test]
1083    fn test_identity_scope_grants() {
1084        let all = Scope::parse("identity:*").unwrap();
1085        let handle = Scope::parse("identity:handle").unwrap();
1086
1087        assert!(all.grants(&handle));
1088        assert!(all.grants(&all));
1089        assert!(!handle.grants(&all));
1090        assert!(handle.grants(&handle));
1091    }
1092
1093    #[test]
1094    fn test_blob_scope_grants() {
1095        let all = Scope::parse("blob:*/*").unwrap();
1096        let image_all = Scope::parse("blob:image/*").unwrap();
1097        let image_png = Scope::parse("blob:image/png").unwrap();
1098        let text_plain = Scope::parse("blob:text/plain").unwrap();
1099
1100        assert!(all.grants(&image_all));
1101        assert!(all.grants(&image_png));
1102        assert!(all.grants(&text_plain));
1103        assert!(image_all.grants(&image_png));
1104        assert!(!image_all.grants(&text_plain));
1105        assert!(!image_png.grants(&image_all));
1106    }
1107
1108    #[test]
1109    fn test_repo_scope_grants() {
1110        let all_all = Scope::parse("repo:*").unwrap();
1111        let all_create = Scope::parse("repo:*?action=create").unwrap();
1112        let specific_all = Scope::parse("repo:foo.bar").unwrap();
1113        let specific_create = Scope::parse("repo:foo.bar?action=create").unwrap();
1114        let other_create = Scope::parse("repo:baz.qux?action=create").unwrap();
1115
1116        assert!(all_all.grants(&all_create));
1117        assert!(all_all.grants(&specific_all));
1118        assert!(all_all.grants(&specific_create));
1119        assert!(all_create.grants(&all_create));
1120        assert!(!all_create.grants(&specific_all));
1121        assert!(specific_all.grants(&specific_create));
1122        assert!(!specific_create.grants(&specific_all));
1123        assert!(!specific_create.grants(&other_create));
1124    }
1125
1126    #[test]
1127    fn test_rpc_scope_grants() {
1128        let all = Scope::parse("rpc:*").unwrap();
1129        let specific_lxm = Scope::parse("rpc:com.example.service").unwrap();
1130        let specific_both = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap();
1131
1132        assert!(all.grants(&specific_lxm));
1133        assert!(all.grants(&specific_both));
1134        assert!(specific_lxm.grants(&specific_both));
1135        assert!(!specific_both.grants(&specific_lxm));
1136        assert!(!specific_both.grants(&all));
1137    }
1138
1139    #[test]
1140    fn test_cross_scope_grants() {
1141        let account = Scope::parse("account:email").unwrap();
1142        let identity = Scope::parse("identity:handle").unwrap();
1143
1144        assert!(!account.grants(&identity));
1145        assert!(!identity.grants(&account));
1146    }
1147
1148    #[test]
1149    fn test_parse_errors() {
1150        assert!(matches!(
1151            Scope::parse("unknown:test"),
1152            Err(ParseError::UnknownPrefix(_))
1153        ));
1154
1155        assert!(matches!(
1156            Scope::parse("account"),
1157            Err(ParseError::MissingResource)
1158        ));
1159
1160        assert!(matches!(
1161            Scope::parse("account:invalid"),
1162            Err(ParseError::InvalidResource(_))
1163        ));
1164
1165        assert!(matches!(
1166            Scope::parse("account:email?action=invalid"),
1167            Err(ParseError::InvalidAction(_))
1168        ));
1169    }
1170
1171    #[test]
1172    fn test_query_parameter_sorting() {
1173        let scope =
1174            Scope::parse("blob?accept=image/png&accept=application/pdf&accept=image/jpeg").unwrap();
1175        let normalized = scope.to_string_normalized();
1176        assert!(normalized.contains("accept=application/pdf"));
1177        assert!(normalized.contains("accept=image/jpeg"));
1178        assert!(normalized.contains("accept=image/png"));
1179        let pdf_pos = normalized.find("accept=application/pdf").unwrap();
1180        let jpeg_pos = normalized.find("accept=image/jpeg").unwrap();
1181        let png_pos = normalized.find("accept=image/png").unwrap();
1182        assert!(pdf_pos < jpeg_pos);
1183        assert!(jpeg_pos < png_pos);
1184    }
1185
1186    #[test]
1187    fn test_repo_action_wildcard() {
1188        let scope = Scope::parse("repo:foo.bar?action=*").unwrap();
1189        let mut actions = BTreeSet::new();
1190        actions.insert(RepoAction::Create);
1191        actions.insert(RepoAction::Update);
1192        actions.insert(RepoAction::Delete);
1193        assert_eq!(
1194            scope,
1195            Scope::Repo(RepoScope {
1196                collection: RepoCollection::Nsid("foo.bar".to_string()),
1197                actions,
1198            })
1199        );
1200    }
1201
1202    #[test]
1203    fn test_multiple_blob_accepts() {
1204        let scope = Scope::parse("blob?accept=image/*&accept=text/plain").unwrap();
1205        assert!(scope.grants(&Scope::parse("blob:image/png").unwrap()));
1206        assert!(scope.grants(&Scope::parse("blob:text/plain").unwrap()));
1207        assert!(!scope.grants(&Scope::parse("blob:application/json").unwrap()));
1208    }
1209
1210    #[test]
1211    fn test_rpc_default_wildcards() {
1212        let scope = Scope::parse("rpc").unwrap();
1213        let mut lxm = BTreeSet::new();
1214        let mut aud = BTreeSet::new();
1215        lxm.insert(RpcLexicon::All);
1216        aud.insert(RpcAudience::All);
1217        assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1218    }
1219
1220    #[test]
1221    fn test_atproto_scope_parsing() {
1222        let scope = Scope::parse("atproto").unwrap();
1223        assert_eq!(scope, Scope::Atproto);
1224
1225        // Atproto should not accept suffixes
1226        assert!(Scope::parse("atproto:something").is_err());
1227        assert!(Scope::parse("atproto?param=value").is_err());
1228    }
1229
1230    #[test]
1231    fn test_transition_scope_parsing() {
1232        let scope = Scope::parse("transition:generic").unwrap();
1233        assert_eq!(scope, Scope::Transition(TransitionScope::Generic));
1234
1235        let scope = Scope::parse("transition:email").unwrap();
1236        assert_eq!(scope, Scope::Transition(TransitionScope::Email));
1237
1238        // Test invalid transition types
1239        assert!(matches!(
1240            Scope::parse("transition:invalid"),
1241            Err(ParseError::InvalidResource(_))
1242        ));
1243
1244        // Test missing suffix
1245        assert!(matches!(
1246            Scope::parse("transition"),
1247            Err(ParseError::MissingResource)
1248        ));
1249
1250        // Test transition doesn't accept query parameters
1251        assert!(matches!(
1252            Scope::parse("transition:generic?param=value"),
1253            Err(ParseError::InvalidResource(_))
1254        ));
1255    }
1256
1257    #[test]
1258    fn test_atproto_scope_normalization() {
1259        let scope = Scope::parse("atproto").unwrap();
1260        assert_eq!(scope.to_string_normalized(), "atproto");
1261    }
1262
1263    #[test]
1264    fn test_transition_scope_normalization() {
1265        let tests = vec![
1266            ("transition:generic", "transition:generic"),
1267            ("transition:email", "transition:email"),
1268        ];
1269
1270        for (input, expected) in tests {
1271            let scope = Scope::parse(input).unwrap();
1272            assert_eq!(scope.to_string_normalized(), expected);
1273        }
1274    }
1275
1276    #[test]
1277    fn test_atproto_scope_grants() {
1278        let atproto = Scope::parse("atproto").unwrap();
1279        let account = Scope::parse("account:email").unwrap();
1280        let identity = Scope::parse("identity:handle").unwrap();
1281        let blob = Scope::parse("blob:image/png").unwrap();
1282        let repo = Scope::parse("repo:foo.bar").unwrap();
1283        let rpc = Scope::parse("rpc:com.example.service").unwrap();
1284        let transition_generic = Scope::parse("transition:generic").unwrap();
1285        let transition_email = Scope::parse("transition:email").unwrap();
1286
1287        // Atproto only grants itself (it's a required scope, not a permission grant)
1288        assert!(atproto.grants(&atproto));
1289        assert!(!atproto.grants(&account));
1290        assert!(!atproto.grants(&identity));
1291        assert!(!atproto.grants(&blob));
1292        assert!(!atproto.grants(&repo));
1293        assert!(!atproto.grants(&rpc));
1294        assert!(!atproto.grants(&transition_generic));
1295        assert!(!atproto.grants(&transition_email));
1296
1297        // Nothing else grants atproto
1298        assert!(!account.grants(&atproto));
1299        assert!(!identity.grants(&atproto));
1300        assert!(!blob.grants(&atproto));
1301        assert!(!repo.grants(&atproto));
1302        assert!(!rpc.grants(&atproto));
1303        assert!(!transition_generic.grants(&atproto));
1304        assert!(!transition_email.grants(&atproto));
1305    }
1306
1307    #[test]
1308    fn test_transition_scope_grants() {
1309        let transition_generic = Scope::parse("transition:generic").unwrap();
1310        let transition_email = Scope::parse("transition:email").unwrap();
1311        let account = Scope::parse("account:email").unwrap();
1312
1313        // Transition scopes only grant themselves
1314        assert!(transition_generic.grants(&transition_generic));
1315        assert!(transition_email.grants(&transition_email));
1316        assert!(!transition_generic.grants(&transition_email));
1317        assert!(!transition_email.grants(&transition_generic));
1318
1319        // Transition scopes don't grant other scope types
1320        assert!(!transition_generic.grants(&account));
1321        assert!(!transition_email.grants(&account));
1322
1323        // Other scopes don't grant transition scopes
1324        assert!(!account.grants(&transition_generic));
1325        assert!(!account.grants(&transition_email));
1326    }
1327
1328    #[test]
1329    fn test_parse_multiple() {
1330        // Test parsing multiple scopes
1331        let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
1332        assert_eq!(scopes.len(), 2);
1333        assert_eq!(scopes[0], Scope::Atproto);
1334        assert_eq!(
1335            scopes[1],
1336            Scope::Repo(RepoScope {
1337                collection: RepoCollection::All,
1338                actions: {
1339                    let mut actions = BTreeSet::new();
1340                    actions.insert(RepoAction::Create);
1341                    actions.insert(RepoAction::Update);
1342                    actions.insert(RepoAction::Delete);
1343                    actions
1344                }
1345            })
1346        );
1347
1348        // Test with more scopes
1349        let scopes = Scope::parse_multiple("account:email identity:handle blob:image/png").unwrap();
1350        assert_eq!(scopes.len(), 3);
1351        assert!(matches!(scopes[0], Scope::Account(_)));
1352        assert!(matches!(scopes[1], Scope::Identity(_)));
1353        assert!(matches!(scopes[2], Scope::Blob(_)));
1354
1355        // Test with complex scopes
1356        let scopes = Scope::parse_multiple(
1357            "account:email?action=manage repo:foo.bar?action=create transition:email",
1358        )
1359        .unwrap();
1360        assert_eq!(scopes.len(), 3);
1361
1362        // Test empty string
1363        let scopes = Scope::parse_multiple("").unwrap();
1364        assert_eq!(scopes.len(), 0);
1365
1366        // Test whitespace only
1367        let scopes = Scope::parse_multiple("   ").unwrap();
1368        assert_eq!(scopes.len(), 0);
1369
1370        // Test with extra whitespace
1371        let scopes = Scope::parse_multiple("  atproto   repo:*  ").unwrap();
1372        assert_eq!(scopes.len(), 2);
1373
1374        // Test single scope
1375        let scopes = Scope::parse_multiple("atproto").unwrap();
1376        assert_eq!(scopes.len(), 1);
1377        assert_eq!(scopes[0], Scope::Atproto);
1378
1379        // Test error propagation
1380        assert!(Scope::parse_multiple("atproto invalid:scope").is_err());
1381        assert!(Scope::parse_multiple("account:invalid repo:*").is_err());
1382    }
1383
1384    #[test]
1385    fn test_parse_multiple_reduced() {
1386        // Test repo scope reduction - wildcard grants specific
1387        let scopes = Scope::parse_multiple_reduced("atproto repo:foo.bar repo:*").unwrap();
1388        assert_eq!(scopes.len(), 2);
1389        assert!(scopes.contains(&Scope::Atproto));
1390        assert!(scopes.contains(&Scope::Repo(RepoScope {
1391            collection: RepoCollection::All,
1392            actions: {
1393                let mut actions = BTreeSet::new();
1394                actions.insert(RepoAction::Create);
1395                actions.insert(RepoAction::Update);
1396                actions.insert(RepoAction::Delete);
1397                actions
1398            }
1399        })));
1400
1401        // Test reverse order - should get same result
1402        let scopes = Scope::parse_multiple_reduced("atproto repo:* repo:foo.bar").unwrap();
1403        assert_eq!(scopes.len(), 2);
1404        assert!(scopes.contains(&Scope::Atproto));
1405        assert!(scopes.contains(&Scope::Repo(RepoScope {
1406            collection: RepoCollection::All,
1407            actions: {
1408                let mut actions = BTreeSet::new();
1409                actions.insert(RepoAction::Create);
1410                actions.insert(RepoAction::Update);
1411                actions.insert(RepoAction::Delete);
1412                actions
1413            }
1414        })));
1415
1416        // Test account scope reduction - manage grants read
1417        let scopes =
1418            Scope::parse_multiple_reduced("account:email account:email?action=manage").unwrap();
1419        assert_eq!(scopes.len(), 1);
1420        assert_eq!(
1421            scopes[0],
1422            Scope::Account(AccountScope {
1423                resource: AccountResource::Email,
1424                action: AccountAction::Manage,
1425            })
1426        );
1427
1428        // Test identity scope reduction - wildcard grants specific
1429        let scopes = Scope::parse_multiple_reduced("identity:handle identity:*").unwrap();
1430        assert_eq!(scopes.len(), 1);
1431        assert_eq!(scopes[0], Scope::Identity(IdentityScope::All));
1432
1433        // Test blob scope reduction - wildcard grants specific
1434        let scopes = Scope::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*").unwrap();
1435        assert_eq!(scopes.len(), 1);
1436        let mut accept = BTreeSet::new();
1437        accept.insert(MimePattern::All);
1438        assert_eq!(scopes[0], Scope::Blob(BlobScope { accept }));
1439
1440        // Test no reduction needed - different scope types
1441        let scopes =
1442            Scope::parse_multiple_reduced("account:email identity:handle blob:image/png").unwrap();
1443        assert_eq!(scopes.len(), 3);
1444
1445        // Test repo action reduction
1446        let scopes =
1447            Scope::parse_multiple_reduced("repo:foo.bar?action=create repo:foo.bar").unwrap();
1448        assert_eq!(scopes.len(), 1);
1449        assert_eq!(
1450            scopes[0],
1451            Scope::Repo(RepoScope {
1452                collection: RepoCollection::Nsid("foo.bar".to_string()),
1453                actions: {
1454                    let mut actions = BTreeSet::new();
1455                    actions.insert(RepoAction::Create);
1456                    actions.insert(RepoAction::Update);
1457                    actions.insert(RepoAction::Delete);
1458                    actions
1459                }
1460            })
1461        );
1462
1463        // Test RPC scope reduction
1464        let scopes = Scope::parse_multiple_reduced(
1465            "rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*",
1466        )
1467        .unwrap();
1468        assert_eq!(scopes.len(), 1);
1469        assert_eq!(
1470            scopes[0],
1471            Scope::Rpc(RpcScope {
1472                lxm: {
1473                    let mut lxm = BTreeSet::new();
1474                    lxm.insert(RpcLexicon::All);
1475                    lxm
1476                },
1477                aud: {
1478                    let mut aud = BTreeSet::new();
1479                    aud.insert(RpcAudience::All);
1480                    aud
1481                }
1482            })
1483        );
1484
1485        // Test duplicate removal
1486        let scopes = Scope::parse_multiple_reduced("atproto atproto atproto").unwrap();
1487        assert_eq!(scopes.len(), 1);
1488        assert_eq!(scopes[0], Scope::Atproto);
1489
1490        // Test transition scopes - only grant themselves
1491        let scopes = Scope::parse_multiple_reduced("transition:generic transition:email").unwrap();
1492        assert_eq!(scopes.len(), 2);
1493        assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic)));
1494        assert!(scopes.contains(&Scope::Transition(TransitionScope::Email)));
1495
1496        // Test empty input
1497        let scopes = Scope::parse_multiple_reduced("").unwrap();
1498        assert_eq!(scopes.len(), 0);
1499
1500        // Test complex scenario with multiple reductions
1501        let scopes = Scope::parse_multiple_reduced(
1502            "account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle"
1503        ).unwrap();
1504        assert_eq!(scopes.len(), 3);
1505        // Should have: account:email?action=manage, account:repo, identity:*
1506        assert!(scopes.contains(&Scope::Account(AccountScope {
1507            resource: AccountResource::Email,
1508            action: AccountAction::Manage,
1509        })));
1510        assert!(scopes.contains(&Scope::Account(AccountScope {
1511            resource: AccountResource::Repo,
1512            action: AccountAction::Read,
1513        })));
1514        assert!(scopes.contains(&Scope::Identity(IdentityScope::All)));
1515
1516        // Test that atproto doesn't grant other scopes (per recent change)
1517        let scopes = Scope::parse_multiple_reduced("atproto account:email repo:*").unwrap();
1518        assert_eq!(scopes.len(), 3);
1519        assert!(scopes.contains(&Scope::Atproto));
1520        assert!(scopes.contains(&Scope::Account(AccountScope {
1521            resource: AccountResource::Email,
1522            action: AccountAction::Read,
1523        })));
1524        assert!(scopes.contains(&Scope::Repo(RepoScope {
1525            collection: RepoCollection::All,
1526            actions: {
1527                let mut actions = BTreeSet::new();
1528                actions.insert(RepoAction::Create);
1529                actions.insert(RepoAction::Update);
1530                actions.insert(RepoAction::Delete);
1531                actions
1532            }
1533        })));
1534    }
1535
1536    #[test]
1537    fn test_openid_connect_scope_parsing() {
1538        // Test OpenID scope
1539        let scope = Scope::parse("openid").unwrap();
1540        assert_eq!(scope, Scope::OpenId);
1541
1542        // Test Profile scope
1543        let scope = Scope::parse("profile").unwrap();
1544        assert_eq!(scope, Scope::Profile);
1545
1546        // Test Email scope
1547        let scope = Scope::parse("email").unwrap();
1548        assert_eq!(scope, Scope::Email);
1549
1550        // Test that they don't accept suffixes
1551        assert!(Scope::parse("openid:something").is_err());
1552        assert!(Scope::parse("profile:something").is_err());
1553        assert!(Scope::parse("email:something").is_err());
1554
1555        // Test that they don't accept query parameters
1556        assert!(Scope::parse("openid?param=value").is_err());
1557        assert!(Scope::parse("profile?param=value").is_err());
1558        assert!(Scope::parse("email?param=value").is_err());
1559    }
1560
1561    #[test]
1562    fn test_openid_connect_scope_normalization() {
1563        let scope = Scope::parse("openid").unwrap();
1564        assert_eq!(scope.to_string_normalized(), "openid");
1565
1566        let scope = Scope::parse("profile").unwrap();
1567        assert_eq!(scope.to_string_normalized(), "profile");
1568
1569        let scope = Scope::parse("email").unwrap();
1570        assert_eq!(scope.to_string_normalized(), "email");
1571    }
1572
1573    #[test]
1574    fn test_openid_connect_scope_grants() {
1575        let openid = Scope::parse("openid").unwrap();
1576        let profile = Scope::parse("profile").unwrap();
1577        let email = Scope::parse("email").unwrap();
1578        let account = Scope::parse("account:email").unwrap();
1579
1580        // OpenID Connect scopes only grant themselves
1581        assert!(openid.grants(&openid));
1582        assert!(!openid.grants(&profile));
1583        assert!(!openid.grants(&email));
1584        assert!(!openid.grants(&account));
1585
1586        assert!(profile.grants(&profile));
1587        assert!(!profile.grants(&openid));
1588        assert!(!profile.grants(&email));
1589        assert!(!profile.grants(&account));
1590
1591        assert!(email.grants(&email));
1592        assert!(!email.grants(&openid));
1593        assert!(!email.grants(&profile));
1594        assert!(!email.grants(&account));
1595
1596        // Other scopes don't grant OpenID Connect scopes
1597        assert!(!account.grants(&openid));
1598        assert!(!account.grants(&profile));
1599        assert!(!account.grants(&email));
1600    }
1601
1602    #[test]
1603    fn test_parse_multiple_with_openid_connect() {
1604        let scopes = Scope::parse_multiple("openid profile email atproto").unwrap();
1605        assert_eq!(scopes.len(), 4);
1606        assert_eq!(scopes[0], Scope::OpenId);
1607        assert_eq!(scopes[1], Scope::Profile);
1608        assert_eq!(scopes[2], Scope::Email);
1609        assert_eq!(scopes[3], Scope::Atproto);
1610
1611        // Test with mixed scopes
1612        let scopes = Scope::parse_multiple("openid account:email profile repo:*").unwrap();
1613        assert_eq!(scopes.len(), 4);
1614        assert!(scopes.contains(&Scope::OpenId));
1615        assert!(scopes.contains(&Scope::Profile));
1616    }
1617
1618    #[test]
1619    fn test_parse_multiple_reduced_with_openid_connect() {
1620        // OpenID Connect scopes don't grant each other, so no reduction
1621        let scopes = Scope::parse_multiple_reduced("openid profile email openid").unwrap();
1622        assert_eq!(scopes.len(), 3);
1623        assert!(scopes.contains(&Scope::OpenId));
1624        assert!(scopes.contains(&Scope::Profile));
1625        assert!(scopes.contains(&Scope::Email));
1626
1627        // Mixed with other scopes
1628        let scopes = Scope::parse_multiple_reduced(
1629            "openid account:email account:email?action=manage profile",
1630        )
1631        .unwrap();
1632        assert_eq!(scopes.len(), 3);
1633        assert!(scopes.contains(&Scope::OpenId));
1634        assert!(scopes.contains(&Scope::Profile));
1635        assert!(scopes.contains(&Scope::Account(AccountScope {
1636            resource: AccountResource::Email,
1637            action: AccountAction::Manage,
1638        })));
1639    }
1640
1641    #[test]
1642    fn test_serialize_multiple() {
1643        // Test empty list
1644        let scopes: Vec<Scope> = vec![];
1645        assert_eq!(Scope::serialize_multiple(&scopes), "");
1646
1647        // Test single scope
1648        let scopes = vec![Scope::Atproto];
1649        assert_eq!(Scope::serialize_multiple(&scopes), "atproto");
1650
1651        // Test multiple scopes - should be sorted alphabetically
1652        let scopes = vec![
1653            Scope::parse("repo:*").unwrap(),
1654            Scope::Atproto,
1655            Scope::parse("account:email").unwrap(),
1656        ];
1657        assert_eq!(
1658            Scope::serialize_multiple(&scopes),
1659            "account:email atproto repo:*"
1660        );
1661
1662        // Test that sorting is consistent regardless of input order
1663        let scopes = vec![
1664            Scope::parse("identity:handle").unwrap(),
1665            Scope::parse("blob:image/png").unwrap(),
1666            Scope::parse("account:repo?action=manage").unwrap(),
1667        ];
1668        assert_eq!(
1669            Scope::serialize_multiple(&scopes),
1670            "account:repo?action=manage blob:image/png identity:handle"
1671        );
1672
1673        // Test with OpenID Connect scopes
1674        let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto];
1675        assert_eq!(
1676            Scope::serialize_multiple(&scopes),
1677            "atproto email openid profile"
1678        );
1679
1680        // Test with complex scopes including query parameters
1681        let scopes = vec![
1682            Scope::parse("rpc:com.example.service?aud=did:example:123&lxm=com.example.method")
1683                .unwrap(),
1684            Scope::parse("repo:foo.bar?action=create&action=update").unwrap(),
1685            Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(),
1686        ];
1687        let result = Scope::serialize_multiple(&scopes);
1688        // The result should be sorted alphabetically
1689        // Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..."
1690        assert!(result.starts_with("blob:"));
1691        assert!(result.contains(" repo:"));
1692        assert!(result.contains("rpc?aud=did:example:123&lxm=com.example.service"));
1693
1694        // Test with transition scopes
1695        let scopes = vec![
1696            Scope::Transition(TransitionScope::Email),
1697            Scope::Transition(TransitionScope::Generic),
1698            Scope::Atproto,
1699        ];
1700        assert_eq!(
1701            Scope::serialize_multiple(&scopes),
1702            "atproto transition:email transition:generic"
1703        );
1704
1705        // Test duplicates - they remain in the output (caller's responsibility to dedupe if needed)
1706        let scopes = vec![
1707            Scope::Atproto,
1708            Scope::Atproto,
1709            Scope::parse("account:email").unwrap(),
1710        ];
1711        assert_eq!(
1712            Scope::serialize_multiple(&scopes),
1713            "account:email atproto atproto"
1714        );
1715
1716        // Test normalization is preserved in serialization
1717        let scopes = vec![Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap()];
1718        // Should normalize query parameters alphabetically
1719        assert_eq!(
1720            Scope::serialize_multiple(&scopes),
1721            "blob?accept=image/jpeg&accept=image/png"
1722        );
1723    }
1724
1725    #[test]
1726    fn test_serialize_multiple_roundtrip() {
1727        // Test that parse_multiple and serialize_multiple are inverses (when sorted)
1728        let original = "account:email atproto blob:image/png identity:handle repo:*";
1729        let scopes = Scope::parse_multiple(original).unwrap();
1730        let serialized = Scope::serialize_multiple(&scopes);
1731        assert_eq!(serialized, original);
1732
1733        // Test with complex scopes
1734        let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*";
1735        let scopes = Scope::parse_multiple(original).unwrap();
1736        let serialized = Scope::serialize_multiple(&scopes);
1737        // Parse again to verify it's valid
1738        let reparsed = Scope::parse_multiple(&serialized).unwrap();
1739        assert_eq!(scopes, reparsed);
1740
1741        // Test with OpenID Connect scopes
1742        let original = "email openid profile";
1743        let scopes = Scope::parse_multiple(original).unwrap();
1744        let serialized = Scope::serialize_multiple(&scopes);
1745        assert_eq!(serialized, original);
1746    }
1747
1748    #[test]
1749    fn test_remove_scope() {
1750        // Test removing a scope that exists
1751        let scopes = vec![
1752            Scope::parse("repo:*").unwrap(),
1753            Scope::Atproto,
1754            Scope::parse("account:email").unwrap(),
1755        ];
1756        let to_remove = Scope::Atproto;
1757        let result = Scope::remove_scope(&scopes, &to_remove);
1758        assert_eq!(result.len(), 2);
1759        assert!(!result.contains(&to_remove));
1760        assert!(result.contains(&Scope::parse("repo:*").unwrap()));
1761        assert!(result.contains(&Scope::parse("account:email").unwrap()));
1762
1763        // Test removing a scope that doesn't exist
1764        let scopes = vec![
1765            Scope::parse("repo:*").unwrap(),
1766            Scope::parse("account:email").unwrap(),
1767        ];
1768        let to_remove = Scope::parse("identity:handle").unwrap();
1769        let result = Scope::remove_scope(&scopes, &to_remove);
1770        assert_eq!(result.len(), 2);
1771        assert_eq!(result, scopes);
1772
1773        // Test removing from empty list
1774        let scopes: Vec<Scope> = vec![];
1775        let to_remove = Scope::Atproto;
1776        let result = Scope::remove_scope(&scopes, &to_remove);
1777        assert_eq!(result.len(), 0);
1778
1779        // Test removing all instances of a duplicate scope
1780        let scopes = vec![
1781            Scope::Atproto,
1782            Scope::parse("account:email").unwrap(),
1783            Scope::Atproto,
1784            Scope::parse("repo:*").unwrap(),
1785            Scope::Atproto,
1786        ];
1787        let to_remove = Scope::Atproto;
1788        let result = Scope::remove_scope(&scopes, &to_remove);
1789        assert_eq!(result.len(), 2);
1790        assert!(!result.contains(&to_remove));
1791        assert!(result.contains(&Scope::parse("account:email").unwrap()));
1792        assert!(result.contains(&Scope::parse("repo:*").unwrap()));
1793
1794        // Test removing complex scopes with query parameters
1795        let scopes = vec![
1796            Scope::parse("account:email?action=manage").unwrap(),
1797            Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(),
1798            Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(),
1799        ];
1800        let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); // Note: normalized order
1801        let result = Scope::remove_scope(&scopes, &to_remove);
1802        assert_eq!(result.len(), 2);
1803        assert!(!result.contains(&to_remove));
1804
1805        // Test with OpenID Connect scopes
1806        let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto];
1807        let to_remove = Scope::Profile;
1808        let result = Scope::remove_scope(&scopes, &to_remove);
1809        assert_eq!(result.len(), 3);
1810        assert!(!result.contains(&to_remove));
1811        assert!(result.contains(&Scope::OpenId));
1812        assert!(result.contains(&Scope::Email));
1813        assert!(result.contains(&Scope::Atproto));
1814
1815        // Test with transition scopes
1816        let scopes = vec![
1817            Scope::Transition(TransitionScope::Generic),
1818            Scope::Transition(TransitionScope::Email),
1819            Scope::Atproto,
1820        ];
1821        let to_remove = Scope::Transition(TransitionScope::Email);
1822        let result = Scope::remove_scope(&scopes, &to_remove);
1823        assert_eq!(result.len(), 2);
1824        assert!(!result.contains(&to_remove));
1825        assert!(result.contains(&Scope::Transition(TransitionScope::Generic)));
1826        assert!(result.contains(&Scope::Atproto));
1827
1828        // Test that only exact matches are removed
1829        let scopes = vec![
1830            Scope::parse("account:email").unwrap(),
1831            Scope::parse("account:email?action=manage").unwrap(),
1832            Scope::parse("account:repo").unwrap(),
1833        ];
1834        let to_remove = Scope::parse("account:email").unwrap();
1835        let result = Scope::remove_scope(&scopes, &to_remove);
1836        assert_eq!(result.len(), 2);
1837        assert!(!result.contains(&Scope::parse("account:email").unwrap()));
1838        assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap()));
1839        assert!(result.contains(&Scope::parse("account:repo").unwrap()));
1840    }
1841}