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