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