A better Rust ATProto crate
102
fork

Configure Feed

Select the types of activity you want to include in your feed.

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