A better Rust ATProto crate
1

Configure Feed

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

at main 174 kB View raw
1//! AT Protocol OAuth scopes 2//! 3//! Originally derived from <https://tangled.org/nickgerakines.me/atproto-crates/raw/main/crates/atproto-oauth/src/scopes.rs>, since substantially modified. 4//! 5//! This module provides comprehensive support for AT Protocol OAuth scopes, 6//! including parsing, serialization, normalization, programmatic construction, 7//! and permission checking. 8//! 9//! See the AT Protocol OAuth spec's 10//! [authorization scopes](https://atproto.com/specs/oauth#authorization-scopes) 11//! section for the profile-level rules, including the required `atproto` marker 12//! scope and current 13//! [transitional scopes](https://atproto.com/specs/oauth#transitional-scopes). 14//! The AT Protocol docs also provide an interactive 15//! [scope builder](https://atproto.com/guides/scope-builder) for choosing 16//! appropriate scopes before translating them into [`Scope`] constructors or a 17//! [`ScopesBuilder`] chain. 18//! 19//! Scopes in AT Protocol follow a prefix-based format with optional query parameters: 20//! - `account`: Access to account information (email, repo, status) 21//! - `identity`: Access to identity information (handle) 22//! - `blob`: Access to blob operations with mime type constraints 23//! - `repo`: Repository operations with collection and action constraints 24//! - `rpc`: RPC method access with lexicon and audience constraints 25//! - `include`: Reference a permission-set NSID, optionally with an audience 26//! - `atproto`: Required scope to indicate that other AT Protocol scopes will be used 27//! - `transition`: Migration operations (generic, email, or chat.bsky) 28//! 29//! Standard OpenID Connect scopes (no suffixes or query parameters): 30//! - `openid`: Required for OpenID Connect authentication 31//! - `profile`: Access to user profile information 32//! - `email`: Access to user email address 33//! 34//! Programmatic construction is available through [`Scope`] constructors, 35//! [`Scopes::from_scopes`], and [`ScopesBuilder`]: 36//! 37//! ```rust 38//! use jacquard_oauth::scopes::{Scope, Scopes}; 39//! 40//! let scopes = Scopes::from_scopes([ 41//! Scope::atproto(), 42//! Scope::transition_generic(), 43//! Scope::rpc("app.bsky.feed.getTimeline")?, 44//! Scope::repo_create("app.bsky.feed.post")?, 45//! ])?; 46//! 47//! assert_eq!( 48//! scopes.to_normalized_string(), 49//! "atproto repo:app.bsky.feed.post?action=create rpc:app.bsky.feed.getTimeline transition:generic" 50//! ); 51//! # Ok::<(), jacquard_oauth::scopes::ParseError>(()) 52//! ``` 53//! 54//! For fluent construction: 55//! 56//! ```rust 57//! use jacquard_oauth::scopes::Scopes; 58//! 59//! let scopes = Scopes::builder() 60//! .atproto() 61//! .transition_generic() 62//! .rpc("app.bsky.feed.getTimeline")? 63//! .repo_create("app.bsky.feed.post")? 64//! .build()?; 65//! 66//! assert!(scopes.grants(&jacquard_oauth::scopes::Scope::atproto())); 67//! # Ok::<(), jacquard_oauth::scopes::ParseError>(()) 68//! ``` 69 70use std::collections::{BTreeMap, BTreeSet}; 71use std::fmt; 72use std::marker::PhantomData; 73use std::str::FromStr; 74 75use jacquard_common::bos::{BosStr, DefaultStr}; 76use jacquard_common::deps::fluent_uri::pct_enc::{ 77 EStr, EString, 78 encoder::{Query, Query as EncQuery}, 79}; 80use jacquard_common::types::collection::Collection; 81use jacquard_common::types::did_service::validate_did_service; 82use jacquard_common::types::nsid::Nsid; 83use jacquard_common::types::string::{AtStrError, DidService}; 84use jacquard_common::xrpc::XrpcRequest; 85use jacquard_common::{BorrowOrShare, Bos, FromStaticStr, IntoStatic}; 86use serde::de::{Error as DeError, Visitor}; 87use serde::{Deserialize, Serialize}; 88use smallvec::SmallVec; 89use smol_str::{SmolStr, SmolStrBuilder, ToSmolStr, format_smolstr}; 90 91/// Represents an AT Protocol OAuth scope 92#[derive(Debug, Clone, PartialEq, Eq, Hash)] 93pub enum Scope<S: BosStr = DefaultStr> { 94 /// Account scope for accessing account information 95 Account(AccountScope), 96 /// Identity scope for accessing identity information 97 Identity(IdentityScope), 98 /// Blob scope for blob operations with mime type constraints 99 Blob(BlobScope<S>), 100 /// Repository scope for collection operations 101 Repo(RepoScope<S>), 102 /// RPC scope for method access 103 Rpc(RpcScope<S>), 104 /// AT Protocol scope - required to indicate that other AT Protocol scopes will be used 105 Atproto, 106 /// Transition scope for migration operations 107 Transition(TransitionScope), 108 /// Include scope referencing a permission set 109 Include(IncludeScope<S>), 110 /// OpenID Connect scope - required for OpenID Connect authentication 111 OpenId, 112 /// Profile scope - access to user profile information 113 Profile, 114 /// Email scope - access to user email address 115 Email, 116} 117 118impl<S: BosStr + Ord> Serialize for Scope<S> { 119 fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error> 120 where 121 Ser: serde::Serializer, 122 { 123 serializer.serialize_str(&self.to_string_normalized()) 124 } 125} 126 127impl<'de, S> Deserialize<'de> for Scope<S> 128where 129 S: BosStr + Ord + Deserialize<'de> + FromStr, 130 <S as FromStr>::Err: core::fmt::Debug, 131{ 132 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 133 where 134 D: serde::Deserializer<'de>, 135 { 136 struct ScopeVisitor<St: BosStr + Ord + FromStr>(PhantomData<St>); 137 138 impl<St: BosStr + Ord + FromStr> Visitor<'_> for ScopeVisitor<St> 139 where 140 <St as FromStr>::Err: core::fmt::Debug, 141 { 142 type Value = Scope<St>; 143 144 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 145 write!(formatter, "a scope string") 146 } 147 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> 148 where 149 E: serde::de::Error, 150 { 151 Scope::parse(v).map_err(|e| serde::de::Error::custom(format!("{:?}", e))) 152 } 153 } 154 deserializer 155 .deserialize_str(ScopeVisitor(PhantomData)) 156 .map(|scope| scope) 157 } 158} 159 160impl<S: BosStr + Ord + IntoStatic> IntoStatic for Scope<S> 161where 162 S::Output: BosStr + Ord, 163{ 164 type Output = Scope<S::Output>; 165 166 fn into_static(self) -> Self::Output { 167 match self { 168 Scope::Account(scope) => Scope::Account(scope), 169 Scope::Identity(scope) => Scope::Identity(scope), 170 Scope::Blob(scope) => Scope::Blob(scope.into_static()), 171 Scope::Repo(scope) => Scope::Repo(scope.into_static()), 172 Scope::Rpc(scope) => Scope::Rpc(scope.into_static()), 173 Scope::Atproto => Scope::Atproto, 174 Scope::Transition(scope) => Scope::Transition(scope), 175 Scope::Include(scope) => Scope::Include(scope.into_static()), 176 Scope::OpenId => Scope::OpenId, 177 Scope::Profile => Scope::Profile, 178 Scope::Email => Scope::Email, 179 } 180 } 181} 182 183impl Scope<SmolStr> { 184 /// Construct the required AT Protocol marker scope. 185 pub fn atproto() -> Self { 186 Scope::Atproto 187 } 188 189 /// Construct the OpenID Connect `openid` scope. 190 pub fn openid() -> Self { 191 Scope::OpenId 192 } 193 194 /// Construct the OpenID Connect `profile` scope. 195 pub fn profile() -> Self { 196 Scope::Profile 197 } 198 199 /// Construct the OpenID Connect `email` scope. 200 pub fn email() -> Self { 201 Scope::Email 202 } 203 204 /// Construct `identity:handle`. 205 pub fn identity_handle() -> Self { 206 Scope::Identity(IdentityScope::Handle) 207 } 208 209 /// Construct `identity:*`. 210 pub fn identity_all() -> Self { 211 Scope::Identity(IdentityScope::All) 212 } 213 214 /// Construct `transition:generic`. 215 pub fn transition_generic() -> Self { 216 Scope::Transition(TransitionScope::Generic) 217 } 218 219 /// Construct `transition:email`. 220 pub fn transition_email() -> Self { 221 Scope::Transition(TransitionScope::Email) 222 } 223 224 /// Construct `transition:chat.bsky`. 225 pub fn transition_chat_bsky() -> Self { 226 Scope::Transition(TransitionScope::ChatBsky) 227 } 228 229 /// Construct `account:email`. 230 pub fn account_email_read() -> Self { 231 account_scope(AccountResource::Email, AccountAction::Read) 232 } 233 234 /// Construct `account:email?action=manage`. 235 pub fn account_email_manage() -> Self { 236 account_scope(AccountResource::Email, AccountAction::Manage) 237 } 238 239 /// Construct `account:repo`. 240 pub fn account_repo_read() -> Self { 241 account_scope(AccountResource::Repo, AccountAction::Read) 242 } 243 244 /// Construct `account:repo?action=manage`. 245 pub fn account_repo_manage() -> Self { 246 account_scope(AccountResource::Repo, AccountAction::Manage) 247 } 248 249 /// Construct `account:status`. 250 pub fn account_status_read() -> Self { 251 account_scope(AccountResource::Status, AccountAction::Read) 252 } 253 254 /// Construct `account:status?action=manage`. 255 pub fn account_status_manage() -> Self { 256 account_scope(AccountResource::Status, AccountAction::Manage) 257 } 258 259 /// Construct `repo:*` with all repository actions. 260 pub fn repo_all() -> Self { 261 Scope::repo_nsid_actions(None, all_repo_actions()) 262 } 263 264 /// Construct a repo scope for all actions on one collection. 265 pub fn repo_collection(collection: impl AsRef<str>) -> Result<Self, ParseError> { 266 Ok(Scope::repo_nsid_actions( 267 Some(Nsid::new_owned(collection)?), 268 all_repo_actions(), 269 )) 270 } 271 272 /// Construct a repo scope granting create on one collection. 273 pub fn repo_create(collection: impl AsRef<str>) -> Result<Self, ParseError> { 274 Ok(Scope::repo_nsid_actions( 275 Some(Nsid::new_owned(collection)?), 276 [RepoAction::Create], 277 )) 278 } 279 280 /// Construct a repo scope granting update on one collection. 281 pub fn repo_update(collection: impl AsRef<str>) -> Result<Self, ParseError> { 282 Ok(Scope::repo_nsid_actions( 283 Some(Nsid::new_owned(collection)?), 284 [RepoAction::Update], 285 )) 286 } 287 288 /// Construct a repo scope granting delete on one collection. 289 pub fn repo_delete(collection: impl AsRef<str>) -> Result<Self, ParseError> { 290 Ok(Scope::repo_nsid_actions( 291 Some(Nsid::new_owned(collection)?), 292 [RepoAction::Delete], 293 )) 294 } 295 296 /// Construct `rpc:*`. 297 pub fn rpc_all() -> Self { 298 let mut lxm = BTreeSet::new(); 299 lxm.insert(RpcLexicon::All); 300 let mut aud = BTreeSet::new(); 301 aud.insert(RpcAudience::All); 302 Scope::Rpc(RpcScope { lxm, aud }) 303 } 304 305 /// Construct an RPC scope for one XRPC method NSID and all audiences. 306 pub fn rpc(lxm: impl AsRef<str>) -> Result<Self, ParseError> { 307 Ok(Scope::rpc_nsid(Nsid::new_owned(lxm)?)) 308 } 309 310 /// Construct an RPC scope for one XRPC method NSID and one DID audience. 311 pub fn rpc_aud(lxm: impl AsRef<str>, aud: impl AsRef<str>) -> Result<Self, ParseError> { 312 Ok(Scope::rpc_nsid_aud( 313 Nsid::new_owned(lxm)?, 314 DidService::new_owned(aud)?, 315 )) 316 } 317 318 /// Construct an RPC scope from an XRPC request type. 319 pub fn rpc_request<R: XrpcRequest>() -> Self { 320 Scope::rpc_nsid(unsafe { Nsid::unchecked(SmolStr::new_static(R::NSID)) }) 321 } 322 323 /// Construct an RPC scope from an XRPC request type and one DID audience. 324 pub fn rpc_request_aud<R: XrpcRequest>(aud: impl AsRef<str>) -> Result<Self, ParseError> { 325 Ok(Scope::rpc_nsid_aud( 326 unsafe { Nsid::unchecked(SmolStr::new_static(R::NSID)) }, 327 DidService::new_owned(aud)?, 328 )) 329 } 330 331 /// Construct an `include:<nsid>` permission-set scope. 332 pub fn include(nsid: impl AsRef<str>) -> Result<Self, ParseError> { 333 Ok(Scope::include_nsid(Nsid::new_owned(nsid)?, None)) 334 } 335 336 /// Construct an `include:<nsid>?aud=<audience>` permission-set scope. 337 pub fn include_aud(nsid: impl AsRef<str>, aud: impl AsRef<str>) -> Result<Self, ParseError> { 338 let audience = normalize_include_audience(aud.as_ref())?; 339 Ok(Scope::include_nsid(Nsid::new_owned(nsid)?, Some(audience))) 340 } 341 342 /// Construct a repo scope granting create for a record collection type. 343 pub fn repo_create_record<C: Collection>() -> Self { 344 Scope::repo_nsid_actions(Some(collection_nsid::<C>()), [RepoAction::Create]) 345 } 346 347 /// Construct a repo scope granting update for a record collection type. 348 pub fn repo_update_record<C: Collection>() -> Self { 349 Scope::repo_nsid_actions(Some(collection_nsid::<C>()), [RepoAction::Update]) 350 } 351 352 /// Construct a repo scope granting delete for a record collection type. 353 pub fn repo_delete_record<C: Collection>() -> Self { 354 Scope::repo_nsid_actions(Some(collection_nsid::<C>()), [RepoAction::Delete]) 355 } 356 357 /// Construct a repo scope granting all repo actions for a record collection type. 358 pub fn repo_all_record<C: Collection>() -> Self { 359 Scope::repo_nsid_actions(Some(collection_nsid::<C>()), all_repo_actions()) 360 } 361} 362 363impl<S: BosStr + Ord> Scope<S> { 364 /// Construct a repo scope for a typed collection NSID and explicit actions. 365 pub fn repo_nsid(collection: Nsid<S>, actions: impl IntoIterator<Item = RepoAction>) -> Self { 366 Self::repo_nsid_actions(Some(collection), actions) 367 } 368 369 fn repo_nsid_actions( 370 collection: Option<Nsid<S>>, 371 actions: impl IntoIterator<Item = RepoAction>, 372 ) -> Self { 373 let mut actions: BTreeSet<_> = actions.into_iter().collect(); 374 if actions.is_empty() { 375 actions.extend(all_repo_actions()); 376 } 377 378 Scope::Repo(RepoScope { 379 collection: collection.map_or(RepoCollection::All, RepoCollection::Nsid), 380 actions, 381 }) 382 } 383 384 /// Construct an RPC scope for a typed XRPC method NSID and all audiences. 385 pub fn rpc_nsid(lxm: Nsid<S>) -> Self { 386 let mut lxm_set = BTreeSet::new(); 387 lxm_set.insert(RpcLexicon::Nsid(lxm)); 388 let mut aud = BTreeSet::new(); 389 aud.insert(RpcAudience::All); 390 Scope::Rpc(RpcScope { lxm: lxm_set, aud }) 391 } 392 393 /// Construct an RPC scope for a typed XRPC method NSID and typed DID audience. 394 pub fn rpc_nsid_aud(lxm: Nsid<S>, aud: DidService<S>) -> Self { 395 let mut lxm_set = BTreeSet::new(); 396 lxm_set.insert(RpcLexicon::Nsid(lxm)); 397 let mut aud_set = BTreeSet::new(); 398 aud_set.insert(RpcAudience::Did(aud)); 399 Scope::Rpc(RpcScope { 400 lxm: lxm_set, 401 aud: aud_set, 402 }) 403 } 404 405 /// Construct an include permission-set scope from a typed NSID and optional audience string. 406 pub fn include_nsid(nsid: Nsid<S>, audience: Option<S>) -> Self { 407 Scope::Include(IncludeScope { nsid, audience }) 408 } 409} 410 411fn account_scope(resource: AccountResource, action: AccountAction) -> Scope<SmolStr> { 412 Scope::Account(AccountScope { resource, action }) 413} 414 415fn all_repo_actions() -> [RepoAction; 3] { 416 [RepoAction::Create, RepoAction::Update, RepoAction::Delete] 417} 418 419fn collection_nsid<C: Collection>() -> Nsid<SmolStr> { 420 unsafe { Nsid::unchecked(SmolStr::new_static(C::NSID)) } 421} 422 423fn normalize_include_audience(audience: &str) -> Result<SmolStr, ParseError> { 424 if audience.contains('%') { 425 let estr = EStr::<Query>::new(audience).ok_or_else(|| { 426 ParseError::InvalidResource( 427 "include audience has invalid percent-encoding".to_smolstr(), 428 ) 429 })?; 430 let decoded = estr.decode().to_string().map_err(|_| { 431 ParseError::InvalidResource( 432 "include audience contains invalid UTF-8 sequence".to_smolstr(), 433 ) 434 })?; 435 validate_include_audience(&decoded)?; 436 Ok(SmolStr::new(decoded)) 437 } else { 438 validate_include_audience(audience)?; 439 Ok(SmolStr::new(audience)) 440 } 441} 442 443fn validate_include_audience(audience: &str) -> Result<(), ParseError> { 444 Ok(validate_did_service(audience)?) 445} 446 447/// Account scope attributes 448#[derive(Debug, Clone, PartialEq, Eq, Hash)] 449pub struct AccountScope { 450 /// The account resource type 451 pub resource: AccountResource, 452 /// The action permission level 453 pub action: AccountAction, 454} 455 456// Re-export from common to avoid duplication and allow use in permission set types 457pub use jacquard_common::types::scope_primitives::{AccountAction, AccountResource, RepoAction}; 458 459/// Identity scope attributes 460#[derive(Debug, Clone, PartialEq, Eq, Hash)] 461pub enum IdentityScope { 462 /// Handle access 463 Handle, 464 /// All identity access (wildcard) 465 All, 466} 467 468/// Transition scope types 469#[derive(Debug, Clone, PartialEq, Eq, Hash)] 470pub enum TransitionScope { 471 /// Generic transition operations 472 Generic, 473 /// Email transition operations 474 Email, 475 /// Chat transition scope for chat.bsky operations. 476 ChatBsky, 477} 478 479/// Include scope referencing a permission set NSID with optional audience. 480/// 481/// Represents `include:<nsid>[?aud=<did>]` scopes. The audience is a plain 482/// validated string - a DID optionally followed by `#fragment`. Stored in 483/// decoded form; `#` is percent-encoded as `%23` on serialisation. 484#[derive(Debug, Clone, PartialEq, Eq, Hash)] 485pub struct IncludeScope<S: BosStr = DefaultStr> { 486 /// The permission set NSID. 487 pub nsid: Nsid<S>, 488 /// Optional audience (decoded form). A DID optionally with a `#fragment`. 489 pub audience: Option<S>, 490} 491 492impl<S: BosStr> IncludeScope<S> { 493 /// Convert to an `IncludeScope` with a different backing type. 494 pub fn convert<B: Bos<str> + From<S> + AsRef<str> + FromStaticStr>(self) -> IncludeScope<B> { 495 IncludeScope { 496 nsid: self.nsid.convert(), 497 audience: self.audience.map(Into::into), 498 } 499 } 500} 501 502impl<S: BosStr + IntoStatic> IntoStatic for IncludeScope<S> 503where 504 S::Output: BosStr, 505{ 506 type Output = IncludeScope<S::Output>; 507 508 fn into_static(self) -> Self::Output { 509 IncludeScope { 510 nsid: self.nsid.into_static(), 511 audience: self.audience.map(|s| s.into_static()), 512 } 513 } 514} 515 516/// Blob scope with mime type constraints 517#[derive(Debug, Clone, PartialEq, Eq, Hash)] 518pub struct BlobScope<S: BosStr = DefaultStr> { 519 /// Accepted mime types 520 pub accept: BTreeSet<MimePattern<S>>, 521} 522 523impl<S: BosStr + AsRef<str> + Ord> BlobScope<S> { 524 /// Convert to a `BlobScope` with a different backing type. 525 pub fn convert<B: Bos<str> + From<S> + AsRef<str> + FromStaticStr + Ord>(self) -> BlobScope<B> { 526 BlobScope { 527 accept: self.accept.into_iter().map(|p| p.convert()).collect(), 528 } 529 } 530} 531 532impl<S: BosStr + IntoStatic> IntoStatic for BlobScope<S> 533where 534 S::Output: BosStr, 535 MimePattern<S::Output>: Ord, 536{ 537 type Output = BlobScope<S::Output>; 538 539 fn into_static(self) -> Self::Output { 540 BlobScope { 541 accept: self.accept.into_iter().map(|p| p.into_static()).collect(), 542 } 543 } 544} 545 546/// The kind of MIME pattern, without carrying string data. 547/// Used by validate_mime_pattern() to return the discriminant. 548#[derive(Debug, Clone, Copy, PartialEq, Eq)] 549pub enum MimePatternKind { 550 /// Matches any MIME type. 551 All, 552 /// Matches any subtype of a given type (e.g., `image/*`). 553 TypeWildcard, 554 /// Matches an exact MIME type (e.g., `image/png`). 555 Exact, 556} 557 558/// Validate a MIME pattern string without allocating. 559/// 560/// Returns the pattern kind. Valid patterns: 561/// - `*/*` -> `MimePatternKind::All` 562/// - `<type>/*` (e.g., `image/*`) -> `MimePatternKind::TypeWildcard` 563/// - `<type>/<subtype>` (e.g., `image/png`) -> `MimePatternKind::Exact` 564pub(crate) fn validate_mime_pattern(s: &str) -> Result<MimePatternKind, ParseError> { 565 if s == "*/*" { 566 Ok(MimePatternKind::All) 567 } else if let Some(slash) = s.find('/') { 568 let type_part = &s[..slash]; 569 let subtype_part = &s[slash + 1..]; 570 if type_part.is_empty() || subtype_part.is_empty() { 571 return Err(ParseError::InvalidMimeType(s.to_smolstr())); 572 } 573 if subtype_part == "*" { 574 Ok(MimePatternKind::TypeWildcard) 575 } else { 576 Ok(MimePatternKind::Exact) 577 } 578 } else { 579 Err(ParseError::InvalidMimeType(s.to_smolstr())) 580 } 581} 582 583/// MIME type pattern for blob scope 584#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 585pub enum MimePattern<S: BosStr = DefaultStr> { 586 /// Match all types 587 All, 588 /// Match all subtypes of a type (e.g., "image/*") 589 TypeWildcard(S), 590 /// Exact mime type match 591 Exact(S), 592} 593 594impl<S: BosStr> MimePattern<S> { 595 /// Convert to a `MimePattern` with a different backing type. 596 pub fn convert<B: Bos<str> + From<S> + AsRef<str> + FromStaticStr>(self) -> MimePattern<B> { 597 match self { 598 MimePattern::All => MimePattern::All, 599 MimePattern::TypeWildcard(s) => MimePattern::TypeWildcard(s.into()), 600 MimePattern::Exact(s) => MimePattern::Exact(s.into()), 601 } 602 } 603 604 /// Construct a MimePattern without validation. 605 /// 606 /// # Safety 607 /// 608 /// The caller must ensure `s` is a valid MIME pattern string 609 /// and `kind` matches the pattern. `MimePattern`'s API assumes 610 /// the invariant holds. Violating it will produce incorrect 611 /// results from downstream operations. 612 pub unsafe fn unchecked(s: S, kind: MimePatternKind) -> Self { 613 match kind { 614 MimePatternKind::All => MimePattern::All, 615 MimePatternKind::TypeWildcard => MimePattern::TypeWildcard(s), 616 MimePatternKind::Exact => MimePattern::Exact(s), 617 } 618 } 619} 620 621impl<S: BosStr + IntoStatic> IntoStatic for MimePattern<S> 622where 623 S::Output: BosStr, 624{ 625 type Output = MimePattern<S::Output>; 626 627 fn into_static(self) -> Self::Output { 628 match self { 629 MimePattern::All => MimePattern::All, 630 MimePattern::TypeWildcard(s) => MimePattern::TypeWildcard(s.into_static()), 631 MimePattern::Exact(s) => MimePattern::Exact(s.into_static()), 632 } 633 } 634} 635 636/// Repository scope with collection and action constraints 637#[derive(Debug, Clone, PartialEq, Eq, Hash)] 638pub struct RepoScope<S: BosStr = DefaultStr> { 639 /// Collection NSID or wildcard 640 pub collection: RepoCollection<S>, 641 /// Allowed actions 642 pub actions: BTreeSet<RepoAction>, 643} 644 645impl<S: BosStr + Ord> RepoScope<S> { 646 /// Convert to a `RepoScope` with a different backing type. 647 pub fn convert<B: Bos<str> + From<S> + AsRef<str> + FromStaticStr + Ord>(self) -> RepoScope<B> { 648 RepoScope { 649 collection: self.collection.convert(), 650 actions: self.actions, 651 } 652 } 653} 654 655impl<S: BosStr + IntoStatic> IntoStatic for RepoScope<S> 656where 657 S::Output: BosStr, 658{ 659 type Output = RepoScope<S::Output>; 660 661 fn into_static(self) -> Self::Output { 662 RepoScope { 663 collection: self.collection.into_static(), 664 actions: self.actions, 665 } 666 } 667} 668 669/// Repository collection identifier 670#[derive(Debug, Clone, PartialEq, Eq, Hash)] 671pub enum RepoCollection<S: BosStr = DefaultStr> { 672 /// All collections (wildcard) 673 All, 674 /// Specific collection NSID 675 Nsid(Nsid<S>), 676} 677 678impl<S: BosStr> RepoCollection<S> { 679 /// Convert to an `Nsid` with a different backing type. 680 pub fn convert<B: Bos<str> + From<S> + AsRef<str> + FromStaticStr>(self) -> RepoCollection<B> { 681 match self { 682 RepoCollection::All => RepoCollection::All, 683 RepoCollection::Nsid(nsid) => RepoCollection::Nsid(nsid.convert()), 684 } 685 } 686} 687 688impl<S: BosStr + IntoStatic> IntoStatic for RepoCollection<S> 689where 690 S::Output: BosStr, 691{ 692 type Output = RepoCollection<S::Output>; 693 694 fn into_static(self) -> Self::Output { 695 match self { 696 RepoCollection::All => RepoCollection::All, 697 RepoCollection::Nsid(nsid) => RepoCollection::Nsid(nsid.into_static()), 698 } 699 } 700} 701 702/// RPC scope with lexicon method and audience constraints 703#[derive(Debug, Clone, PartialEq, Eq, Hash)] 704pub struct RpcScope<S: BosStr = DefaultStr> { 705 /// Lexicon methods (NSIDs or wildcard) 706 pub lxm: BTreeSet<RpcLexicon<S>>, 707 /// Audiences (DIDs or wildcard) 708 pub aud: BTreeSet<RpcAudience<S>>, 709} 710 711impl<S: BosStr + Ord> RpcScope<S> { 712 /// Convert to a `RpcScope` with a different backing type. 713 pub fn convert<B: Bos<str> + From<S> + AsRef<str> + FromStaticStr + Ord>(self) -> RpcScope<B> { 714 RpcScope { 715 lxm: self.lxm.into_iter().map(|s| s.convert()).collect(), 716 aud: self.aud.into_iter().map(|s| s.convert()).collect(), 717 } 718 } 719} 720 721impl<S: BosStr + IntoStatic> IntoStatic for RpcScope<S> 722where 723 S::Output: BosStr, 724 RpcLexicon<S::Output>: Ord, 725 RpcAudience<S::Output>: Ord, 726{ 727 type Output = RpcScope<S::Output>; 728 729 fn into_static(self) -> Self::Output { 730 RpcScope { 731 lxm: self.lxm.into_iter().map(|s| s.into_static()).collect(), 732 aud: self.aud.into_iter().map(|s| s.into_static()).collect(), 733 } 734 } 735} 736 737/// RPC lexicon identifier 738#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 739pub enum RpcLexicon<S: BosStr = DefaultStr> { 740 /// All lexicons (wildcard) 741 All, 742 /// Specific lexicon NSID 743 Nsid(Nsid<S>), 744} 745 746impl<S: BosStr> RpcLexicon<S> { 747 /// Convert to an `Nsid` with a different backing type. 748 pub fn convert<B: Bos<str> + From<S> + AsRef<str> + FromStaticStr>(self) -> RpcLexicon<B> { 749 match self { 750 RpcLexicon::All => RpcLexicon::All, 751 RpcLexicon::Nsid(nsid) => RpcLexicon::Nsid(nsid.convert()), 752 } 753 } 754} 755 756impl<S: BosStr + IntoStatic> IntoStatic for RpcLexicon<S> 757where 758 S::Output: BosStr, 759{ 760 type Output = RpcLexicon<S::Output>; 761 762 fn into_static(self) -> Self::Output { 763 match self { 764 RpcLexicon::All => RpcLexicon::All, 765 RpcLexicon::Nsid(nsid) => RpcLexicon::Nsid(nsid.into_static()), 766 } 767 } 768} 769 770/// RPC audience identifier 771#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 772pub enum RpcAudience<S: BosStr = DefaultStr> { 773 /// All audiences (wildcard) 774 All, 775 /// Specific DID with optional service id fragment 776 Did(DidService<S>), 777} 778 779impl<S: BosStr> RpcAudience<S> { 780 /// Convert to an `Nsid` with a different backing type. 781 pub fn convert<B: Bos<str> + From<S> + AsRef<str> + FromStaticStr>(self) -> RpcAudience<B> { 782 match self { 783 RpcAudience::All => RpcAudience::All, 784 RpcAudience::Did(did) => RpcAudience::Did(did.convert()), 785 } 786 } 787} 788 789impl<S: BosStr + IntoStatic> IntoStatic for RpcAudience<S> 790where 791 S::Output: BosStr, 792{ 793 type Output = RpcAudience<S::Output>; 794 795 fn into_static(self) -> Self::Output { 796 match self { 797 RpcAudience::All => RpcAudience::All, 798 RpcAudience::Did(did) => RpcAudience::Did(did.into_static()), 799 } 800 } 801} 802 803/// Byte-range indices for a single scope within a `Scopes` buffer. 804#[derive(Debug, Clone, PartialEq, Eq)] 805pub(crate) struct ScopeIndices { 806 pub(crate) start: u16, 807 pub(crate) end: u16, 808 pub(crate) inner: ScopeInnerIndices, 809} 810 811/// Pre-parsed structure of a scope, storing only byte-range indices into the buffer. 812#[derive(Debug, Clone, PartialEq, Eq)] 813pub(crate) enum ScopeInnerIndices { 814 Account { 815 resource: AccountResource, 816 action: AccountAction, 817 }, 818 Identity(IdentityScope), 819 Transition(TransitionScope), 820 Blob { 821 accept: SmallVec<[(u16, u16); 2]>, 822 }, 823 Repo { 824 collection: Option<(u16, u16)>, 825 actions: RepoActionFlags, 826 }, 827 Rpc { 828 lxm: SmallVec<[(u16, u16); 2]>, 829 aud: SmallVec<[(u16, u16); 2]>, 830 }, 831 Include { 832 nsid: (u16, u16), 833 audience: Option<IncludeAudience>, 834 }, 835 /// Unit scopes: atproto, openid, profile, email. 836 Unit(ScopeKind), 837} 838 839/// Discriminant for unit scopes (no string data). 840#[derive(Debug, Clone, Copy, PartialEq, Eq)] 841pub(crate) enum ScopeKind { 842 Atproto, 843 OpenId, 844 Profile, 845 Email, 846} 847 848/// Bitflag representation of repo actions for compact index storage. 849#[derive(Debug, Clone, Copy, PartialEq, Eq)] 850pub(crate) struct RepoActionFlags(u8); 851 852impl RepoActionFlags { 853 pub(crate) const CREATE: u8 = 0b001; 854 pub(crate) const UPDATE: u8 = 0b010; 855 pub(crate) const DELETE: u8 = 0b100; 856 pub(crate) const ALL: u8 = 0b111; 857 858 pub(crate) fn contains(self, flag: u8) -> bool { 859 self.0 & flag != 0 860 } 861 862 pub(crate) fn to_actions(self) -> BTreeSet<RepoAction> { 863 let mut set = BTreeSet::new(); 864 if self.contains(Self::CREATE) { 865 set.insert(RepoAction::Create); 866 } 867 if self.contains(Self::UPDATE) { 868 set.insert(RepoAction::Update); 869 } 870 if self.contains(Self::DELETE) { 871 set.insert(RepoAction::Delete); 872 } 873 set 874 } 875} 876 877/// Audience encoding state for include scope indices. 878/// 879/// Both variants store byte ranges into the buffer. The discriminant 880/// tells `grants()` whether to decode before comparing, and tells 881/// `to_string_normalized()` whether the raw form needs encoding. 882#[derive(Debug, Clone, PartialEq, Eq)] 883pub(crate) enum IncludeAudience { 884 /// Audience in buffer is already decoded (no percent-encoding). 885 /// `grants()` can compare directly. Serialisation must encode `#` -> `%23`. 886 Plain(u16, u16), 887 /// Audience in buffer contains percent-encoding (e.g., `%23`). 888 /// `grants()` must decode before comparing. Serialisation can pass through. 889 Encoded(u16, u16), 890} 891 892/// Iterator over scopes in a `Scopes<S>` container. 893pub struct ScopesIter<'i, 'o> { 894 buffer: &'o str, 895 indices: std::slice::Iter<'i, ScopeIndices>, 896} 897 898impl<'i, 'o> Iterator for ScopesIter<'i, 'o> { 899 type Item = Scope<&'o str>; 900 901 fn next(&mut self) -> Option<Scope<&'o str>> { 902 self.indices.next().map(|idx| { 903 // Safety: indices computed at construction from the buffer. 904 unsafe { reconstruct_scope(self.buffer, idx) } 905 }) 906 } 907 908 fn size_hint(&self) -> (usize, Option<usize>) { 909 self.indices.size_hint() 910 } 911 912 fn count(self) -> usize { 913 self.indices.count() 914 } 915} 916 917impl<'i, 'o> ExactSizeIterator for ScopesIter<'i, 'o> { 918 fn len(&self) -> usize { 919 self.indices.len() 920 } 921} 922 923impl<'i, 'o> std::iter::FusedIterator for ScopesIter<'i, 'o> {} 924 925/// A validated container of space-separated OAuth scopes. 926/// 927/// Owns or borrows a single scope string and stores pre-computed byte-range 928/// indices. Typed `Scope<&str>` views are reconstructed on demand from the 929/// shared buffer. 930#[derive(Debug, Clone, PartialEq, Eq)] 931pub struct Scopes<S: Bos<str> + AsRef<str> = DefaultStr> { 932 buffer: S, 933 indices: Vec<ScopeIndices>, 934} 935 936impl<S: Bos<str> + AsRef<str>> Scopes<S> { 937 /// Parse a space-separated scope string, validate each scope, and 938 /// compute byte-range indices. 939 /// 940 /// Returns an empty `Scopes` for an empty string. Returns an error 941 /// if any individual scope is malformed. 942 pub fn new(buffer: S) -> Result<Self, ParseError> { 943 let s = buffer.as_ref(); 944 945 if s.is_empty() { 946 return Ok(Scopes { 947 buffer, 948 indices: Vec::new(), 949 }); 950 } 951 952 // Check u16 limit on buffer length. 953 if s.len() > u16::MAX as usize { 954 return Err(ParseError::InvalidResource( 955 "scope string exceeds u16 byte limit".to_smolstr(), 956 )); 957 } 958 959 let mut indices = Vec::new(); 960 let mut pos: usize = 0; 961 962 for token in s.split(' ') { 963 if token.is_empty() { 964 pos += 1; // Advance past the space. 965 continue; 966 } 967 968 let start = pos as u16; 969 let end = start + token.len() as u16; 970 971 let inner = parse_scope_indices(token, start)?; 972 indices.push(ScopeIndices { start, end, inner }); 973 974 pos = end as usize + 1; // +1 for the space delimiter. 975 } 976 977 // Reduce the set by removing indices for scopes already granted by a broader scope. 978 indices = reduce_indices(s, indices)?; 979 980 Ok(Scopes { buffer, indices }) 981 } 982 983 /// Return the number of scopes in this container. 984 pub fn len(&self) -> usize { 985 self.indices.len() 986 } 987 988 /// Return whether this container is empty. 989 pub fn is_empty(&self) -> bool { 990 self.indices.is_empty() 991 } 992 993 /// Iterate over scopes as `Scope<&'o str>` views borrowing from the buffer. 994 pub fn iter<'i, 'o>(&'i self) -> ScopesIter<'i, 'o> 995 where 996 S: BorrowOrShare<'i, 'o, str>, 997 { 998 let buffer: &'o str = self.buffer.borrow_or_share(); 999 ScopesIter { 1000 buffer, 1001 indices: self.indices.iter(), 1002 } 1003 } 1004 1005 /// Get a single scope by positional index. 1006 pub fn get<'i, 'o>(&'i self, index: usize) -> Option<Scope<&'o str>> 1007 where 1008 S: BorrowOrShare<'i, 'o, str>, 1009 { 1010 let idx = self.indices.get(index)?; 1011 let buffer: &'o str = self.buffer.borrow_or_share(); 1012 Some(unsafe { reconstruct_scope(buffer, idx) }) 1013 } 1014 1015 /// Get a single scope with owned `SmolStr` backing, independent 1016 /// of the buffer's lifetime. 1017 pub fn get_owned(&self, index: usize) -> Option<Scope<SmolStr>> { 1018 let idx = self.indices.get(index)?; 1019 let buffer: &str = self.buffer.as_ref(); 1020 let scope = unsafe { reconstruct_scope(buffer, idx) }; 1021 Some(scope.convert()) 1022 } 1023 1024 /// Get a single scope with caller-chosen backing type. 1025 pub fn get_as<B: BosStr + for<'a> From<&'a str>>(&self, index: usize) -> Option<Scope<B>> 1026 where 1027 B: Ord, 1028 { 1029 let idx = self.indices.get(index)?; 1030 let buffer: &str = self.buffer.as_ref(); 1031 let scope = unsafe { reconstruct_scope(buffer, idx) }; 1032 Some(scope.convert()) 1033 } 1034 1035 /// Borrow as `Scopes<&str>`. 1036 pub fn borrow(&self) -> Scopes<&str> { 1037 Scopes { 1038 buffer: self.buffer.as_ref(), 1039 indices: self.indices.clone(), 1040 } 1041 } 1042 1043 /// Convert to `Scopes` with a different backing type. 1044 pub fn convert<B: Bos<str> + AsRef<str> + From<S>>(self) -> Scopes<B> { 1045 Scopes { 1046 buffer: B::from(self.buffer), 1047 indices: self.indices, 1048 } 1049 } 1050 1051 /// Produce the sorted, normalized space-separated scope string. 1052 pub fn to_normalized_string(&self) -> SmolStr { 1053 if self.indices.is_empty() { 1054 return SmolStr::default(); 1055 } 1056 let buffer = self.buffer.as_ref(); 1057 let mut normalized: Vec<SmolStr> = self 1058 .indices 1059 .iter() 1060 .map(|idx| { 1061 let scope = unsafe { reconstruct_scope(buffer, idx) }; 1062 scope.to_string_normalized() 1063 }) 1064 .collect(); 1065 normalized.sort(); 1066 1067 let mut result = SmolStrBuilder::new(); 1068 for (i, s) in normalized.iter().enumerate() { 1069 if i > 0 { 1070 result.push(' '); 1071 } 1072 result.push_str(s); 1073 } 1074 result.finish() 1075 } 1076 1077 /// Check if the container has a scope that grants the given scope. 1078 pub fn grants<T: BosStr>(&self, scope: &Scope<T>) -> bool { 1079 let buffer = self.buffer.as_ref(); 1080 self.indices.iter().any(|idx| { 1081 let s = unsafe { reconstruct_scope(buffer, idx) }; 1082 s.grants(scope) 1083 }) 1084 } 1085 1086 /// Return the raw buffer as a string slice. 1087 pub fn as_str(&self) -> &str { 1088 self.buffer.as_ref() 1089 } 1090} 1091 1092impl<S: Bos<str> + AsRef<str>> Serialize for Scopes<S> { 1093 fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error> 1094 where 1095 Ser: serde::Serializer, 1096 { 1097 serializer.serialize_str(&self.to_normalized_string()) 1098 } 1099} 1100 1101impl<'de, S> Deserialize<'de> for Scopes<S> 1102where 1103 S: Bos<str> + AsRef<str> + Deserialize<'de>, 1104{ 1105 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 1106 where 1107 D: serde::Deserializer<'de>, 1108 { 1109 let s = S::deserialize(deserializer)?; 1110 Scopes::new(s).map_err(D::Error::custom) 1111 } 1112} 1113 1114impl Scopes<SmolStr> { 1115 /// Create an empty `Scopes` with `SmolStr` backing. 1116 pub fn empty() -> Self { 1117 Scopes { 1118 buffer: SmolStr::default(), 1119 indices: Vec::new(), 1120 } 1121 } 1122 1123 /// Construct scopes from typed scope values, reusing normal parsing, validation, 1124 /// reduction, and indexing. 1125 pub fn from_scopes<I>(scopes: I) -> Result<Self, ParseError> 1126 where 1127 I: IntoIterator<Item = Scope<SmolStr>>, 1128 { 1129 let mut builder = SmolStrBuilder::new(); 1130 for (index, scope) in scopes.into_iter().enumerate() { 1131 if index > 0 { 1132 builder.push(' '); 1133 } 1134 builder.push_str(&scope.to_string_normalized()); 1135 } 1136 Scopes::new(builder.finish()) 1137 } 1138 1139 /// Construct the default atproto marker scope set. 1140 pub fn atproto() -> Self { 1141 Scopes::new(SmolStr::new_static("atproto")).expect("static atproto scope is valid") 1142 } 1143 1144 /// Construct a conservative local-development scope set. 1145 pub fn local_development() -> Self { 1146 Scopes::new(SmolStr::new_static("atproto transition:generic")) 1147 .expect("static local-development scopes are valid") 1148 } 1149 1150 /// Start a fluent scope builder. 1151 pub fn builder() -> ScopesBuilder { 1152 ScopesBuilder::new() 1153 } 1154} 1155 1156/// Fluent builder for programmatic OAuth scope construction. 1157#[derive(Debug, Clone, Default)] 1158pub struct ScopesBuilder { 1159 scopes: Vec<Scope<SmolStr>>, 1160} 1161 1162impl ScopesBuilder { 1163 /// Create an empty builder. 1164 pub fn new() -> Self { 1165 Self { scopes: Vec::new() } 1166 } 1167 1168 /// Add an already-constructed scope. 1169 pub fn scope(mut self, scope: Scope<SmolStr>) -> Self { 1170 self.scopes.push(scope); 1171 self 1172 } 1173 1174 /// Add `atproto`. 1175 pub fn atproto(self) -> Self { 1176 self.scope(Scope::atproto()) 1177 } 1178 1179 /// Add `openid`. 1180 pub fn openid(self) -> Self { 1181 self.scope(Scope::openid()) 1182 } 1183 1184 /// Add `profile`. 1185 pub fn profile(self) -> Self { 1186 self.scope(Scope::profile()) 1187 } 1188 1189 /// Add `email`. 1190 pub fn email(self) -> Self { 1191 self.scope(Scope::email()) 1192 } 1193 1194 /// Add `transition:generic`. 1195 pub fn transition_generic(self) -> Self { 1196 self.scope(Scope::transition_generic()) 1197 } 1198 1199 /// Add `transition:email`. 1200 pub fn transition_email(self) -> Self { 1201 self.scope(Scope::transition_email()) 1202 } 1203 1204 /// Add `transition:chat.bsky`. 1205 pub fn transition_chat_bsky(self) -> Self { 1206 self.scope(Scope::transition_chat_bsky()) 1207 } 1208 1209 /// Add `identity:handle`. 1210 pub fn identity_handle(self) -> Self { 1211 self.scope(Scope::identity_handle()) 1212 } 1213 1214 /// Add `identity:*`. 1215 pub fn identity_all(self) -> Self { 1216 self.scope(Scope::identity_all()) 1217 } 1218 1219 /// Add `rpc:*`. 1220 pub fn rpc_all(self) -> Self { 1221 self.scope(Scope::rpc_all()) 1222 } 1223 1224 /// Add an RPC scope for one XRPC method NSID. 1225 pub fn rpc(self, nsid: impl AsRef<str>) -> Result<Self, ParseError> { 1226 Ok(self.scope(Scope::rpc(nsid)?)) 1227 } 1228 1229 /// Add an RPC scope for one XRPC request type. 1230 pub fn rpc_request<R: XrpcRequest>(self) -> Self { 1231 self.scope(Scope::rpc_request::<R>()) 1232 } 1233 1234 /// Add an RPC scope for one XRPC request type. 1235 pub fn rpc_request_aud<R: XrpcRequest>(self, aud: impl AsRef<str>) -> Result<Self, ParseError> { 1236 Ok(self.scope(Scope::rpc_request_aud::<R>(aud)?)) 1237 } 1238 1239 /// Add a repo create scope for one collection NSID. 1240 pub fn repo_create(self, collection: impl AsRef<str>) -> Result<Self, ParseError> { 1241 Ok(self.scope(Scope::repo_create(collection)?)) 1242 } 1243 1244 /// Add a repo update scope for one collection NSID. 1245 pub fn repo_update(self, collection: impl AsRef<str>) -> Result<Self, ParseError> { 1246 Ok(self.scope(Scope::repo_update(collection)?)) 1247 } 1248 1249 /// Add a repo delete scope for one collection NSID. 1250 pub fn repo_delete(self, collection: impl AsRef<str>) -> Result<Self, ParseError> { 1251 Ok(self.scope(Scope::repo_delete(collection)?)) 1252 } 1253 1254 /// Add a repo all-actions scope for one collection NSID. 1255 pub fn repo_collection(self, collection: impl AsRef<str>) -> Result<Self, ParseError> { 1256 Ok(self.scope(Scope::repo_collection(collection)?)) 1257 } 1258 1259 /// Add a repo create scope for a collection type. 1260 pub fn repo_create_record<C: Collection>(self) -> Self { 1261 self.scope(Scope::repo_create_record::<C>()) 1262 } 1263 1264 /// Add a repo update scope for a collection type. 1265 pub fn repo_update_record<C: Collection>(self) -> Self { 1266 self.scope(Scope::repo_update_record::<C>()) 1267 } 1268 1269 /// Add a repo delete scope for a collection type. 1270 pub fn repo_delete_record<C: Collection>(self) -> Self { 1271 self.scope(Scope::repo_delete_record::<C>()) 1272 } 1273 1274 /// Add a repo all-actions scope for a collection type. 1275 pub fn repo_all_record<C: Collection>(self) -> Self { 1276 self.scope(Scope::repo_all_record::<C>()) 1277 } 1278 1279 /// Add an include permission-set scope. 1280 pub fn include(self, nsid: impl AsRef<str>) -> Result<Self, ParseError> { 1281 Ok(self.scope(Scope::include(nsid)?)) 1282 } 1283 1284 /// Add an include permission-set scope with an audience. 1285 pub fn include_aud( 1286 self, 1287 nsid: impl AsRef<str>, 1288 aud: impl AsRef<str>, 1289 ) -> Result<Self, ParseError> { 1290 Ok(self.scope(Scope::include_aud(nsid, aud)?)) 1291 } 1292 1293 /// Validate, reduce, and build the final scope container. 1294 pub fn build(self) -> Result<Scopes<SmolStr>, ParseError> { 1295 Scopes::from_scopes(self.scopes) 1296 } 1297} 1298 1299impl<S: Bos<str> + AsRef<str> + Default + FromStaticStr> Default for Scopes<S> { 1300 fn default() -> Self { 1301 let buffer = S::from_static("atproto"); 1302 let end = (buffer.as_ref().len() - 1) as u16; 1303 Scopes { 1304 buffer, 1305 indices: vec![ScopeIndices { 1306 start: 0, 1307 end, 1308 inner: ScopeInnerIndices::Unit(ScopeKind::Atproto), 1309 }], 1310 } 1311 } 1312} 1313 1314impl<S: Bos<str> + AsRef<str> + IntoStatic> IntoStatic for Scopes<S> 1315where 1316 S::Output: Bos<str> + AsRef<str>, 1317{ 1318 type Output = Scopes<S::Output>; 1319 1320 fn into_static(self) -> Scopes<S::Output> { 1321 Scopes { 1322 buffer: self.buffer.into_static(), 1323 indices: self.indices, 1324 } 1325 } 1326} 1327 1328/// Parse a single scope token into index structure. 1329/// 1330/// `base` is the byte offset of `token` within the outer buffer. 1331/// All `(u16, u16)` ranges in the returned indices are absolute 1332/// offsets into the outer buffer, NOT relative to `token`. 1333fn parse_scope_indices(token: &str, base: u16) -> Result<ScopeInnerIndices, ParseError> { 1334 // Determine the prefix by checking for known prefixes. 1335 let prefixes = [ 1336 "account", 1337 "identity", 1338 "blob", 1339 "repo", 1340 "rpc", 1341 "atproto", 1342 "transition", 1343 "include", 1344 "openid", 1345 "profile", 1346 "email", 1347 ]; 1348 1349 let mut found_prefix = None; 1350 let mut suffix = None; 1351 1352 for prefix in &prefixes { 1353 if let Some(remainder) = token.strip_prefix(prefix) { 1354 if remainder.is_empty() || remainder.starts_with(':') || remainder.starts_with('?') { 1355 found_prefix = Some(*prefix); 1356 if let Some(stripped) = remainder.strip_prefix(':') { 1357 suffix = Some(stripped); 1358 } else if remainder.starts_with('?') { 1359 suffix = Some(remainder); 1360 } else { 1361 suffix = None; 1362 } 1363 break; 1364 } 1365 } 1366 } 1367 1368 let prefix = found_prefix.ok_or_else(|| { 1369 ParseError::UnknownPrefix(token[..token.find(':').unwrap_or(token.len())].to_smolstr()) 1370 })?; 1371 1372 match prefix { 1373 "account" => parse_account_indices(suffix), 1374 "identity" => parse_identity_indices(suffix), 1375 "blob" => parse_blob_indices(token, suffix, base), 1376 "repo" => parse_repo_indices(token, suffix, base), 1377 "rpc" => parse_rpc_indices(token, suffix, base), 1378 "atproto" => parse_atproto_indices(suffix), 1379 "transition" => parse_transition_indices(suffix), 1380 "include" => parse_include_indices(token, suffix, base), 1381 "openid" => parse_openid_indices(suffix), 1382 "profile" => parse_profile_indices(suffix), 1383 "email" => parse_email_indices(suffix), 1384 _ => Err(ParseError::UnknownPrefix(prefix.to_smolstr())), 1385 } 1386} 1387 1388/// Parse account scope indices. 1389fn parse_account_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 1390 let (resource_str, params) = match suffix { 1391 Some(s) => { 1392 if let Some(pos) = s.find('?') { 1393 (&s[..pos], Some(&s[pos + 1..])) 1394 } else { 1395 (s, None) 1396 } 1397 } 1398 None => return Err(ParseError::MissingResource), 1399 }; 1400 1401 let resource = match resource_str { 1402 "email" => AccountResource::Email, 1403 "repo" => AccountResource::Repo, 1404 "status" => AccountResource::Status, 1405 _ => return Err(ParseError::InvalidResource(resource_str.to_smolstr())), 1406 }; 1407 1408 let action = if let Some(params) = params { 1409 let parsed_params = parse_query_string(params); 1410 match parsed_params 1411 .get("action") 1412 .and_then(|v| v.first()) 1413 .map(|s| s.as_ref()) 1414 { 1415 Some("read") => AccountAction::Read, 1416 Some("manage") => AccountAction::Manage, 1417 Some(other) => return Err(ParseError::InvalidAction(other.to_smolstr())), 1418 None => AccountAction::Read, 1419 } 1420 } else { 1421 AccountAction::Read 1422 }; 1423 1424 Ok(ScopeInnerIndices::Account { resource, action }) 1425} 1426 1427/// Parse identity scope indices. 1428fn parse_identity_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 1429 let scope = match suffix { 1430 Some("handle") => IdentityScope::Handle, 1431 Some("*") => IdentityScope::All, 1432 Some(other) => return Err(ParseError::InvalidResource(other.to_smolstr())), 1433 None => return Err(ParseError::MissingResource), 1434 }; 1435 1436 Ok(ScopeInnerIndices::Identity(scope)) 1437} 1438 1439/// Parse transition scope indices. 1440fn parse_transition_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 1441 let scope = match suffix { 1442 Some("generic") => TransitionScope::Generic, 1443 Some("email") => TransitionScope::Email, 1444 Some("chat.bsky") => TransitionScope::ChatBsky, 1445 Some(other) => return Err(ParseError::InvalidResource(other.to_smolstr())), 1446 None => return Err(ParseError::MissingResource), 1447 }; 1448 1449 Ok(ScopeInnerIndices::Transition(scope)) 1450} 1451 1452/// Parse atproto scope indices (unit scope, no suffix allowed). 1453fn parse_atproto_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 1454 if suffix.is_some() { 1455 return Err(ParseError::InvalidResource( 1456 "atproto scope does not accept suffixes".to_smolstr(), 1457 )); 1458 } 1459 Ok(ScopeInnerIndices::Unit(ScopeKind::Atproto)) 1460} 1461 1462/// Parse openid scope indices (unit scope, no suffix allowed). 1463fn parse_openid_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 1464 if suffix.is_some() { 1465 return Err(ParseError::InvalidResource( 1466 "openid scope does not accept suffixes".to_smolstr(), 1467 )); 1468 } 1469 Ok(ScopeInnerIndices::Unit(ScopeKind::OpenId)) 1470} 1471 1472/// Parse profile scope indices (unit scope, no suffix allowed). 1473fn parse_profile_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 1474 if suffix.is_some() { 1475 return Err(ParseError::InvalidResource( 1476 "profile scope does not accept suffixes".to_smolstr(), 1477 )); 1478 } 1479 Ok(ScopeInnerIndices::Unit(ScopeKind::Profile)) 1480} 1481 1482/// Parse email scope indices (unit scope, no suffix allowed). 1483fn parse_email_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 1484 if suffix.is_some() { 1485 return Err(ParseError::InvalidResource( 1486 "email scope does not accept suffixes".to_smolstr(), 1487 )); 1488 } 1489 Ok(ScopeInnerIndices::Unit(ScopeKind::Email)) 1490} 1491 1492/// Parse blob scope indices, storing byte ranges of MIME patterns. 1493fn parse_blob_indices( 1494 token: &str, 1495 suffix: Option<&str>, 1496 base: u16, 1497) -> Result<ScopeInnerIndices, ParseError> { 1498 let mut accept: SmallVec<[(u16, u16); 2]> = SmallVec::new(); 1499 1500 match suffix { 1501 Some(s) if s.starts_with('?') => { 1502 let params = parse_query_string(&s[1..]); 1503 if let Some(values) = params.get("accept") { 1504 for value in values { 1505 validate_mime_pattern(value)?; 1506 // Find the byte position of this value in the token. 1507 if let Some(pos) = token.find(value) { 1508 let start = base + pos as u16; 1509 let end = start + value.len() as u16; 1510 accept.push((start, end)); 1511 } 1512 } 1513 } 1514 } 1515 Some(s) => { 1516 validate_mime_pattern(s)?; 1517 let start = base + ("blob:".len() as u16); 1518 let end = start + s.len() as u16; 1519 accept.push((start, end)); 1520 } 1521 None => { 1522 // Default to all patterns (bare `blob` token). 1523 // Store empty SmallVec to signal "all wildcards" on reconstruction. 1524 } 1525 } 1526 1527 // Empty accept SmallVec signals MimePattern::All on reconstruction. 1528 1529 Ok(ScopeInnerIndices::Blob { accept }) 1530} 1531 1532/// Parse repo scope indices, storing byte range of collection NSID if present. 1533fn parse_repo_indices( 1534 token: &str, 1535 suffix: Option<&str>, 1536 base: u16, 1537) -> Result<ScopeInnerIndices, ParseError> { 1538 let (collection_str, params) = match suffix { 1539 Some(s) => { 1540 if let Some(pos) = s.find('?') { 1541 (Some(&s[..pos]), Some(&s[pos + 1..])) 1542 } else { 1543 (Some(s), None) 1544 } 1545 } 1546 None => (None, None), 1547 }; 1548 1549 let collection = match collection_str { 1550 Some("*") | None => None, 1551 Some(nsid_str) => { 1552 jacquard_common::types::nsid::validate_nsid(nsid_str)?; 1553 // Find position of the NSID in the token. 1554 if let Some(pos) = token.find(nsid_str) { 1555 let start = base + pos as u16; 1556 let end = start + nsid_str.len() as u16; 1557 Some((start, end)) 1558 } else { 1559 return Err(ParseError::InvalidResource(nsid_str.to_smolstr())); 1560 } 1561 } 1562 }; 1563 1564 let mut actions = RepoActionFlags(RepoActionFlags::ALL); 1565 1566 if let Some(params) = params { 1567 let parsed_params = parse_query_string(params); 1568 if let Some(values) = parsed_params.get("action") { 1569 let mut flags = 0u8; 1570 for value in values { 1571 match value.as_ref() { 1572 "create" => flags |= RepoActionFlags::CREATE, 1573 "update" => flags |= RepoActionFlags::UPDATE, 1574 "delete" => flags |= RepoActionFlags::DELETE, 1575 "*" => flags = RepoActionFlags::ALL, 1576 other => return Err(ParseError::InvalidAction(other.to_smolstr())), 1577 } 1578 } 1579 actions = RepoActionFlags(flags); 1580 } 1581 } 1582 1583 Ok(ScopeInnerIndices::Repo { 1584 collection, 1585 actions, 1586 }) 1587} 1588 1589/// Parse RPC scope indices, storing byte ranges of lexicon and audience values. 1590fn parse_rpc_indices( 1591 token: &str, 1592 suffix: Option<&str>, 1593 base: u16, 1594) -> Result<ScopeInnerIndices, ParseError> { 1595 let mut lxm: SmallVec<[(u16, u16); 2]> = SmallVec::new(); 1596 let mut aud: SmallVec<[(u16, u16); 2]> = SmallVec::new(); 1597 1598 match suffix { 1599 Some("*") => { 1600 let wildcard_pos = token.rfind('*').unwrap_or(token.len() - 1); 1601 let start = base + wildcard_pos as u16; 1602 lxm.push((start, start + 1)); 1603 aud.push((start, start + 1)); 1604 } 1605 Some(s) if s.starts_with('?') => { 1606 let params = parse_query_string(&s[1..]); 1607 1608 if let Some(values) = params.get("lxm") { 1609 for value in values { 1610 if *value == "*" { 1611 if let Some(pos) = token.rfind('*') { 1612 let start = base + pos as u16; 1613 lxm.push((start, start + 1)); 1614 } 1615 } else { 1616 jacquard_common::types::nsid::validate_nsid(value)?; 1617 if let Some(pos) = token.find(value) { 1618 let start = base + pos as u16; 1619 let end = start + value.len() as u16; 1620 lxm.push((start, end)); 1621 } 1622 } 1623 } 1624 } 1625 1626 if let Some(values) = params.get("aud") { 1627 for value in values { 1628 if *value == "*" { 1629 if let Some(pos) = token.rfind('*') { 1630 let start = base + pos as u16; 1631 aud.push((start, start + 1)); 1632 } 1633 } else { 1634 validate_did_service(value)?; 1635 if let Some(pos) = token.find(value) { 1636 let start = base + pos as u16; 1637 let end = start + value.len() as u16; 1638 aud.push((start, end)); 1639 } 1640 } 1641 } 1642 } 1643 } 1644 Some(s) => { 1645 // Single NSID, possibly with query params. 1646 if let Some(pos) = s.find('?') { 1647 let nsid_str = &s[..pos]; 1648 let params = parse_query_string(&s[pos + 1..]); 1649 1650 jacquard_common::types::nsid::validate_nsid(nsid_str)?; 1651 if let Some(token_pos) = token.find(nsid_str) { 1652 let start = base + token_pos as u16; 1653 let end = start + nsid_str.len() as u16; 1654 lxm.push((start, end)); 1655 } 1656 1657 if let Some(values) = params.get("aud") { 1658 for value in values { 1659 if *value == "*" { 1660 if let Some(pos) = token.rfind('*') { 1661 let start = base + pos as u16; 1662 aud.push((start, start + 1)); 1663 } 1664 } else { 1665 validate_did_service(value)?; 1666 if let Some(pos) = token.find(value) { 1667 let start = base + pos as u16; 1668 let end = start + value.len() as u16; 1669 aud.push((start, end)); 1670 } 1671 } 1672 } 1673 } 1674 } else { 1675 // Just an NSID, no query params. 1676 jacquard_common::types::nsid::validate_nsid(s)?; 1677 if let Some(pos) = token.find(s) { 1678 let start = base + pos as u16; 1679 let end = start + s.len() as u16; 1680 lxm.push((start, end)); 1681 } 1682 // aud remains empty, which means wildcard on reconstruction. 1683 } 1684 } 1685 None => { 1686 // Empty suffix, default to all. 1687 // Leave both lxm and aud empty to signal wildcards on reconstruction. 1688 } 1689 } 1690 1691 // Empty lxm SmallVec signals RpcLexicon::All on reconstruction. 1692 // Empty aud SmallVec signals RpcAudience::All on reconstruction (already handled). 1693 1694 Ok(ScopeInnerIndices::Rpc { lxm, aud }) 1695} 1696 1697/// Parse include scope indices, validating NSID and optional audience. 1698fn parse_include_indices( 1699 token: &str, 1700 suffix: Option<&str>, 1701 base: u16, 1702) -> Result<ScopeInnerIndices, ParseError> { 1703 let (nsid_str, params) = match suffix { 1704 Some(s) => { 1705 if let Some(pos) = s.find('?') { 1706 (&s[..pos], Some(&s[pos + 1..])) 1707 } else { 1708 (s, None) 1709 } 1710 } 1711 None => return Err(ParseError::MissingResource), 1712 }; 1713 1714 // Validate the NSID. 1715 jacquard_common::types::nsid::validate_nsid(nsid_str)?; 1716 1717 // Find the NSID's byte position in the token. 1718 let nsid_pos = token 1719 .find(nsid_str) 1720 .ok_or_else(|| ParseError::InvalidResource(nsid_str.to_smolstr()))?; 1721 let nsid_start = base + nsid_pos as u16; 1722 let nsid_end = nsid_start + nsid_str.len() as u16; 1723 1724 let audience = if let Some(params) = params { 1725 let parsed_params = parse_query_string(params); 1726 if let Some(values) = parsed_params.get("aud") { 1727 if let Some(aud_value) = values.first() { 1728 // Check if value contains percent-encoding. 1729 let has_encoding = aud_value.contains('%'); 1730 1731 if has_encoding { 1732 // Validate and decode the percent-encoded value using fluent-uri. 1733 let estr = EStr::<Query>::new(aud_value).ok_or_else(|| { 1734 ParseError::InvalidResource( 1735 "include audience has invalid percent-encoding".to_smolstr(), 1736 ) 1737 })?; 1738 1739 let decoded = estr.decode().to_string().map_err(|_| { 1740 ParseError::InvalidResource( 1741 "include audience contains invalid UTF-8 sequence".to_smolstr(), 1742 ) 1743 })?; 1744 1745 validate_did_service(&decoded)?; 1746 } else { 1747 validate_did_service(aud_value)?; 1748 } 1749 1750 // Find the audience's byte position in the token. 1751 let aud_pos = token 1752 .find(aud_value) 1753 .ok_or_else(|| ParseError::InvalidResource(aud_value.to_smolstr()))?; 1754 let aud_start = base + aud_pos as u16; 1755 let aud_end = aud_start + aud_value.len() as u16; 1756 1757 if has_encoding { 1758 Some(IncludeAudience::Encoded(aud_start, aud_end)) 1759 } else { 1760 Some(IncludeAudience::Plain(aud_start, aud_end)) 1761 } 1762 } else { 1763 None 1764 } 1765 } else { 1766 None 1767 } 1768 } else { 1769 None 1770 }; 1771 1772 Ok(ScopeInnerIndices::Include { 1773 nsid: (nsid_start, nsid_end), 1774 audience, 1775 }) 1776} 1777 1778/// Reduce scope indices by removing those already granted by broader scopes. 1779fn reduce_indices( 1780 buffer: &str, 1781 indices: Vec<ScopeIndices>, 1782) -> Result<Vec<ScopeIndices>, ParseError> { 1783 if indices.is_empty() { 1784 return Ok(indices); 1785 } 1786 1787 // Partition indices by scope kind. 1788 let mut unit_or_account_or_identity_or_transition: Vec<_> = Vec::new(); 1789 let mut repo_indices: Vec<_> = Vec::new(); 1790 let mut rpc_indices: Vec<_> = Vec::new(); 1791 let mut blob_indices: Vec<_> = Vec::new(); 1792 let mut include_indices: Vec<_> = Vec::new(); 1793 1794 for indices in indices.into_iter() { 1795 match &indices.inner { 1796 ScopeInnerIndices::Unit(_) 1797 | ScopeInnerIndices::Account { .. } 1798 | ScopeInnerIndices::Identity(_) 1799 | ScopeInnerIndices::Transition(_) => { 1800 unit_or_account_or_identity_or_transition.push(indices); 1801 } 1802 ScopeInnerIndices::Repo { .. } => repo_indices.push(indices), 1803 ScopeInnerIndices::Rpc { .. } => rpc_indices.push(indices), 1804 ScopeInnerIndices::Blob { .. } => blob_indices.push(indices), 1805 ScopeInnerIndices::Include { .. } => include_indices.push(indices), 1806 } 1807 } 1808 1809 // Deduplicate unit/account/identity/transition scopes. 1810 let mut seen = std::collections::HashSet::new(); 1811 unit_or_account_or_identity_or_transition.retain(|idx| { 1812 let scope = unsafe { reconstruct_scope(buffer, idx) }; 1813 seen.insert(scope.to_string_normalized()) 1814 }); 1815 1816 // Pairwise reduction for repo scopes. 1817 repo_indices = reduce_pairwise(buffer, repo_indices); 1818 1819 // Pairwise reduction for rpc scopes. 1820 rpc_indices = reduce_pairwise(buffer, rpc_indices); 1821 1822 // Combine back. 1823 let mut result = unit_or_account_or_identity_or_transition; 1824 result.extend(repo_indices); 1825 result.extend(rpc_indices); 1826 result.extend(blob_indices); 1827 result.extend(include_indices); 1828 1829 Ok(result) 1830} 1831 1832/// Perform pairwise reduction on a set of indices, removing those granted by others. 1833fn reduce_pairwise(buffer: &str, indices: Vec<ScopeIndices>) -> Vec<ScopeIndices> { 1834 let mut result: Vec<ScopeIndices> = Vec::new(); 1835 1836 for candidate_idx in indices { 1837 // Reconstruct the candidate scope. 1838 let candidate = unsafe { reconstruct_scope(buffer, &candidate_idx) }; 1839 1840 // Check if it's already granted by something in result. 1841 let mut is_granted = false; 1842 for existing_idx in &result { 1843 let existing = unsafe { reconstruct_scope(buffer, existing_idx) }; 1844 if existing.grants(&candidate) && existing != candidate { 1845 is_granted = true; 1846 break; 1847 } 1848 } 1849 1850 if is_granted { 1851 continue; 1852 } 1853 1854 // Check if it grants any existing scopes. 1855 let mut indices_to_remove = Vec::new(); 1856 for (i, existing_idx) in result.iter().enumerate() { 1857 let existing = unsafe { reconstruct_scope(buffer, existing_idx) }; 1858 if candidate.grants(&existing) && candidate != existing { 1859 indices_to_remove.push(i); 1860 } 1861 } 1862 1863 for i in indices_to_remove.into_iter().rev() { 1864 result.remove(i); 1865 } 1866 1867 // Add candidate if not a duplicate. 1868 if !result.iter().any(|idx| { 1869 let existing = unsafe { reconstruct_scope(buffer, idx) }; 1870 existing == candidate 1871 }) { 1872 result.push(candidate_idx); 1873 } 1874 } 1875 1876 result 1877} 1878 1879/// Reconstruct a typed `Scope<&str>` from pre-computed indices. 1880/// 1881/// # Safety 1882/// 1883/// `indices` must have been computed from `buffer` during `Scopes::new()`. 1884/// All byte ranges in `indices` must be valid for `buffer`. 1885unsafe fn reconstruct_scope<'a>(buffer: &'a str, indices: &ScopeIndices) -> Scope<&'a str> { 1886 match &indices.inner { 1887 ScopeInnerIndices::Unit(kind) => match kind { 1888 ScopeKind::Atproto => Scope::Atproto, 1889 ScopeKind::OpenId => Scope::OpenId, 1890 ScopeKind::Profile => Scope::Profile, 1891 ScopeKind::Email => Scope::Email, 1892 }, 1893 1894 ScopeInnerIndices::Account { resource, action } => Scope::Account(AccountScope { 1895 resource: *resource, 1896 action: *action, 1897 }), 1898 1899 ScopeInnerIndices::Identity(scope) => Scope::Identity(scope.clone()), 1900 1901 ScopeInnerIndices::Transition(scope) => Scope::Transition(scope.clone()), 1902 1903 ScopeInnerIndices::Blob { accept } => { 1904 let mut patterns = BTreeSet::new(); 1905 if accept.is_empty() { 1906 // Empty accept SmallVec signals MimePattern::All (bare `blob` token). 1907 patterns.insert(MimePattern::All); 1908 } else { 1909 for &(start, end) in accept.iter() { 1910 let s = &buffer[start as usize..end as usize]; 1911 let pattern = if s == "*/*" { 1912 MimePattern::All 1913 } else if s.ends_with("/*") { 1914 MimePattern::TypeWildcard(&s[..s.len() - 2]) 1915 } else { 1916 MimePattern::Exact(s) 1917 }; 1918 patterns.insert(pattern); 1919 } 1920 } 1921 Scope::Blob(BlobScope { accept: patterns }) 1922 } 1923 1924 ScopeInnerIndices::Repo { 1925 collection, 1926 actions, 1927 } => { 1928 let collection = match collection { 1929 None => RepoCollection::All, 1930 Some((start, end)) => { 1931 let s = &buffer[*start as usize..*end as usize]; 1932 RepoCollection::Nsid(unsafe { Nsid::unchecked(s) }) 1933 } 1934 }; 1935 Scope::Repo(RepoScope { 1936 collection, 1937 actions: actions.to_actions(), 1938 }) 1939 } 1940 1941 ScopeInnerIndices::Rpc { lxm, aud } => { 1942 let mut lxm_set = BTreeSet::new(); 1943 let mut aud_set = BTreeSet::new(); 1944 1945 if lxm.is_empty() { 1946 // Empty lxm means wildcard (bare `rpc` token). 1947 lxm_set.insert(RpcLexicon::All); 1948 } else { 1949 for &(start, end) in lxm.iter() { 1950 let s = &buffer[start as usize..end as usize]; 1951 if s == "*" { 1952 lxm_set.insert(RpcLexicon::All); 1953 } else { 1954 lxm_set.insert(RpcLexicon::Nsid(unsafe { Nsid::unchecked(s) })); 1955 } 1956 } 1957 } 1958 1959 if aud.is_empty() { 1960 // Empty aud means wildcard. 1961 aud_set.insert(RpcAudience::All); 1962 } else { 1963 for &(start, end) in aud.iter() { 1964 let s = &buffer[start as usize..end as usize]; 1965 if s == "*" { 1966 aud_set.insert(RpcAudience::All); 1967 } else { 1968 aud_set.insert(RpcAudience::Did(unsafe { DidService::unchecked(s) })); 1969 } 1970 } 1971 } 1972 1973 Scope::Rpc(RpcScope { 1974 lxm: lxm_set, 1975 aud: aud_set, 1976 }) 1977 } 1978 1979 ScopeInnerIndices::Include { nsid, audience } => { 1980 let (ns, ne) = *nsid; 1981 let nsid_str = &buffer[ns as usize..ne as usize]; 1982 1983 let aud = match audience { 1984 None => None, 1985 Some(IncludeAudience::Plain(start, end)) 1986 | Some(IncludeAudience::Encoded(start, end)) => { 1987 Some(&buffer[*start as usize..*end as usize]) 1988 } 1989 }; 1990 1991 Scope::Include(IncludeScope { 1992 nsid: unsafe { Nsid::unchecked(nsid_str) }, 1993 audience: aud, 1994 }) 1995 } 1996 } 1997} 1998 1999impl<S: BosStr + Ord> Scope<S> { 2000 /// Convert to a `Scope` with a different backing type. 2001 pub fn convert<B: Bos<str> + From<S> + AsRef<str> + FromStaticStr + Ord>(self) -> Scope<B> { 2002 match self { 2003 Scope::Account(scope) => Scope::Account(scope), 2004 Scope::Identity(scope) => Scope::Identity(scope), 2005 Scope::Blob(scope) => Scope::Blob(scope.convert()), 2006 Scope::Repo(scope) => Scope::Repo(scope.convert()), 2007 Scope::Rpc(scope) => Scope::Rpc(scope.convert()), 2008 Scope::Atproto => Scope::Atproto, 2009 Scope::Transition(scope) => Scope::Transition(scope), 2010 Scope::Include(scope) => Scope::Include(scope.convert()), 2011 Scope::OpenId => Scope::OpenId, 2012 Scope::Profile => Scope::Profile, 2013 Scope::Email => Scope::Email, 2014 } 2015 } 2016 2017 /// Parse a scope from a string 2018 pub fn parse<'a>(s: &'a str) -> Result<Self, ParseError> 2019 where 2020 S: FromStr, 2021 <S as FromStr>::Err: core::fmt::Debug, 2022 { 2023 // Determine the prefix first by checking for known prefixes 2024 let prefixes = [ 2025 "account", 2026 "identity", 2027 "blob", 2028 "repo", 2029 "rpc", 2030 "atproto", 2031 "transition", 2032 "include", 2033 "openid", 2034 "profile", 2035 "email", 2036 ]; 2037 let mut found_prefix = None; 2038 let mut suffix = None; 2039 2040 for prefix in &prefixes { 2041 if let Some(remainder) = s.strip_prefix(prefix) 2042 && (remainder.is_empty() 2043 || remainder.starts_with(':') 2044 || remainder.starts_with('?')) 2045 { 2046 found_prefix = Some(*prefix); 2047 if let Some(stripped) = remainder.strip_prefix(':') { 2048 suffix = Some(stripped); 2049 } else if remainder.starts_with('?') { 2050 suffix = Some(remainder); 2051 } else { 2052 suffix = None; 2053 } 2054 break; 2055 } 2056 } 2057 2058 let prefix = found_prefix.ok_or_else(|| { 2059 // If no known prefix found, extract what looks like a prefix for error reporting 2060 let end = s.find(':').or_else(|| s.find('?')).unwrap_or(s.len()); 2061 ParseError::UnknownPrefix(s[..end].to_smolstr()) 2062 })?; 2063 2064 match prefix { 2065 "account" => Self::parse_account(suffix), 2066 "identity" => Self::parse_identity(suffix), 2067 "blob" => Self::parse_blob(suffix), 2068 "repo" => Self::parse_repo(suffix), 2069 "rpc" => Self::parse_rpc(suffix), 2070 "atproto" => Self::parse_atproto(suffix), 2071 "transition" => Self::parse_transition(suffix), 2072 "include" => Self::parse_include(suffix), 2073 "openid" => Self::parse_openid(suffix), 2074 "profile" => Self::parse_profile(suffix), 2075 "email" => Self::parse_email(suffix), 2076 _ => Err(ParseError::UnknownPrefix(prefix.to_smolstr())), 2077 } 2078 } 2079 2080 fn parse_account(suffix: Option<&str>) -> Result<Self, ParseError> { 2081 let (resource_str, params) = match suffix { 2082 Some(s) => { 2083 if let Some(pos) = s.find('?') { 2084 (&s[..pos], Some(&s[pos + 1..])) 2085 } else { 2086 (s, None) 2087 } 2088 } 2089 None => return Err(ParseError::MissingResource), 2090 }; 2091 2092 let resource = match resource_str { 2093 "email" => AccountResource::Email, 2094 "repo" => AccountResource::Repo, 2095 "status" => AccountResource::Status, 2096 _ => return Err(ParseError::InvalidResource(resource_str.to_smolstr())), 2097 }; 2098 2099 let action = if let Some(params) = params { 2100 let parsed_params = parse_query_string(params); 2101 match parsed_params 2102 .get("action") 2103 .and_then(|v| v.first()) 2104 .map(|s| s.as_ref()) 2105 { 2106 Some("read") => AccountAction::Read, 2107 Some("manage") => AccountAction::Manage, 2108 Some(other) => return Err(ParseError::InvalidAction(other.to_smolstr())), 2109 None => AccountAction::Read, 2110 } 2111 } else { 2112 AccountAction::Read 2113 }; 2114 2115 Ok(Scope::Account(AccountScope { resource, action })) 2116 } 2117 2118 fn parse_identity(suffix: Option<&str>) -> Result<Self, ParseError> { 2119 let scope = match suffix { 2120 Some("handle") => IdentityScope::Handle, 2121 Some("*") => IdentityScope::All, 2122 Some(other) => return Err(ParseError::InvalidResource(other.to_smolstr())), 2123 None => return Err(ParseError::MissingResource), 2124 }; 2125 2126 Ok(Scope::Identity(scope)) 2127 } 2128 2129 fn parse_blob<'a>(suffix: Option<&'a str>) -> Result<Self, ParseError> 2130 where 2131 S: FromStr, 2132 <S as FromStr>::Err: core::fmt::Debug, 2133 { 2134 let mut accept: BTreeSet<MimePattern<S>> = BTreeSet::new(); 2135 2136 match suffix { 2137 Some(s) if s.starts_with('?') => { 2138 let params = parse_query_string(&s[1..]); 2139 if let Some(values) = params.get("accept") { 2140 for value in values { 2141 accept.insert(MimePattern::from_str(*value)?); 2142 } 2143 } 2144 } 2145 Some(s) => { 2146 accept.insert(MimePattern::from_str(s)?); 2147 } 2148 None => { 2149 accept.insert(MimePattern::All); 2150 } 2151 } 2152 2153 if accept.is_empty() { 2154 accept.insert(MimePattern::All); 2155 } 2156 2157 Ok(Scope::Blob(BlobScope { accept })) 2158 } 2159 2160 fn parse_repo<'a>(suffix: Option<&'a str>) -> Result<Self, ParseError> 2161 where 2162 S: FromStr, 2163 { 2164 let (collection_str, params) = match suffix { 2165 Some(s) => { 2166 if let Some(pos) = s.find('?') { 2167 (Some(&s[..pos]), Some(&s[pos + 1..])) 2168 } else { 2169 (Some(s), None) 2170 } 2171 } 2172 None => (None, None), 2173 }; 2174 2175 let collection = match collection_str { 2176 Some("*") | None => RepoCollection::All, 2177 Some(nsid) => RepoCollection::Nsid(Nsid::from_str(nsid)?), 2178 }; 2179 2180 let mut actions = BTreeSet::new(); 2181 if let Some(params) = params { 2182 let parsed_params = parse_query_string(params); 2183 if let Some(values) = parsed_params.get("action") { 2184 for value in values { 2185 match value.as_ref() { 2186 "create" => { 2187 actions.insert(RepoAction::Create); 2188 } 2189 "update" => { 2190 actions.insert(RepoAction::Update); 2191 } 2192 "delete" => { 2193 actions.insert(RepoAction::Delete); 2194 } 2195 "*" => { 2196 actions.insert(RepoAction::Create); 2197 actions.insert(RepoAction::Update); 2198 actions.insert(RepoAction::Delete); 2199 } 2200 other => return Err(ParseError::InvalidAction(other.to_smolstr())), 2201 } 2202 } 2203 } 2204 } 2205 2206 if actions.is_empty() { 2207 actions.insert(RepoAction::Create); 2208 actions.insert(RepoAction::Update); 2209 actions.insert(RepoAction::Delete); 2210 } 2211 2212 Ok(Scope::Repo(RepoScope { 2213 collection, 2214 actions, 2215 })) 2216 } 2217 2218 fn parse_rpc<'a>(suffix: Option<&'a str>) -> Result<Self, ParseError> 2219 where 2220 S: FromStr, 2221 { 2222 let mut lxm = BTreeSet::new(); 2223 let mut aud = BTreeSet::new(); 2224 2225 match suffix { 2226 Some("*") => { 2227 lxm.insert(RpcLexicon::All); 2228 aud.insert(RpcAudience::All); 2229 } 2230 Some(s) if s.starts_with('?') => { 2231 let params = parse_query_string(&s[1..]); 2232 2233 if let Some(values) = params.get("lxm") { 2234 for value in values { 2235 if *value == "*" { 2236 lxm.insert(RpcLexicon::All); 2237 } else { 2238 lxm.insert(RpcLexicon::Nsid(Nsid::from_str(*value)?)); 2239 } 2240 } 2241 } 2242 2243 if let Some(values) = params.get("aud") { 2244 for value in values { 2245 if *value == "*" { 2246 aud.insert(RpcAudience::All); 2247 } else { 2248 aud.insert(RpcAudience::Did(DidService::from_str(*value)?)); 2249 } 2250 } 2251 } 2252 } 2253 Some(s) => { 2254 // Check if there's a query string in the suffix 2255 if let Some(pos) = s.find('?') { 2256 let nsid = &s[..pos]; 2257 let params = parse_query_string(&s[pos + 1..]); 2258 2259 lxm.insert(RpcLexicon::Nsid(Nsid::from_str(nsid)?)); 2260 2261 if let Some(values) = params.get("aud") { 2262 for value in values { 2263 if *value == "*" { 2264 aud.insert(RpcAudience::All); 2265 } else { 2266 aud.insert(RpcAudience::Did(DidService::from_str(*value)?)); 2267 } 2268 } 2269 } 2270 } else { 2271 lxm.insert(RpcLexicon::Nsid(Nsid::from_str(s)?)); 2272 } 2273 } 2274 None => {} 2275 } 2276 2277 if lxm.is_empty() { 2278 lxm.insert(RpcLexicon::All); 2279 } 2280 if aud.is_empty() { 2281 aud.insert(RpcAudience::All); 2282 } 2283 2284 Ok(Scope::Rpc(RpcScope { lxm, aud })) 2285 } 2286 2287 fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> { 2288 if suffix.is_some() { 2289 return Err(ParseError::InvalidResource( 2290 "atproto scope does not accept suffixes".to_smolstr(), 2291 )); 2292 } 2293 Ok(Scope::Atproto) 2294 } 2295 2296 fn parse_transition(suffix: Option<&str>) -> Result<Self, ParseError> { 2297 let scope = match suffix { 2298 Some("generic") => TransitionScope::Generic, 2299 Some("email") => TransitionScope::Email, 2300 Some("chat.bsky") => TransitionScope::ChatBsky, 2301 Some(other) => return Err(ParseError::InvalidResource(other.to_smolstr())), 2302 None => return Err(ParseError::MissingResource), 2303 }; 2304 2305 Ok(Scope::Transition(scope)) 2306 } 2307 2308 fn parse_include<'a>(suffix: Option<&'a str>) -> Result<Self, ParseError> 2309 where 2310 S: FromStr, 2311 { 2312 let (nsid_str, params) = match suffix { 2313 Some(s) => { 2314 if let Some(pos) = s.find('?') { 2315 (&s[..pos], Some(&s[pos + 1..])) 2316 } else { 2317 (s, None) 2318 } 2319 } 2320 None => return Err(ParseError::MissingResource), 2321 }; 2322 2323 let audience = if let Some(params) = params { 2324 let parsed_params = parse_query_string(params); 2325 parsed_params 2326 .get("aud") 2327 .and_then(|values| values.first()) 2328 .map(|audience| { 2329 let normalized = normalize_include_audience(audience)?; 2330 S::from_str(normalized.as_str()).map_err(|_| { 2331 ParseError::InvalidResource("invalid include audience".to_smolstr()) 2332 }) 2333 }) 2334 .transpose()? 2335 } else { 2336 None 2337 }; 2338 2339 Ok(Scope::Include(IncludeScope { 2340 nsid: Nsid::from_str(nsid_str)?, 2341 audience, 2342 })) 2343 } 2344 2345 fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> { 2346 if suffix.is_some() { 2347 return Err(ParseError::InvalidResource( 2348 "openid scope does not accept suffixes".to_smolstr(), 2349 )); 2350 } 2351 Ok(Scope::OpenId) 2352 } 2353 2354 fn parse_profile(suffix: Option<&str>) -> Result<Self, ParseError> { 2355 if suffix.is_some() { 2356 return Err(ParseError::InvalidResource( 2357 "profile scope does not accept suffixes".to_smolstr(), 2358 )); 2359 } 2360 Ok(Scope::Profile) 2361 } 2362 2363 fn parse_email(suffix: Option<&str>) -> Result<Self, ParseError> { 2364 if suffix.is_some() { 2365 return Err(ParseError::InvalidResource( 2366 "email scope does not accept suffixes".to_smolstr(), 2367 )); 2368 } 2369 Ok(Scope::Email) 2370 } 2371} 2372 2373impl<S: BosStr + Ord> Scope<S> { 2374 /// Convert the scope to its normalized string representation 2375 pub fn to_string_normalized(&self) -> SmolStr { 2376 match self { 2377 Scope::Account(scope) => { 2378 let resource = match scope.resource { 2379 AccountResource::Email => "email", 2380 AccountResource::Repo => "repo", 2381 AccountResource::Status => "status", 2382 }; 2383 2384 match scope.action { 2385 AccountAction::Read => format_smolstr!("account:{}", resource), 2386 AccountAction::Manage => format_smolstr!("account:{}?action=manage", resource), 2387 } 2388 } 2389 Scope::Identity(scope) => match scope { 2390 IdentityScope::Handle => "identity:handle".to_smolstr(), 2391 IdentityScope::All => "identity:*".to_smolstr(), 2392 }, 2393 Scope::Blob(scope) => { 2394 if scope.accept.len() == 1 { 2395 if let Some(pattern) = scope.accept.iter().next() { 2396 match pattern { 2397 MimePattern::All => "blob:*/*".to_smolstr(), 2398 MimePattern::TypeWildcard(t) => { 2399 format_smolstr!("blob:{}/*", t.as_ref()) 2400 } 2401 MimePattern::Exact(mime) => format_smolstr!("blob:{}", mime.as_ref()), 2402 } 2403 } else { 2404 "blob:*/*".to_smolstr() 2405 } 2406 } else { 2407 let mut params = Vec::new(); 2408 for pattern in &scope.accept { 2409 match pattern { 2410 MimePattern::All => params.push("accept=*/*".to_smolstr()), 2411 MimePattern::TypeWildcard(t) => { 2412 params.push(format_smolstr!("accept={}/*", t.as_ref())) 2413 } 2414 MimePattern::Exact(mime) => { 2415 params.push(format_smolstr!("accept={}", mime.as_ref())) 2416 } 2417 } 2418 } 2419 params.sort(); 2420 format_smolstr!("blob?{}", params.join("&")) 2421 } 2422 } 2423 Scope::Repo(scope) => { 2424 let collection = match &scope.collection { 2425 RepoCollection::All => "*", 2426 RepoCollection::Nsid(nsid) => nsid, 2427 }; 2428 2429 if scope.actions.len() == 3 { 2430 format_smolstr!("repo:{}", collection) 2431 } else { 2432 let mut params = Vec::new(); 2433 for action in &scope.actions { 2434 match action { 2435 RepoAction::Create => params.push("action=create"), 2436 RepoAction::Update => params.push("action=update"), 2437 RepoAction::Delete => params.push("action=delete"), 2438 } 2439 } 2440 format_smolstr!("repo:{}?{}", collection, params.join("&")) 2441 } 2442 } 2443 Scope::Rpc(scope) => { 2444 if scope.lxm.len() == 1 2445 && scope.lxm.contains(&RpcLexicon::All) 2446 && scope.aud.len() == 1 2447 && scope.aud.contains(&RpcAudience::All) 2448 { 2449 "rpc:*".to_smolstr() 2450 } else if scope.lxm.len() == 1 2451 && scope.aud.len() == 1 2452 && scope.aud.contains(&RpcAudience::All) 2453 { 2454 if let Some(lxm) = scope.lxm.iter().next() { 2455 match lxm { 2456 RpcLexicon::All => "rpc:*".to_smolstr(), 2457 RpcLexicon::Nsid(nsid) => format_smolstr!("rpc:{}", nsid), 2458 } 2459 } else { 2460 "rpc:*".to_smolstr() 2461 } 2462 } else { 2463 let mut params = Vec::new(); 2464 2465 for lxm in &scope.lxm { 2466 match lxm { 2467 RpcLexicon::All => params.push("lxm=*".to_smolstr()), 2468 RpcLexicon::Nsid(nsid) => params.push(format_smolstr!("lxm={}", nsid)), 2469 } 2470 } 2471 2472 for aud in &scope.aud { 2473 match aud { 2474 RpcAudience::All => params.push("aud=*".to_smolstr()), 2475 RpcAudience::Did(did) => params.push(format_smolstr!("aud={}", did)), 2476 } 2477 } 2478 2479 params.sort(); 2480 2481 if params.is_empty() { 2482 "rpc:*".to_smolstr() 2483 } else { 2484 format_smolstr!("rpc?{}", params.join("&")) 2485 } 2486 } 2487 } 2488 Scope::Include(scope) => { 2489 if let Some(ref aud) = scope.audience { 2490 // Encode audience using fluent-uri Query encoder. 2491 // '#' is not in the Query table, so it gets encoded as %23. 2492 // DID-safe characters (:, ., etc.) are in the Query table 2493 // and pass through unencoded. 2494 let mut encoded = EString::<EncQuery>::new(); 2495 encoded.encode_str::<EncQuery>(aud.as_ref()); 2496 format_smolstr!("include:{}?aud={}", scope.nsid, encoded.as_str()) 2497 } else { 2498 format_smolstr!("include:{}", scope.nsid) 2499 } 2500 } 2501 Scope::Atproto => "atproto".to_smolstr(), 2502 Scope::Transition(scope) => match scope { 2503 TransitionScope::Generic => "transition:generic".to_smolstr(), 2504 TransitionScope::Email => "transition:email".to_smolstr(), 2505 TransitionScope::ChatBsky => "transition:chat.bsky".to_smolstr(), 2506 }, 2507 Scope::OpenId => "openid".to_smolstr(), 2508 Scope::Profile => "profile".to_smolstr(), 2509 Scope::Email => "email".to_smolstr(), 2510 } 2511 } 2512 2513 /// Check if this scope grants the permissions of another scope 2514 pub fn grants<T: BosStr>(&self, other: &Scope<T>) -> bool { 2515 match (self, other) { 2516 // Atproto only grants itself 2517 (Scope::Atproto, Scope::Atproto) => true, 2518 (Scope::Atproto, _) => false, 2519 // Nothing else grants atproto 2520 (_, Scope::Atproto) => false, 2521 // Transition scopes only grant themselves 2522 (Scope::Transition(a), Scope::Transition(b)) => a == b, 2523 // Other scopes don't grant transition scopes 2524 (_, Scope::Transition(_)) => false, 2525 (Scope::Transition(_), _) => false, 2526 // Include scopes only grant exact match (opaque until resolved). 2527 (Scope::Include(a), Scope::Include(b)) => { 2528 a.nsid.as_ref() == b.nsid.as_ref() 2529 && match (&a.audience, &b.audience) { 2530 (Some(a_aud), Some(b_aud)) => a_aud.as_ref() == b_aud.as_ref(), 2531 (None, None) => true, 2532 _ => false, 2533 } 2534 } 2535 (_, Scope::Include(_)) => false, 2536 (Scope::Include(_), _) => false, 2537 // OpenID Connect scopes only grant themselves 2538 (Scope::OpenId, Scope::OpenId) => true, 2539 (Scope::OpenId, _) => false, 2540 (_, Scope::OpenId) => false, 2541 (Scope::Profile, Scope::Profile) => true, 2542 (Scope::Profile, _) => false, 2543 (_, Scope::Profile) => false, 2544 (Scope::Email, Scope::Email) => true, 2545 (Scope::Email, _) => false, 2546 (_, Scope::Email) => false, 2547 (Scope::Account(a), Scope::Account(b)) => { 2548 a.resource == b.resource 2549 && matches!( 2550 (a.action, b.action), 2551 (AccountAction::Manage, _) | (AccountAction::Read, AccountAction::Read) 2552 ) 2553 } 2554 (Scope::Identity(a), Scope::Identity(b)) => matches!( 2555 (a, b), 2556 (IdentityScope::All, _) | (IdentityScope::Handle, IdentityScope::Handle) 2557 ), 2558 (Scope::Blob(a), Scope::Blob(b)) => { 2559 for b_pattern in &b.accept { 2560 let mut granted = false; 2561 for a_pattern in &a.accept { 2562 if a_pattern.grants(b_pattern) { 2563 granted = true; 2564 break; 2565 } 2566 } 2567 if !granted { 2568 return false; 2569 } 2570 } 2571 true 2572 } 2573 (Scope::Repo(a), Scope::Repo(b)) => { 2574 let collection_match = match (&a.collection, &b.collection) { 2575 (RepoCollection::All, _) => true, 2576 (RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => { 2577 a_nsid.as_ref() == b_nsid.as_ref() 2578 } 2579 _ => false, 2580 }; 2581 2582 if !collection_match { 2583 return false; 2584 } 2585 2586 b.actions.is_subset(&a.actions) || a.actions.len() == 3 2587 } 2588 (Scope::Rpc(a), Scope::Rpc(b)) => { 2589 let lxm_match = if a.lxm.iter().any(|l| matches!(l, RpcLexicon::All)) { 2590 true 2591 } else { 2592 b.lxm.iter().all(|b_lxm| match b_lxm { 2593 RpcLexicon::All => false, 2594 RpcLexicon::Nsid(b_nsid) => a.lxm.iter().any(|a_lxm| match a_lxm { 2595 RpcLexicon::All => false, 2596 RpcLexicon::Nsid(a_nsid) => a_nsid.as_ref() == b_nsid.as_ref(), 2597 }), 2598 }) 2599 }; 2600 2601 let aud_match = if a.aud.iter().any(|a| matches!(a, RpcAudience::All)) { 2602 true 2603 } else { 2604 b.aud.iter().all(|b_aud| match b_aud { 2605 RpcAudience::All => false, 2606 RpcAudience::Did(b_did) => a.aud.iter().any(|a_aud| match a_aud { 2607 RpcAudience::All => false, 2608 RpcAudience::Did(a_did) => a_did.as_ref() == b_did.as_ref(), 2609 }), 2610 }) 2611 }; 2612 2613 lxm_match && aud_match 2614 } 2615 _ => false, 2616 } 2617 } 2618} 2619 2620impl<S: BosStr> MimePattern<S> { 2621 fn grants<T: BosStr>(&self, other: &MimePattern<T>) -> bool { 2622 match (self, other) { 2623 (MimePattern::All, _) => true, 2624 (MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => { 2625 // Compare as strings to support cross-type-parameter equality. 2626 a_type.as_ref() == b_type.as_ref() 2627 } 2628 (MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => b_mime 2629 .as_ref() 2630 .starts_with(format_smolstr!("{}/", a_type.as_ref()).as_str()), 2631 (MimePattern::Exact(a), MimePattern::Exact(b)) => a.as_ref() == b.as_ref(), 2632 _ => false, 2633 } 2634 } 2635} 2636 2637impl<S: BosStr + FromStr> FromStr for MimePattern<S> 2638where 2639 <S as FromStr>::Err: core::fmt::Debug, 2640{ 2641 type Err = ParseError; 2642 2643 fn from_str(s: &str) -> Result<Self, Self::Err> { 2644 if s == "*/*" { 2645 Ok(MimePattern::All) 2646 } else if let Some(stripped) = s.strip_suffix("/*") { 2647 Ok(MimePattern::TypeWildcard(S::from_str(stripped).unwrap())) 2648 } else if s.contains('/') { 2649 Ok(MimePattern::Exact(S::from_str(s).unwrap())) 2650 } else { 2651 Err(ParseError::InvalidMimeType(s.to_smolstr())) 2652 } 2653 } 2654} 2655 2656impl<'a, S: BosStr + From<&'a str>> TryFrom<&'a str> for MimePattern<S> { 2657 type Error = ParseError; 2658 2659 fn try_from(s: &'a str) -> Result<Self, Self::Error> { 2660 if s == "*/*" { 2661 Ok(MimePattern::All) 2662 } else if let Some(stripped) = s.strip_suffix("/*") { 2663 Ok(MimePattern::TypeWildcard(S::from(stripped))) 2664 } else if s.contains('/') { 2665 Ok(MimePattern::Exact(S::from(s))) 2666 } else { 2667 Err(ParseError::InvalidMimeType(s.to_smolstr())) 2668 } 2669 } 2670} 2671 2672impl<S: BosStr + Ord> fmt::Display for Scope<S> { 2673 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 2674 write!(f, "{}", self.to_string_normalized()) 2675 } 2676} 2677 2678/// Parse a query string into a map of keys to lists of values 2679fn parse_query_string(query: &str) -> BTreeMap<SmolStr, Vec<&str>> { 2680 let mut params = BTreeMap::new(); 2681 2682 for pair in query.split('&') { 2683 if let Some(pos) = pair.find('=') { 2684 let key = &pair[..pos]; 2685 let value = &pair[pos + 1..]; 2686 params 2687 .entry(key.to_smolstr()) 2688 .or_insert_with(Vec::new) 2689 .push(value); 2690 } 2691 } 2692 2693 params 2694} 2695 2696/// Error type for permission set expansion and conversion 2697#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] 2698#[non_exhaustive] 2699pub enum PermissionSetConversionError { 2700 /// Unknown identity attribute in permission set 2701 #[error("unknown identity attribute: {0}")] 2702 UnknownIdentityAttr(SmolStr), 2703 2704 /// Unknown account attribute in permission set 2705 #[error("unknown account attribute: {0}")] 2706 UnknownAccountAttr(SmolStr), 2707 2708 /// Invalid MIME pattern in blob permission 2709 #[error("invalid MIME pattern: {0}")] 2710 InvalidMimePattern(SmolStr), 2711} 2712 2713/// Error type for scope parsing 2714#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 2715#[non_exhaustive] 2716pub enum ParseError { 2717 /// Unknown scope prefix 2718 UnknownPrefix(SmolStr), 2719 /// Missing required resource 2720 MissingResource, 2721 /// Invalid resource type 2722 InvalidResource(SmolStr), 2723 /// Invalid action type 2724 InvalidAction(SmolStr), 2725 /// Invalid MIME type 2726 InvalidMimeType(SmolStr), 2727 /// An AT Protocol string type (DID, NSID, etc.) failed validation during scope parsing. 2728 ParseError(#[from] AtStrError), 2729} 2730 2731impl fmt::Display for ParseError { 2732 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 2733 match self { 2734 ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix), 2735 ParseError::MissingResource => write!(f, "Missing required resource"), 2736 ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource), 2737 ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action), 2738 ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime), 2739 ParseError::ParseError(err) => write!(f, "Parse error: {}", err), 2740 } 2741 } 2742} 2743 2744/// Convert a resolved permission set into its constituent scope values. 2745/// 2746/// Each permission entry expands to one or more concrete scopes: 2747/// - Repo: one `Scope::Repo` per collection NSID 2748/// - Rpc: one `Scope::Rpc` per lxm NSID (with shared aud) 2749/// - Blob: one `Scope::Blob` with all accept patterns 2750/// - Identity: `Scope::Identity` based on attr 2751/// - Account: `Scope::Account` based on attr and action 2752/// `inherited_audience` is the audience from the `include:` scope's `?aud=` 2753/// parameter. Passed to RPC permissions with `inherit_aud: true`. 2754#[cfg(feature = "scope-check")] 2755pub fn expand_permission_set( 2756 perm_set: &jacquard_lexicon::lexicon::LexPermissionSet<'static>, 2757 inherited_audience: Option<&DidService<SmolStr>>, 2758) -> Result<Vec<Scope<SmolStr>>, PermissionSetConversionError> { 2759 use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource}; 2760 2761 let mut scopes = Vec::new(); 2762 2763 for perm in &perm_set.permissions { 2764 let LexPermission::Permission { resource } = perm; 2765 match resource { 2766 LexPermissionResource::Repo { collection, action } => { 2767 let actions = action 2768 .as_ref() 2769 .map(|a| a.iter().copied().collect()) 2770 .unwrap_or_else(|| { 2771 let mut all = BTreeSet::new(); 2772 all.insert(RepoAction::Create); 2773 all.insert(RepoAction::Update); 2774 all.insert(RepoAction::Delete); 2775 all 2776 }); 2777 2778 for col_nsid in collection { 2779 scopes.push(Scope::Repo(RepoScope { 2780 collection: RepoCollection::Nsid(col_nsid.clone().convert()), 2781 actions: actions.clone(), 2782 })); 2783 } 2784 } 2785 LexPermissionResource::Rpc { 2786 lxm, 2787 aud, 2788 inherit_aud, 2789 } => { 2790 // Build the audience set based on priority order 2791 let mut aud_set = BTreeSet::new(); 2792 if let Some(explicit_aud) = aud { 2793 aud_set.insert(RpcAudience::Did(explicit_aud.clone().convert())); 2794 } else if inherit_aud.unwrap_or(false) && inherited_audience.is_some() { 2795 aud_set.insert(RpcAudience::Did(inherited_audience.unwrap().clone())); 2796 } else { 2797 aud_set.insert(RpcAudience::All); 2798 } 2799 2800 // Create one RpcScope with all lxm NSIDs and the resolved audience 2801 let mut lxm_set = BTreeSet::new(); 2802 for lxm_nsid in lxm { 2803 lxm_set.insert(RpcLexicon::Nsid(lxm_nsid.clone().convert())); 2804 } 2805 2806 if !lxm_set.is_empty() { 2807 scopes.push(Scope::Rpc(RpcScope { 2808 lxm: lxm_set, 2809 aud: aud_set, 2810 })); 2811 } 2812 } 2813 LexPermissionResource::Blob { accept, .. } => { 2814 let mut patterns = BTreeSet::new(); 2815 for mime_type in accept { 2816 let pattern_str = mime_type.as_ref(); 2817 match validate_mime_pattern(pattern_str) { 2818 Ok(kind) => { 2819 // For TypeWildcard, strip the `/*` suffix before storing. 2820 let mime_str = match kind { 2821 MimePatternKind::TypeWildcard => { 2822 SmolStr::new(&pattern_str[..pattern_str.len() - 2]) 2823 } 2824 _ => SmolStr::new(pattern_str), 2825 }; 2826 let pattern = unsafe { MimePattern::unchecked(mime_str, kind) }; 2827 patterns.insert(pattern); 2828 } 2829 Err(_) => { 2830 return Err(PermissionSetConversionError::InvalidMimePattern( 2831 pattern_str.to_smolstr(), 2832 )); 2833 } 2834 } 2835 } 2836 2837 if !patterns.is_empty() { 2838 scopes.push(Scope::Blob(BlobScope { accept: patterns })); 2839 } 2840 } 2841 LexPermissionResource::Identity { attr } => { 2842 let identity_scope = match attr.as_ref() { 2843 "handle" => IdentityScope::Handle, 2844 "*" => IdentityScope::All, 2845 other => { 2846 return Err(PermissionSetConversionError::UnknownIdentityAttr( 2847 other.to_smolstr(), 2848 )); 2849 } 2850 }; 2851 scopes.push(Scope::Identity(identity_scope)); 2852 } 2853 LexPermissionResource::Account { attr, action } => { 2854 let resource = match attr.as_ref() { 2855 "email" => AccountResource::Email, 2856 "repo" => AccountResource::Repo, 2857 "status" => AccountResource::Status, 2858 other => { 2859 return Err(PermissionSetConversionError::UnknownAccountAttr( 2860 other.to_smolstr(), 2861 )); 2862 } 2863 }; 2864 2865 // Take the highest privilege level. Manage subsumes Read. 2866 let act = action 2867 .as_ref() 2868 .map(|actions| { 2869 if actions.contains(&AccountAction::Manage) { 2870 AccountAction::Manage 2871 } else { 2872 AccountAction::Read 2873 } 2874 }) 2875 .unwrap_or(AccountAction::Read); 2876 2877 scopes.push(Scope::Account(AccountScope { 2878 resource, 2879 action: act, 2880 })); 2881 } 2882 } 2883 } 2884 2885 Ok(scopes) 2886} 2887 2888#[cfg(test)] 2889mod tests { 2890 use super::*; 2891 #[cfg(feature = "scope-check")] 2892 use jacquard_common::CowStr; 2893 use jacquard_common::xrpc::{XrpcMethod, XrpcResp}; 2894 use serde::{Deserialize, Serialize}; 2895 2896 #[derive(Debug, Serialize, Deserialize)] 2897 struct TestRequest; 2898 2899 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] 2900 #[error("test error")] 2901 struct TestError; 2902 2903 struct TestResponse; 2904 2905 impl XrpcResp for TestResponse { 2906 const NSID: &'static str = "app.bsky.feed.getTimeline"; 2907 const ENCODING: &'static str = "application/json"; 2908 type Output<S: BosStr> = (); 2909 type Err = TestError; 2910 } 2911 2912 impl XrpcRequest for TestRequest { 2913 const NSID: &'static str = "app.bsky.feed.getTimeline"; 2914 const METHOD: XrpcMethod = XrpcMethod::Query; 2915 type Response = TestResponse; 2916 } 2917 2918 struct TestCollection; 2919 2920 impl Collection for TestCollection { 2921 const NSID: &'static str = "app.bsky.feed.post"; 2922 type Record = TestResponse; 2923 } 2924 2925 #[test] 2926 fn test_scope_constructors() { 2927 assert_eq!(Scope::atproto().to_string_normalized(), "atproto"); 2928 assert_eq!( 2929 Scope::identity_handle().to_string_normalized(), 2930 "identity:handle" 2931 ); 2932 assert_eq!( 2933 Scope::account_repo_manage().to_string_normalized(), 2934 "account:repo?action=manage" 2935 ); 2936 assert_eq!(Scope::repo_all().to_string_normalized(), "repo:*"); 2937 assert_eq!( 2938 Scope::repo_create("app.bsky.feed.post") 2939 .unwrap() 2940 .to_string_normalized(), 2941 "repo:app.bsky.feed.post?action=create" 2942 ); 2943 assert_eq!( 2944 Scope::rpc("app.bsky.feed.getTimeline") 2945 .unwrap() 2946 .to_string_normalized(), 2947 "rpc:app.bsky.feed.getTimeline" 2948 ); 2949 assert_eq!( 2950 Scope::rpc_aud( 2951 "app.bsky.feed.getTimeline", 2952 "did:plc:yfvwmnlztr4dwkb7hwz55r2g" 2953 ) 2954 .unwrap() 2955 .to_string_normalized(), 2956 "rpc?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=app.bsky.feed.getTimeline" 2957 ); 2958 assert_eq!( 2959 Scope::include_aud("app.bsky.authFull", "did:web:example.com#svc") 2960 .unwrap() 2961 .to_string_normalized(), 2962 "include:app.bsky.authFull?aud=did:web:example.com%23svc" 2963 ); 2964 } 2965 2966 #[test] 2967 fn test_endpoint_and_collection_scope_constructors() { 2968 assert_eq!( 2969 Scope::rpc_request::<TestRequest>().to_string_normalized(), 2970 "rpc:app.bsky.feed.getTimeline" 2971 ); 2972 assert_eq!( 2973 Scope::repo_create_record::<TestCollection>().to_string_normalized(), 2974 "repo:app.bsky.feed.post?action=create" 2975 ); 2976 assert_eq!( 2977 Scope::repo_all_record::<TestCollection>().to_string_normalized(), 2978 "repo:app.bsky.feed.post" 2979 ); 2980 } 2981 2982 #[test] 2983 fn test_scopes_from_scopes_and_builder() { 2984 let scopes = Scopes::from_scopes([ 2985 Scope::repo_create("app.bsky.feed.post").unwrap(), 2986 Scope::repo_all(), 2987 Scope::atproto(), 2988 ]) 2989 .unwrap(); 2990 2991 assert_eq!(scopes.to_normalized_string(), "atproto repo:*"); 2992 2993 let built = Scopes::builder() 2994 .atproto() 2995 .transition_generic() 2996 .rpc_request::<TestRequest>() 2997 .repo_create_record::<TestCollection>() 2998 .include("app.bsky.authFull") 2999 .unwrap() 3000 .build() 3001 .unwrap(); 3002 3003 assert_eq!( 3004 built.to_normalized_string(), 3005 "atproto include:app.bsky.authFull repo:app.bsky.feed.post?action=create rpc:app.bsky.feed.getTimeline transition:generic" 3006 ); 3007 } 3008 3009 #[test] 3010 fn test_single_scope_include_parsing() { 3011 let scope: Scope = 3012 Scope::parse("include:app.bsky.authFull?aud=did:web:example.com#svc").unwrap(); 3013 assert_eq!( 3014 scope.to_string_normalized(), 3015 "include:app.bsky.authFull?aud=did:web:example.com%23svc" 3016 ); 3017 } 3018 3019 #[test] 3020 fn test_account_scope_parsing() { 3021 let scope: Scope = Scope::parse("account:email").unwrap(); 3022 assert_eq!( 3023 scope, 3024 Scope::Account(AccountScope { 3025 resource: AccountResource::Email, 3026 action: AccountAction::Read, 3027 }) 3028 ); 3029 3030 let scope: Scope = Scope::parse("account:repo?action=manage").unwrap(); 3031 assert_eq!( 3032 scope, 3033 Scope::Account(AccountScope { 3034 resource: AccountResource::Repo, 3035 action: AccountAction::Manage, 3036 }) 3037 ); 3038 3039 let scope: Scope = Scope::parse("account:status?action=read").unwrap(); 3040 assert_eq!( 3041 scope, 3042 Scope::Account(AccountScope { 3043 resource: AccountResource::Status, 3044 action: AccountAction::Read, 3045 }) 3046 ); 3047 } 3048 3049 #[test] 3050 fn test_identity_scope_parsing() { 3051 let scope: Scope = Scope::parse("identity:handle").unwrap(); 3052 assert_eq!(scope, Scope::Identity(IdentityScope::Handle)); 3053 3054 let scope: Scope = Scope::parse("identity:*").unwrap(); 3055 assert_eq!(scope, Scope::Identity(IdentityScope::All)); 3056 } 3057 3058 #[test] 3059 fn test_blob_scope_parsing() { 3060 let scope: Scope = Scope::parse("blob:*/*").unwrap(); 3061 let mut accept = BTreeSet::new(); 3062 accept.insert(MimePattern::All); 3063 assert_eq!(scope, Scope::Blob(BlobScope { accept })); 3064 3065 let scope: Scope<SmolStr> = Scope::parse("blob:image/png").unwrap(); 3066 let mut accept = BTreeSet::new(); 3067 accept.insert(MimePattern::Exact(SmolStr::new_static("image/png"))); 3068 assert_eq!(scope, Scope::Blob(BlobScope { accept })); 3069 3070 let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(); 3071 let mut accept = BTreeSet::new(); 3072 accept.insert(MimePattern::Exact(SmolStr::new_static("image/png"))); 3073 accept.insert(MimePattern::Exact(SmolStr::new_static("image/jpeg"))); 3074 assert_eq!(scope, Scope::Blob(BlobScope { accept })); 3075 3076 let scope = Scope::parse("blob:image/*").unwrap(); 3077 let mut accept = BTreeSet::new(); 3078 accept.insert(MimePattern::TypeWildcard(SmolStr::new_static("image"))); 3079 assert_eq!(scope, Scope::Blob(BlobScope { accept })); 3080 } 3081 3082 #[test] 3083 fn test_repo_scope_parsing() { 3084 let scope: Scope<SmolStr> = Scope::parse("repo:*?action=create").unwrap(); 3085 let mut actions = BTreeSet::new(); 3086 actions.insert(RepoAction::Create); 3087 assert_eq!( 3088 scope, 3089 Scope::Repo(RepoScope { 3090 collection: RepoCollection::All, 3091 actions, 3092 }) 3093 ); 3094 3095 let scope: Scope = 3096 Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(); 3097 let mut actions = BTreeSet::new(); 3098 actions.insert(RepoAction::Create); 3099 actions.insert(RepoAction::Update); 3100 assert_eq!( 3101 scope, 3102 Scope::Repo(RepoScope { 3103 collection: RepoCollection::Nsid(Nsid::new_owned("app.bsky.feed.post").unwrap()), 3104 actions, 3105 }) 3106 ); 3107 3108 let scope: Scope = Scope::parse("repo:app.bsky.feed.post").unwrap(); 3109 let mut actions = BTreeSet::new(); 3110 actions.insert(RepoAction::Create); 3111 actions.insert(RepoAction::Update); 3112 actions.insert(RepoAction::Delete); 3113 assert_eq!( 3114 scope, 3115 Scope::Repo(RepoScope { 3116 collection: RepoCollection::Nsid(Nsid::new_owned("app.bsky.feed.post").unwrap()), 3117 actions, 3118 }) 3119 ); 3120 } 3121 3122 #[test] 3123 fn test_rpc_scope_parsing() { 3124 let scope: Scope = Scope::parse("rpc:*").unwrap(); 3125 let mut lxm = BTreeSet::new(); 3126 let mut aud = BTreeSet::new(); 3127 lxm.insert(RpcLexicon::All); 3128 aud.insert(RpcAudience::All); 3129 assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 3130 3131 let scope: Scope<SmolStr> = Scope::parse("rpc:com.example.service").unwrap(); 3132 let mut lxm = BTreeSet::new(); 3133 let mut aud = BTreeSet::new(); 3134 lxm.insert(RpcLexicon::Nsid( 3135 Nsid::new_static("com.example.service").unwrap(), 3136 )); 3137 aud.insert(RpcAudience::All); 3138 assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 3139 3140 let scope: Scope = 3141 Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(); 3142 let mut lxm = BTreeSet::new(); 3143 let mut aud = BTreeSet::new(); 3144 lxm.insert(RpcLexicon::Nsid( 3145 Nsid::new_owned("com.example.service").unwrap(), 3146 )); 3147 aud.insert(RpcAudience::Did( 3148 DidService::new_owned("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), 3149 )); 3150 assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 3151 3152 let scope: Scope = 3153 Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g") 3154 .unwrap(); 3155 let mut lxm = BTreeSet::new(); 3156 let mut aud = BTreeSet::new(); 3157 lxm.insert(RpcLexicon::Nsid( 3158 Nsid::new_owned("com.example.method1").unwrap(), 3159 )); 3160 lxm.insert(RpcLexicon::Nsid( 3161 Nsid::new_owned("com.example.method2").unwrap(), 3162 )); 3163 aud.insert(RpcAudience::Did( 3164 DidService::new_owned("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), 3165 )); 3166 assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 3167 } 3168 3169 #[test] 3170 fn test_scope_normalization() { 3171 let tests = vec![ 3172 ("account:email", "account:email"), 3173 ("account:email?action=read", "account:email"), 3174 ("account:email?action=manage", "account:email?action=manage"), 3175 ("blob:image/png", "blob:image/png"), 3176 ( 3177 "blob?accept=image/jpeg&accept=image/png", 3178 "blob?accept=image/jpeg&accept=image/png", 3179 ), 3180 ("repo:app.bsky.feed.post", "repo:app.bsky.feed.post"), 3181 ( 3182 "repo:app.bsky.feed.post?action=create", 3183 "repo:app.bsky.feed.post?action=create", 3184 ), 3185 ("rpc:*", "rpc:*"), 3186 ]; 3187 3188 for (input, expected) in tests { 3189 let scope: Scope = Scope::parse(input).unwrap(); 3190 assert_eq!(scope.to_string_normalized(), expected); 3191 } 3192 } 3193 3194 #[test] 3195 fn test_account_scope_grants() { 3196 let manage: Scope = Scope::parse("account:email?action=manage").unwrap(); 3197 let read: Scope = Scope::parse("account:email?action=read").unwrap(); 3198 let other_read: Scope = Scope::parse("account:repo?action=read").unwrap(); 3199 3200 assert!(manage.grants(&read)); 3201 assert!(manage.grants(&manage)); 3202 assert!(!read.grants(&manage)); 3203 assert!(read.grants(&read)); 3204 assert!(!read.grants(&other_read)); 3205 } 3206 3207 #[test] 3208 fn test_identity_scope_grants() { 3209 let all: Scope = Scope::parse("identity:*").unwrap(); 3210 let handle: Scope = Scope::parse("identity:handle").unwrap(); 3211 3212 assert!(all.grants(&handle)); 3213 assert!(all.grants(&all)); 3214 assert!(!handle.grants(&all)); 3215 assert!(handle.grants(&handle)); 3216 } 3217 3218 #[test] 3219 fn test_blob_scope_grants() { 3220 let all: Scope = Scope::parse("blob:*/*").unwrap(); 3221 let image_all: Scope = Scope::parse("blob:image/*").unwrap(); 3222 let image_png: Scope = Scope::parse("blob:image/png").unwrap(); 3223 let text_plain: Scope = Scope::parse("blob:text/plain").unwrap(); 3224 3225 assert!(all.grants(&image_all)); 3226 assert!(all.grants(&image_png)); 3227 assert!(all.grants(&text_plain)); 3228 assert!(image_all.grants(&image_png)); 3229 assert!(!image_all.grants(&text_plain)); 3230 assert!(!image_png.grants(&image_all)); 3231 } 3232 3233 #[test] 3234 fn test_repo_scope_grants() { 3235 let all_all: Scope = Scope::parse("repo:*").unwrap(); 3236 let all_create: Scope = Scope::parse("repo:*?action=create").unwrap(); 3237 let specific_all: Scope = Scope::parse("repo:app.bsky.feed.post").unwrap(); 3238 let specific_create: Scope = Scope::parse("repo:app.bsky.feed.post?action=create").unwrap(); 3239 let other_create: Scope = 3240 Scope::parse("repo:pub.leaflet.publication?action=create").unwrap(); 3241 3242 assert!(all_all.grants(&all_create)); 3243 assert!(all_all.grants(&specific_all)); 3244 assert!(all_all.grants(&specific_create)); 3245 assert!(all_create.grants(&all_create)); 3246 assert!(!all_create.grants(&specific_all)); 3247 assert!(specific_all.grants(&specific_create)); 3248 assert!(!specific_create.grants(&specific_all)); 3249 assert!(!specific_create.grants(&other_create)); 3250 } 3251 3252 #[test] 3253 fn test_rpc_scope_grants() { 3254 let all: Scope = Scope::parse("rpc:*").unwrap(); 3255 let specific_lxm: Scope = Scope::parse("rpc:com.example.service").unwrap(); 3256 let specific_both: Scope = 3257 Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(); 3258 3259 assert!(all.grants(&specific_lxm)); 3260 assert!(all.grants(&specific_both)); 3261 assert!(specific_lxm.grants(&specific_both)); 3262 assert!(!specific_both.grants(&specific_lxm)); 3263 assert!(!specific_both.grants(&all)); 3264 } 3265 3266 #[test] 3267 fn test_cross_scope_grants() { 3268 let account: Scope = Scope::parse("account:email").unwrap(); 3269 let identity: Scope = Scope::parse("identity:handle").unwrap(); 3270 3271 assert!(!account.grants(&identity)); 3272 assert!(!identity.grants(&account)); 3273 } 3274 3275 #[test] 3276 fn test_parse_errors() { 3277 assert!(matches!( 3278 Scope::<SmolStr>::parse("unknown:test"), 3279 Err(ParseError::UnknownPrefix(_)) 3280 )); 3281 3282 assert!(matches!( 3283 Scope::<SmolStr>::parse("account"), 3284 Err(ParseError::MissingResource) 3285 )); 3286 3287 assert!(matches!( 3288 Scope::<SmolStr>::parse("account:invalid"), 3289 Err(ParseError::InvalidResource(_)) 3290 )); 3291 3292 assert!(matches!( 3293 Scope::<SmolStr>::parse("account:email?action=invalid"), 3294 Err(ParseError::InvalidAction(_)) 3295 )); 3296 } 3297 3298 #[test] 3299 fn test_query_parameter_sorting() { 3300 let scope = Scope::<SmolStr>::parse( 3301 "blob?accept=image/png&accept=application/pdf&accept=image/jpeg", 3302 ) 3303 .unwrap(); 3304 let normalized = scope.to_string_normalized(); 3305 assert!(normalized.contains("accept=application/pdf")); 3306 assert!(normalized.contains("accept=image/jpeg")); 3307 assert!(normalized.contains("accept=image/png")); 3308 let pdf_pos = normalized.find("accept=application/pdf").unwrap(); 3309 let jpeg_pos = normalized.find("accept=image/jpeg").unwrap(); 3310 let png_pos = normalized.find("accept=image/png").unwrap(); 3311 assert!(pdf_pos < jpeg_pos); 3312 assert!(jpeg_pos < png_pos); 3313 } 3314 3315 #[test] 3316 fn test_repo_action_wildcard() { 3317 let scope = Scope::<SmolStr>::parse("repo:app.bsky.feed.post?action=*").unwrap(); 3318 let mut actions = BTreeSet::new(); 3319 actions.insert(RepoAction::Create); 3320 actions.insert(RepoAction::Update); 3321 actions.insert(RepoAction::Delete); 3322 assert_eq!( 3323 scope, 3324 Scope::Repo(RepoScope { 3325 collection: RepoCollection::Nsid(Nsid::new_owned("app.bsky.feed.post").unwrap()), 3326 actions, 3327 }) 3328 ); 3329 } 3330 3331 #[test] 3332 fn test_multiple_blob_accepts() { 3333 let scope = Scope::<SmolStr>::parse("blob?accept=image/*&accept=text/plain").unwrap(); 3334 assert!(scope.grants(&Scope::<SmolStr>::parse("blob:image/png").unwrap())); 3335 assert!(scope.grants(&Scope::<SmolStr>::parse("blob:text/plain").unwrap())); 3336 assert!(!scope.grants(&Scope::<SmolStr>::parse("blob:application/json").unwrap())); 3337 } 3338 3339 #[test] 3340 fn test_rpc_default_wildcards() { 3341 let scope = Scope::<SmolStr>::parse("rpc").unwrap(); 3342 let mut lxm = BTreeSet::new(); 3343 let mut aud = BTreeSet::new(); 3344 lxm.insert(RpcLexicon::All); 3345 aud.insert(RpcAudience::All); 3346 assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 3347 } 3348 3349 #[test] 3350 fn test_atproto_scope_parsing() { 3351 let scope = Scope::<SmolStr>::parse("atproto").unwrap(); 3352 assert_eq!(scope, Scope::Atproto); 3353 3354 // Atproto should not accept suffixes 3355 assert!(Scope::<SmolStr>::parse("atproto:something").is_err()); 3356 assert!(Scope::<SmolStr>::parse("atproto?param=value").is_err()); 3357 } 3358 3359 #[test] 3360 fn test_transition_scope_parsing() { 3361 let scope = Scope::<SmolStr>::parse("transition:generic").unwrap(); 3362 assert_eq!(scope, Scope::Transition(TransitionScope::Generic)); 3363 3364 let scope = Scope::<SmolStr>::parse("transition:email").unwrap(); 3365 assert_eq!(scope, Scope::Transition(TransitionScope::Email)); 3366 3367 // Test invalid transition types 3368 assert!(matches!( 3369 Scope::<SmolStr>::parse("transition:invalid"), 3370 Err(ParseError::InvalidResource(_)) 3371 )); 3372 3373 // Test missing suffix 3374 assert!(matches!( 3375 Scope::<SmolStr>::parse("transition"), 3376 Err(ParseError::MissingResource) 3377 )); 3378 3379 // Test transition doesn't accept query parameters 3380 assert!(matches!( 3381 Scope::<SmolStr>::parse("transition:generic?param=value"), 3382 Err(ParseError::InvalidResource(_)) 3383 )); 3384 } 3385 3386 #[test] 3387 fn test_atproto_scope_normalization() { 3388 let scope = Scope::<SmolStr>::parse("atproto").unwrap(); 3389 assert_eq!(scope.to_string_normalized(), "atproto"); 3390 } 3391 3392 #[test] 3393 fn test_transition_scope_normalization() { 3394 let tests = vec![ 3395 ("transition:generic", "transition:generic"), 3396 ("transition:email", "transition:email"), 3397 ]; 3398 3399 for (input, expected) in tests { 3400 let scope = Scope::<SmolStr>::parse(input).unwrap(); 3401 assert_eq!(scope.to_string_normalized(), expected); 3402 } 3403 } 3404 3405 #[test] 3406 fn test_transition_chat_bsky() { 3407 // Test parsing. 3408 let scope = Scope::<SmolStr>::parse("transition:chat.bsky").unwrap(); 3409 assert_eq!(scope, Scope::Transition(TransitionScope::ChatBsky)); 3410 3411 // Test serialization. 3412 assert_eq!(scope.to_string_normalized(), "transition:chat.bsky"); 3413 3414 // Test grants itself. 3415 let other: Scope<SmolStr> = Scope::Transition(TransitionScope::ChatBsky); 3416 assert!(scope.grants(&other)); 3417 3418 // Test doesn't grant other transition scopes. 3419 let generic: Scope<SmolStr> = Scope::Transition(TransitionScope::Generic); 3420 let email: Scope<SmolStr> = Scope::Transition(TransitionScope::Email); 3421 assert!(!scope.grants(&generic)); 3422 assert!(!scope.grants(&email)); 3423 3424 // Test other scopes don't grant ChatBsky. 3425 assert!(!generic.grants(&scope)); 3426 assert!(!email.grants(&scope)); 3427 3428 // Test typo is rejected. 3429 assert!(matches!( 3430 Scope::<SmolStr>::parse("transition:chat.bsk"), 3431 Err(ParseError::InvalidResource(_)) 3432 )); 3433 } 3434 3435 #[test] 3436 fn test_include_scope_serialisation() { 3437 // Test with audience containing '#' — should encode as %23. 3438 let scope: Scope<SmolStr> = Scope::Include(IncludeScope { 3439 nsid: Nsid::new_static("app.bsky.full").unwrap(), 3440 audience: Some(SmolStr::new_static("did:web:api.example.com#svc_appview")), 3441 }); 3442 assert_eq!( 3443 scope.to_string_normalized(), 3444 "include:app.bsky.full?aud=did:web:api.example.com%23svc_appview" 3445 ); 3446 3447 // Test without audience. 3448 let scope: Scope<SmolStr> = Scope::Include(IncludeScope { 3449 nsid: Nsid::new_static("app.bsky.authFull").unwrap(), 3450 audience: None, 3451 }); 3452 assert_eq!(scope.to_string_normalized(), "include:app.bsky.authFull"); 3453 3454 // Test with simple audience (no '#'). 3455 let scope: Scope<SmolStr> = Scope::Include(IncludeScope { 3456 nsid: Nsid::new_static("com.example.perm").unwrap(), 3457 audience: Some(SmolStr::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g")), 3458 }); 3459 assert_eq!( 3460 scope.to_string_normalized(), 3461 "include:com.example.perm?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g" 3462 ); 3463 } 3464 3465 #[test] 3466 fn test_include_scope_grants() { 3467 // Test identical include scopes grant each other. 3468 let scope1: Scope<SmolStr> = Scope::Include(IncludeScope { 3469 nsid: Nsid::new_static("app.bsky.full").unwrap(), 3470 audience: Some(SmolStr::new_static("did:web:api.example.com")), 3471 }); 3472 let scope2: Scope<SmolStr> = Scope::Include(IncludeScope { 3473 nsid: Nsid::new_static("app.bsky.full").unwrap(), 3474 audience: Some(SmolStr::new_static("did:web:api.example.com")), 3475 }); 3476 assert!(scope1.grants(&scope2)); 3477 3478 // Test different NSIDs don't grant. 3479 let scope3: Scope<SmolStr> = Scope::Include(IncludeScope { 3480 nsid: Nsid::new_static("app.bsky.authFull").unwrap(), 3481 audience: Some(SmolStr::new_static("did:web:api.example.com")), 3482 }); 3483 assert!(!scope1.grants(&scope3)); 3484 3485 // Test same NSID but different audiences don't grant. 3486 let scope4: Scope<SmolStr> = Scope::Include(IncludeScope { 3487 nsid: Nsid::new_static("app.bsky.full").unwrap(), 3488 audience: Some(SmolStr::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g")), 3489 }); 3490 assert!(!scope1.grants(&scope4)); 3491 3492 // Test audience vs no audience don't grant. 3493 let scope5: Scope<SmolStr> = Scope::Include(IncludeScope { 3494 nsid: Nsid::new_static("app.bsky.full").unwrap(), 3495 audience: None, 3496 }); 3497 assert!(!scope1.grants(&scope5)); 3498 3499 // Test no-audience scopes grant each other only if NSID matches. 3500 let scope6: Scope<SmolStr> = Scope::Include(IncludeScope { 3501 nsid: Nsid::new_static("app.bsky.full").unwrap(), 3502 audience: None, 3503 }); 3504 assert!(scope5.grants(&scope6)); 3505 3506 // Test non-include scopes don't grant include scopes and vice versa. 3507 let account = Scope::<SmolStr>::parse("account:email").unwrap(); 3508 assert!(!account.grants(&scope1)); 3509 assert!(!scope1.grants(&account)); 3510 } 3511 3512 #[test] 3513 fn test_atproto_scope_grants() { 3514 let atproto = Scope::<SmolStr>::parse("atproto").unwrap(); 3515 let account = Scope::<SmolStr>::parse("account:email").unwrap(); 3516 let identity = Scope::<SmolStr>::parse("identity:handle").unwrap(); 3517 let blob = Scope::<SmolStr>::parse("blob:image/png").unwrap(); 3518 let repo = Scope::<SmolStr>::parse("repo:app.bsky.feed.post").unwrap(); 3519 let rpc = Scope::<SmolStr>::parse("rpc:com.example.service").unwrap(); 3520 let transition_generic = Scope::<SmolStr>::parse("transition:generic").unwrap(); 3521 let transition_email = Scope::<SmolStr>::parse("transition:email").unwrap(); 3522 3523 // Atproto only grants itself (it's a required scope, not a permission grant) 3524 assert!(atproto.grants(&atproto)); 3525 assert!(!atproto.grants(&account)); 3526 assert!(!atproto.grants(&identity)); 3527 assert!(!atproto.grants(&blob)); 3528 assert!(!atproto.grants(&repo)); 3529 assert!(!atproto.grants(&rpc)); 3530 assert!(!atproto.grants(&transition_generic)); 3531 assert!(!atproto.grants(&transition_email)); 3532 3533 // Nothing else grants atproto 3534 assert!(!account.grants(&atproto)); 3535 assert!(!identity.grants(&atproto)); 3536 assert!(!blob.grants(&atproto)); 3537 assert!(!repo.grants(&atproto)); 3538 assert!(!rpc.grants(&atproto)); 3539 assert!(!transition_generic.grants(&atproto)); 3540 assert!(!transition_email.grants(&atproto)); 3541 } 3542 3543 #[test] 3544 fn test_transition_scope_grants() { 3545 let transition_generic = Scope::<SmolStr>::parse("transition:generic").unwrap(); 3546 let transition_email = Scope::<SmolStr>::parse("transition:email").unwrap(); 3547 let account = Scope::<SmolStr>::parse("account:email").unwrap(); 3548 3549 // Transition scopes only grant themselves 3550 assert!(transition_generic.grants(&transition_generic)); 3551 assert!(transition_email.grants(&transition_email)); 3552 assert!(!transition_generic.grants(&transition_email)); 3553 assert!(!transition_email.grants(&transition_generic)); 3554 3555 // Transition scopes don't grant other scope types 3556 assert!(!transition_generic.grants(&account)); 3557 assert!(!transition_email.grants(&account)); 3558 3559 // Other scopes don't grant transition scopes 3560 assert!(!account.grants(&transition_generic)); 3561 assert!(!account.grants(&transition_email)); 3562 } 3563 3564 #[test] 3565 fn test_openid_connect_scope_parsing() { 3566 // Test OpenID scope 3567 let scope = Scope::<SmolStr>::parse("openid").unwrap(); 3568 assert_eq!(scope, Scope::OpenId); 3569 3570 // Test Profile scope 3571 let scope = Scope::<SmolStr>::parse("profile").unwrap(); 3572 assert_eq!(scope, Scope::Profile); 3573 3574 // Test Email scope 3575 let scope = Scope::<SmolStr>::parse("email").unwrap(); 3576 assert_eq!(scope, Scope::Email); 3577 3578 // Test that they don't accept suffixes 3579 assert!(Scope::<SmolStr>::parse("openid:something").is_err()); 3580 assert!(Scope::<SmolStr>::parse("profile:something").is_err()); 3581 assert!(Scope::<SmolStr>::parse("email:something").is_err()); 3582 3583 // Test that they don't accept query parameters 3584 assert!(Scope::<SmolStr>::parse("openid?param=value").is_err()); 3585 assert!(Scope::<SmolStr>::parse("profile?param=value").is_err()); 3586 assert!(Scope::<SmolStr>::parse("email?param=value").is_err()); 3587 } 3588 3589 #[test] 3590 fn test_openid_connect_scope_normalization() { 3591 let scope = Scope::<SmolStr>::parse("openid").unwrap(); 3592 assert_eq!(scope.to_string_normalized(), "openid"); 3593 3594 let scope = Scope::<SmolStr>::parse("profile").unwrap(); 3595 assert_eq!(scope.to_string_normalized(), "profile"); 3596 3597 let scope = Scope::<SmolStr>::parse("email").unwrap(); 3598 assert_eq!(scope.to_string_normalized(), "email"); 3599 } 3600 3601 #[test] 3602 fn test_openid_connect_scope_grants() { 3603 let openid = Scope::<SmolStr>::parse("openid").unwrap(); 3604 let profile = Scope::<SmolStr>::parse("profile").unwrap(); 3605 let email = Scope::<SmolStr>::parse("email").unwrap(); 3606 let account = Scope::<SmolStr>::parse("account:email").unwrap(); 3607 3608 // OpenID Connect scopes only grant themselves 3609 assert!(openid.grants(&openid)); 3610 assert!(!openid.grants(&profile)); 3611 assert!(!openid.grants(&email)); 3612 assert!(!openid.grants(&account)); 3613 3614 assert!(profile.grants(&profile)); 3615 assert!(!profile.grants(&openid)); 3616 assert!(!profile.grants(&email)); 3617 assert!(!profile.grants(&account)); 3618 3619 assert!(email.grants(&email)); 3620 assert!(!email.grants(&openid)); 3621 assert!(!email.grants(&profile)); 3622 assert!(!email.grants(&account)); 3623 3624 // Other scopes don't grant OpenID Connect scopes 3625 assert!(!account.grants(&openid)); 3626 assert!(!account.grants(&profile)); 3627 assert!(!account.grants(&email)); 3628 } 3629 3630 // ======================================================================== 3631 // Tests for Task 1: Scopes<S> container and constructor 3632 // ======================================================================== 3633 3634 #[test] 3635 fn test_scopes_new_multiple() { 3636 // Test AC3.1: Parse multiple scopes and create indices. 3637 let scopes = 3638 Scopes::new(SmolStr::new_static("atproto rpc:* repo:app.bsky.feed.post")).unwrap(); 3639 assert_eq!(scopes.len(), 3); 3640 } 3641 3642 #[test] 3643 fn test_scopes_new_empty() { 3644 // Test AC3.7: Empty string produces empty Scopes. 3645 let scopes = Scopes::new(SmolStr::new_static("")).unwrap(); 3646 assert!(scopes.is_empty()); 3647 } 3648 3649 #[test] 3650 fn test_scopes_new_with_spaces() { 3651 // Test consecutive spaces are handled. 3652 let scopes = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 3653 assert_eq!(scopes.len(), 2); 3654 } 3655 3656 #[test] 3657 fn test_scopes_new_invalid_scope() { 3658 // Test AC3.8: Invalid scope is rejected. 3659 let result = Scopes::new(SmolStr::new_static("atproto badscope")); 3660 assert!(result.is_err()); 3661 match result.unwrap_err() { 3662 ParseError::UnknownPrefix(_) => {} 3663 e => panic!("expected UnknownPrefix error, got {:?}", e), 3664 } 3665 } 3666 3667 #[test] 3668 fn test_scopes_buffer_size_limit() { 3669 // Test buffer exceeding u16 limit is rejected. 3670 let too_long = "a".repeat(u16::MAX as usize + 1); 3671 let smol = too_long.as_str().to_smolstr(); 3672 let result = Scopes::new(smol); 3673 assert!(result.is_err()); 3674 } 3675 3676 #[test] 3677 fn test_scopes_unit_scope_parsing() { 3678 // Test each unit scope parses correctly. 3679 let test_cases = vec![ 3680 ("atproto", 1), 3681 ("openid", 1), 3682 ("profile", 1), 3683 ("email", 1), 3684 ("atproto openid profile", 3), 3685 ]; 3686 3687 for (input, expected_count) in test_cases { 3688 let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3689 assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3690 } 3691 } 3692 3693 #[test] 3694 fn test_scopes_account_scope_parsing() { 3695 // Test account scopes parse correctly. 3696 let test_cases = vec![ 3697 ("account:email", 1), 3698 ("account:repo", 1), 3699 ("account:status", 1), 3700 ("account:email?action=manage", 1), 3701 ("account:email?action=read", 1), 3702 ]; 3703 3704 for (input, expected_count) in test_cases { 3705 let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3706 assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3707 } 3708 } 3709 3710 #[test] 3711 fn test_scopes_identity_scope_parsing() { 3712 // Test identity scopes parse correctly. 3713 let test_cases = vec![ 3714 ("identity:handle", 1), 3715 ("identity:*", 1), 3716 ("identity:handle identity:*", 2), 3717 ]; 3718 3719 for (input, expected_count) in test_cases { 3720 let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3721 assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3722 } 3723 } 3724 3725 #[test] 3726 fn test_scopes_blob_scope_parsing() { 3727 // Test blob scopes parse correctly. 3728 let test_cases = vec![ 3729 ("blob:*/*", 1), 3730 ("blob:image/png", 1), 3731 ("blob:image/*", 1), 3732 ("blob?accept=image/png&accept=image/jpeg", 1), 3733 ]; 3734 3735 for (input, expected_count) in test_cases { 3736 let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3737 assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3738 } 3739 } 3740 3741 #[test] 3742 fn test_scopes_repo_scope_parsing() { 3743 // Test repo scopes parse correctly. 3744 let test_cases = vec![ 3745 ("repo:*", 1), 3746 ("repo:app.bsky.feed.post", 1), 3747 ("repo:app.bsky.feed.post?action=create", 1), 3748 ("repo:app.bsky.feed.post?action=create&action=update", 1), 3749 ]; 3750 3751 for (input, expected_count) in test_cases { 3752 let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3753 assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3754 } 3755 } 3756 3757 #[test] 3758 fn test_scopes_rpc_scope_parsing() { 3759 // Test rpc scopes parse correctly. 3760 let test_cases = vec![ 3761 ("rpc:*", 1), 3762 ("rpc:com.example.service", 1), 3763 ("rpc:com.example.service?aud=did:web:example.com", 1), 3764 ("rpc?lxm=com.example.service&aud=did:web:example.com", 1), 3765 ]; 3766 3767 for (input, expected_count) in test_cases { 3768 let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3769 assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3770 } 3771 } 3772 3773 #[test] 3774 fn test_scopes_include_scope_parsing() { 3775 // Test include scopes parse correctly. 3776 let test_cases = vec![ 3777 ("include:app.bsky.authFull", 1), 3778 ("include:app.bsky.full?aud=did:web:api.example.com", 1), 3779 ( 3780 "include:app.bsky.full?aud=did:web:api.example.com%23svc_appview", 3781 1, 3782 ), 3783 ]; 3784 3785 for (input, expected_count) in test_cases { 3786 let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3787 assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3788 } 3789 } 3790 3791 #[test] 3792 fn test_scopes_include_missing_nsid() { 3793 // Test AC2.6: include: with no NSID is rejected. 3794 let result = Scopes::new(SmolStr::new_static("include:")); 3795 assert!(result.is_err()); 3796 } 3797 3798 #[test] 3799 fn test_scopes_include_invalid_audience_did() { 3800 // Test AC2.7: include scope with invalid DID audience is rejected. 3801 let result = Scopes::new(SmolStr::new_static("include:app.bsky.authFull?aud=notadid")); 3802 assert!(result.is_err()); 3803 } 3804 3805 #[test] 3806 fn test_scopes_transition_scope_parsing() { 3807 // Test transition scopes parse correctly. 3808 let test_cases = vec![ 3809 ("transition:generic", 1), 3810 ("transition:email", 1), 3811 ("transition:chat.bsky", 1), 3812 ("transition:generic transition:email", 2), 3813 ]; 3814 3815 for (input, expected_count) in test_cases { 3816 let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3817 assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3818 } 3819 } 3820 3821 #[test] 3822 fn test_scopes_reduction_removes_broader_scope() { 3823 // Test that broader scopes subsume narrower ones. 3824 // repo:* grants repo:app.bsky.feed.post, so only repo:* should remain. 3825 let scopes = Scopes::new(SmolStr::new_static("repo:app.bsky.feed.post repo:*")).unwrap(); 3826 assert_eq!(scopes.len(), 1); 3827 } 3828 3829 // ======================================================================== 3830 // Tests for Task 2: Scope reconstruction from indices (via Scopes container) 3831 // ======================================================================== 3832 3833 #[test] 3834 fn test_scopes_reconstruction_unit() { 3835 // Reconstruct unit scopes from indices. 3836 let scopes = Scopes::new(SmolStr::new_static("atproto openid")).unwrap(); 3837 assert_eq!(scopes.len(), 2); 3838 } 3839 3840 #[test] 3841 fn test_scopes_reconstruction_account() { 3842 // Reconstruct account scopes from indices. 3843 let scopes = Scopes::new(SmolStr::new_static( 3844 "account:email account:repo?action=manage", 3845 )) 3846 .unwrap(); 3847 assert_eq!(scopes.len(), 2); 3848 } 3849 3850 #[test] 3851 fn test_scopes_reconstruction_identity() { 3852 // Reconstruct identity scopes from indices. 3853 let scopes = Scopes::new(SmolStr::new_static("identity:handle identity:*")).unwrap(); 3854 assert_eq!(scopes.len(), 2); 3855 } 3856 3857 #[test] 3858 fn test_scopes_reconstruction_blob() { 3859 // Reconstruct blob scopes from indices. 3860 let scopes = Scopes::new(SmolStr::new_static("blob:image/png blob:*/*")).unwrap(); 3861 assert_eq!(scopes.len(), 2); 3862 } 3863 3864 #[test] 3865 fn test_scopes_reconstruction_repo() { 3866 // Reconstruct repo scopes from indices. 3867 let scopes = Scopes::new(SmolStr::new_static("repo:app.bsky.feed.post repo:*")).unwrap(); 3868 // repo:* grants repo:app.bsky.feed.post, so only repo:* should remain. 3869 assert_eq!(scopes.len(), 1); 3870 } 3871 3872 #[test] 3873 fn test_scopes_reconstruction_rpc() { 3874 // Reconstruct rpc scopes from indices. 3875 let scopes = Scopes::new(SmolStr::new_static("rpc:com.example.service rpc:*")).unwrap(); 3876 // rpc:* grants rpc:com.example.service, so only rpc:* should remain. 3877 assert_eq!(scopes.len(), 1); 3878 } 3879 3880 #[test] 3881 fn test_scopes_reconstruction_include() { 3882 // Reconstruct include scopes from indices. 3883 let scopes = Scopes::new(SmolStr::new_static( 3884 "include:app.bsky.authFull include:app.bsky.full?aud=did:web:api.example.com", 3885 )) 3886 .unwrap(); 3887 assert_eq!(scopes.len(), 2); 3888 } 3889 3890 // ======================================================================== 3891 // Task 3: Accessor Tests 3892 // ======================================================================== 3893 3894 #[test] 3895 fn test_scopes_iter() { 3896 // oauth-scopes-rework.AC3.2: `iter()` yields correctly typed `Scope<&str>` 3897 // views borrowing from the buffer. 3898 let scopes = 3899 Scopes::new(SmolStr::new_static("atproto rpc:* repo:app.bsky.feed.post")).unwrap(); 3900 3901 let collected: Vec<_> = scopes.iter().collect(); 3902 3903 // Verify we got scopes back 3904 assert!(!collected.is_empty()); 3905 3906 // Verify we can iterate and get expected scope types 3907 let has_atproto = collected.iter().any(|s| matches!(s, Scope::Atproto)); 3908 let has_rpc = collected.iter().any(|s| matches!(s, Scope::Rpc(_))); 3909 let has_repo = collected.iter().any(|s| matches!(s, Scope::Repo(_))); 3910 3911 assert!(has_atproto, "Should contain Atproto scope"); 3912 assert!(has_rpc, "Should contain Rpc scope"); 3913 assert!(has_repo, "Should contain Repo scope"); 3914 } 3915 3916 #[test] 3917 fn test_scopes_get() { 3918 // Test `get()` accessor for positional index access. 3919 let scopes = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 3920 assert_eq!(scopes.len(), 2); 3921 3922 let first = scopes.get(0).expect("First scope should exist"); 3923 match first { 3924 Scope::Atproto => (), 3925 _ => panic!("Expected Atproto scope"), 3926 } 3927 3928 let second = scopes.get(1).expect("Second scope should exist"); 3929 match second { 3930 Scope::Rpc(_) => (), 3931 _ => panic!("Expected Rpc scope"), 3932 } 3933 3934 let third = scopes.get(2); 3935 assert!(third.is_none(), "Third scope should not exist"); 3936 } 3937 3938 #[test] 3939 fn test_scopes_get_owned() { 3940 // oauth-scopes-rework.AC3.3: `get_owned()` returns `Scope<SmolStr>` 3941 // independent of the buffer's lifetime. 3942 let scopes = Scopes::new(SmolStr::new_static("atproto repo:app.bsky.feed.post")).unwrap(); 3943 assert_eq!(scopes.len(), 2); 3944 3945 let owned = scopes.get_owned(0).expect("First scope should exist"); 3946 match owned { 3947 Scope::Atproto => (), 3948 _ => panic!("Expected Atproto scope"), 3949 } 3950 3951 let repo_owned = scopes.get_owned(1).expect("Second scope should exist"); 3952 match repo_owned { 3953 Scope::Repo(_) => (), 3954 _ => panic!("Expected Repo scope"), 3955 } 3956 3957 let none = scopes.get_owned(99); 3958 assert!(none.is_none(), "Out-of-bounds access should return None"); 3959 } 3960 3961 #[test] 3962 fn test_scopes_get_as() { 3963 // Test `get_as()` with caller-chosen backing type. 3964 let scopes = Scopes::new(SmolStr::new_static("atproto")).unwrap(); 3965 3966 // Convert to String backing 3967 let as_string: Option<Scope<String>> = scopes.get_as(0); 3968 assert!(as_string.is_some()); 3969 match as_string { 3970 Some(Scope::Atproto) => (), 3971 _ => panic!("Expected Atproto scope as String"), 3972 } 3973 3974 // Verify get_as handles out of bounds 3975 let out_of_bounds: Option<Scope<String>> = scopes.get_as(10); 3976 assert!(out_of_bounds.is_none()); 3977 } 3978 3979 #[test] 3980 fn test_scopes_iter_multiple() { 3981 // Verify iterator works with multiple scope types. 3982 let scopes = Scopes::new(SmolStr::new_static( 3983 "atproto rpc:* repo:app.bsky.feed.post account:email identity:handle", 3984 )) 3985 .unwrap(); 3986 3987 let mut count = 0; 3988 for scope in scopes.iter() { 3989 count += 1; 3990 // Just verify we can iterate and get back a Scope 3991 let _ = scope; 3992 } 3993 3994 assert_eq!(count, scopes.len()); 3995 } 3996 3997 #[test] 3998 fn test_scopes_iter_empty() { 3999 // Verify iterator works on empty Scopes. 4000 let scopes = Scopes::new(SmolStr::new_static("")).unwrap(); 4001 assert!(scopes.is_empty()); 4002 4003 let collected: Vec<_> = scopes.iter().collect(); 4004 assert_eq!(collected.len(), 0); 4005 } 4006 4007 // ======================================================================== 4008 // Task 4: Conversion Tests 4009 // ======================================================================== 4010 4011 #[test] 4012 fn test_scopes_borrow() { 4013 // oauth-scopes-rework.AC3.4: `borrow()` produces `Scopes<&str>` cheaply. 4014 let original: Scopes<SmolStr> = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 4015 assert_eq!(original.len(), 2); 4016 4017 let borrowed: Scopes<&str> = original.borrow(); 4018 assert_eq!(borrowed.len(), 2); 4019 4020 // Verify the borrowed version has the same scopes 4021 let iter_count: usize = borrowed.iter().count(); 4022 assert_eq!(iter_count, 2); 4023 4024 // Verify content matches 4025 let orig_iter = original.iter().collect::<Vec<_>>(); 4026 let borrow_iter = borrowed.iter().collect::<Vec<_>>(); 4027 assert_eq!(orig_iter.len(), borrow_iter.len()); 4028 } 4029 4030 #[test] 4031 fn test_scopes_convert() { 4032 // oauth-scopes-rework.AC3.5: `convert()` produces correct backing type conversions. 4033 let original: Scopes<SmolStr> = Scopes::new(SmolStr::new_static("atproto repo:*")).unwrap(); 4034 assert_eq!(original.len(), 2); 4035 4036 // Convert to String 4037 let converted: Scopes<String> = original.convert(); 4038 assert_eq!(converted.len(), 2); 4039 4040 // Verify content is preserved 4041 let converted_iter = converted.iter().collect::<Vec<_>>(); 4042 assert_eq!(converted_iter.len(), 2); 4043 4044 match &converted_iter[0] { 4045 Scope::Atproto => (), 4046 _ => panic!("Expected Atproto scope"), 4047 } 4048 } 4049 4050 #[test] 4051 fn test_scopes_into_static() { 4052 // Test IntoStatic trait implementation. 4053 use jacquard_common::CowStr; 4054 4055 let original = Scopes::new(CowStr::copy_from_str("atproto rpc:*")).unwrap(); 4056 assert_eq!(original.len(), 2); 4057 4058 let static_scopes = original.into_static(); 4059 assert_eq!(static_scopes.len(), 2); 4060 4061 // Verify content is preserved 4062 let iter_count: usize = static_scopes.iter().count(); 4063 assert_eq!(iter_count, 2); 4064 } 4065 4066 #[test] 4067 fn test_scopes_conversions_preserve_content() { 4068 // Verify that all conversion methods preserve scope content. 4069 let input = "atproto repo:app.bsky.feed.post?action=create account:repo"; 4070 let original: Scopes<SmolStr> = Scopes::new(SmolStr::new(input)).unwrap(); 4071 let original_count = original.len(); 4072 4073 // Test borrow 4074 let borrowed = original.borrow(); 4075 assert_eq!(borrowed.len(), original_count); 4076 4077 // Verify both have the same normalized output before converting 4078 let orig_normalized = original.to_normalized_string(); 4079 let borrow_normalized = borrowed.to_normalized_string(); 4080 assert_eq!(orig_normalized, borrow_normalized); 4081 4082 // Test convert (this moves original) 4083 let converted: Scopes<String> = original.convert(); 4084 assert_eq!(converted.len(), original_count); 4085 4086 let conv_normalized = converted.to_normalized_string(); 4087 assert_eq!(orig_normalized, conv_normalized); 4088 } 4089 4090 // ======================================================================== 4091 // Task 5: Serialize Tests 4092 // ======================================================================== 4093 4094 #[test] 4095 fn test_scopes_serialize_single() { 4096 // Test serialization of a single scope. 4097 let scopes = Scopes::new(SmolStr::new_static("atproto")).unwrap(); 4098 let json = serde_json::to_string(&scopes).unwrap(); 4099 assert_eq!(json, "\"atproto\""); 4100 } 4101 4102 #[test] 4103 fn test_scopes_serialize_multiple_sorted() { 4104 // oauth-scopes-rework.AC3.6: Serialize produces sorted output 4105 // regardless of input order. 4106 let scopes = 4107 Scopes::new(SmolStr::new_static("rpc:* atproto repo:app.bsky.feed.post")).unwrap(); 4108 let json = serde_json::to_string(&scopes).unwrap(); 4109 // Should be sorted: atproto, repo:app.bsky.feed.post, rpc:* 4110 assert_eq!(json, "\"atproto repo:app.bsky.feed.post rpc:*\""); 4111 } 4112 4113 #[test] 4114 fn test_scopes_serialize_empty() { 4115 // Test serialization of empty Scopes. 4116 let scopes: Scopes<SmolStr> = Scopes::empty(); 4117 let json = serde_json::to_string(&scopes).unwrap(); 4118 assert_eq!(json, "\"\""); 4119 } 4120 4121 #[test] 4122 fn test_scopes_serialize_with_reduction() { 4123 // Test serialization when scopes reduce (e.g., repo:* includes repo:app.bsky.feed.post). 4124 let scopes = Scopes::new(SmolStr::new_static("repo:* repo:app.bsky.feed.post")).unwrap(); 4125 // Should reduce to just repo:* 4126 assert_eq!(scopes.len(), 1); 4127 let json = serde_json::to_string(&scopes).unwrap(); 4128 assert_eq!(json, "\"repo:*\""); 4129 } 4130 4131 #[test] 4132 fn test_scopes_serialize_includes_include_scope() { 4133 // Test serialization with include scope. 4134 let scopes = Scopes::new(SmolStr::new_static("atproto include:app.bsky.authFull")).unwrap(); 4135 let json = serde_json::to_string(&scopes).unwrap(); 4136 // Should be sorted and include normalized form 4137 assert_eq!(json, "\"atproto include:app.bsky.authFull\""); 4138 } 4139 4140 // ======================================================================== 4141 // Task 6: Deserialize Tests 4142 // ======================================================================== 4143 4144 #[test] 4145 fn test_scopes_deserialize_single() { 4146 // Test deserialization of a single scope. 4147 let json = "\"atproto\""; 4148 let scopes: Scopes<SmolStr> = serde_json::from_str(json).unwrap(); 4149 assert_eq!(scopes.len(), 1); 4150 match scopes.get(0) { 4151 Some(Scope::Atproto) => (), 4152 _ => panic!("Expected Atproto scope"), 4153 } 4154 } 4155 4156 #[test] 4157 fn test_scopes_deserialize_multiple() { 4158 // Test deserialization of multiple scopes. 4159 let json = "\"atproto rpc:* repo:app.bsky.feed.post\""; 4160 let scopes: Scopes<SmolStr> = serde_json::from_str(json).unwrap(); 4161 assert_eq!(scopes.len(), 3); 4162 } 4163 4164 #[test] 4165 fn test_scopes_deserialize_empty() { 4166 // Test deserialization of empty string. 4167 let json = "\"\""; 4168 let scopes: Scopes<SmolStr> = serde_json::from_str(json).unwrap(); 4169 assert_eq!(scopes.len(), 0); 4170 assert!(scopes.is_empty()); 4171 } 4172 4173 #[test] 4174 fn test_scopes_serde_roundtrip() { 4175 // oauth-scopes-rework.AC3.6: Round-trip test with sorting verification. 4176 let input = "rpc:* atproto repo:app.bsky.feed.post account:email"; 4177 let scopes: Scopes<SmolStr> = Scopes::new(SmolStr::new(input)).unwrap(); 4178 4179 // Serialize 4180 let json = serde_json::to_string(&scopes).unwrap(); 4181 4182 // Should be sorted 4183 assert_eq!( 4184 json, 4185 "\"account:email atproto repo:app.bsky.feed.post rpc:*\"" 4186 ); 4187 4188 // Deserialize 4189 let deserialized: Scopes<SmolStr> = serde_json::from_str(&json).unwrap(); 4190 4191 // Should have same len (reduction applied) 4192 assert_eq!(deserialized.len(), scopes.len()); 4193 4194 // Verify content matches by collecting scopes 4195 let orig_normalized = scopes.to_normalized_string(); 4196 let deser_normalized = deserialized.to_normalized_string(); 4197 assert_eq!(orig_normalized, deser_normalized); 4198 } 4199 4200 #[test] 4201 fn test_scopes_deserialize_invalid() { 4202 // Test deserialization of invalid scope. 4203 let json = "\"invalid:notagoodscope\""; 4204 let result: Result<Scopes<SmolStr>, _> = serde_json::from_str(json); 4205 assert!(result.is_err()); 4206 } 4207 4208 #[test] 4209 fn test_scopes_roundtrip_with_encoded_audience() { 4210 // AC2.3: include scope with audience (including special chars) can be serialized and deserialized. 4211 // Create an include scope with audience containing special character 4212 let input = "include:app.bsky.authFull?aud=did:web:example.com%23svc"; 4213 let scopes: Scopes<SmolStr> = Scopes::new(SmolStr::new(input)).unwrap(); 4214 assert_eq!(scopes.len(), 1); 4215 4216 // Serialize to JSON - should not panic 4217 let json = serde_json::to_string(&scopes).unwrap(); 4218 assert!(json.contains("include:app.bsky.authFull")); 4219 4220 // Deserialize back - should not panic 4221 let deserialized: Scopes<SmolStr> = serde_json::from_str(&json).unwrap(); 4222 4223 // Scopes should have the same length 4224 assert_eq!(scopes.len(), deserialized.len()); 4225 assert_eq!(deserialized.len(), 1); 4226 } 4227 4228 // ======================================================================== 4229 // Task 7: Convenience Methods Tests 4230 // ======================================================================== 4231 4232 #[test] 4233 fn test_scopes_len() { 4234 // AC3.1: len() returns correct count after reduction. 4235 let scopes = Scopes::new(SmolStr::new_static( 4236 "atproto repo:* repo:app.bsky.feed.post", 4237 )) 4238 .unwrap(); 4239 // repo:* should reduce the more specific one 4240 assert_eq!(scopes.len(), 2); // atproto + repo:* 4241 } 4242 4243 #[test] 4244 fn test_scopes_is_empty() { 4245 // AC3.7: is_empty() returns true for empty Scopes. 4246 let empty: Scopes<SmolStr> = Scopes::empty(); 4247 assert!(empty.is_empty()); 4248 4249 let nonempty = Scopes::new(SmolStr::new_static("atproto")).unwrap(); 4250 assert!(!nonempty.is_empty()); 4251 } 4252 4253 #[test] 4254 fn test_scopes_as_str() { 4255 // Test as_str() returns the raw buffer. 4256 let scopes = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 4257 let s = scopes.as_str(); 4258 assert_eq!(s, "atproto rpc:*"); 4259 } 4260 4261 #[test] 4262 fn test_scopes_to_normalized_string() { 4263 // Test to_normalized_string() produces same output as serialize. 4264 let scopes = Scopes::new(SmolStr::new_static("rpc:* atproto")).unwrap(); 4265 let normalized = scopes.to_normalized_string(); 4266 assert_eq!(normalized, "atproto rpc:*"); 4267 4268 // Serialize should match 4269 let json = serde_json::to_string(&scopes).unwrap(); 4270 assert_eq!(json, "\"atproto rpc:*\""); 4271 } 4272 4273 #[test] 4274 fn test_scopes_empty_constructor() { 4275 // Test Scopes::<SmolStr>::empty() creates empty container. 4276 let empty = Scopes::empty(); 4277 assert_eq!(empty.len(), 0); 4278 assert!(empty.is_empty()); 4279 assert_eq!(empty.to_normalized_string(), SmolStr::default()); 4280 } 4281 4282 #[test] 4283 fn test_scopes_default() { 4284 // Test Default trait for Scopes. 4285 // should return atproto scope 4286 let default: Scopes<SmolStr> = Default::default(); 4287 assert_eq!(default.buffer.as_str(), "atproto"); 4288 assert!(matches!(default.get(0), Some(Scope::Atproto))); 4289 } 4290 4291 #[test] 4292 fn test_scopes_grants_single() { 4293 // Test grants() method with single scope. 4294 let scopes = Scopes::new(SmolStr::new_static("repo:*")).unwrap(); 4295 let queried: Scope<SmolStr> = Scope::parse("repo:app.bsky.feed.post").unwrap(); 4296 assert!(scopes.grants(&queried)); 4297 4298 let queried2: Scope<SmolStr> = Scope::parse("atproto").unwrap(); 4299 assert!(!scopes.grants(&queried2)); 4300 } 4301 4302 #[test] 4303 fn test_scopes_grants_multiple() { 4304 // Test grants() with multiple scopes. 4305 let scopes = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 4306 let queried: Scope<SmolStr> = Scope::parse("rpc:com.atproto.server.createSession").unwrap(); 4307 assert!(scopes.grants(&queried)); 4308 } 4309 4310 #[test] 4311 fn test_scopes_construction() { 4312 // AC3.1: Construct multi-scope string, verify len and individual scopes. 4313 let scopes = 4314 Scopes::new(SmolStr::new_static("atproto rpc:* repo:app.bsky.feed.post")).unwrap(); 4315 assert_eq!(scopes.len(), 3); 4316 4317 // Verify individual scopes 4318 match scopes.get(0) { 4319 Some(Scope::Atproto) => (), 4320 _ => panic!("Expected Atproto at index 0"), 4321 } 4322 assert!(scopes.get(1).is_some()); 4323 assert!(scopes.get(2).is_some()); 4324 assert!(scopes.get(3).is_none()); 4325 } 4326 4327 #[test] 4328 fn test_scopes_empty_string() { 4329 // AC3.7: Empty string produces empty Scopes. 4330 let scopes = Scopes::new(SmolStr::new_static("")).unwrap(); 4331 assert_eq!(scopes.len(), 0); 4332 assert!(scopes.is_empty()); 4333 } 4334 4335 #[test] 4336 fn test_scopes_invalid_scope() { 4337 // AC3.8: Invalid scope in string causes construction failure. 4338 let result = Scopes::new(SmolStr::new("invalid:nosuchprefix")); 4339 assert!(result.is_err()); 4340 } 4341 4342 #[test] 4343 fn test_scopes_iter_collection() { 4344 // AC3.2: Iterate, collect, verify typed views. 4345 let scopes = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 4346 let collected: Vec<_> = scopes.iter().collect(); 4347 assert_eq!(collected.len(), 2); 4348 assert!(matches!(collected[0], Scope::Atproto)); 4349 } 4350 4351 #[test] 4352 fn test_scopes_consecutive_spaces() { 4353 // Test handling of multiple spaces between scopes. 4354 let scopes = Scopes::new(SmolStr::new("atproto rpc:*")).unwrap(); 4355 assert_eq!(scopes.len(), 2); 4356 } 4357 4358 #[test] 4359 fn test_scopes_reduction() { 4360 // Test scope reduction (broader scope removes more specific ones). 4361 let scopes = Scopes::new(SmolStr::new_static("repo:* repo:app.bsky.feed.post")).unwrap(); 4362 assert_eq!(scopes.len(), 1); // Should reduce to just repo:* 4363 } 4364 4365 #[test] 4366 fn test_scopes_include_no_audience() { 4367 // AC2.1: include scope with no audience parses correctly. 4368 let scopes = Scopes::new(SmolStr::new_static("include:app.bsky.authFull")).unwrap(); 4369 assert_eq!(scopes.len(), 1); 4370 match scopes.get(0) { 4371 Some(Scope::Include(inc)) => { 4372 assert_eq!(inc.nsid.as_ref(), "app.bsky.authFull"); 4373 assert_eq!(inc.audience, None); 4374 } 4375 _ => panic!("Expected Include scope"), 4376 } 4377 } 4378 4379 #[test] 4380 fn test_scopes_include_plain_audience() { 4381 // AC2.2: include scope with plain unencoded audience. 4382 let scopes = Scopes::new(SmolStr::new_static( 4383 "include:app.bsky.authFull?aud=did:web:api.example.com", 4384 )) 4385 .unwrap(); 4386 assert_eq!(scopes.len(), 1); 4387 match scopes.get(0) { 4388 Some(Scope::Include(inc)) => { 4389 assert_eq!(inc.nsid.as_ref(), "app.bsky.authFull"); 4390 assert!(inc.audience.is_some()); 4391 } 4392 _ => panic!("Expected Include scope"), 4393 } 4394 } 4395 4396 #[test] 4397 fn test_scopes_include_empty_nsid() { 4398 // AC2.6: include with no NSID is rejected. 4399 let result = Scopes::new(SmolStr::new("include:")); 4400 assert!(result.is_err()); 4401 } 4402 4403 #[test] 4404 fn test_scopes_include_invalid_did_audience() { 4405 // AC2.7: include with invalid DID audience is rejected. 4406 let result = Scopes::new(SmolStr::new("include:app.bsky.authFull?aud=notadid")); 4407 assert!(result.is_err()); 4408 } 4409 4410 #[test] 4411 fn test_scopes_all_prefixes() { 4412 // Test every scope prefix parses correctly in a Scopes container. 4413 let prefixes = vec![ 4414 "account:email", 4415 "identity:handle", 4416 "blob:*/*", 4417 "repo:*", 4418 "rpc:*", 4419 "atproto", 4420 "transition:generic", 4421 "openid", 4422 "profile", 4423 "email", 4424 ]; 4425 4426 for prefix in prefixes { 4427 let scopes = Scopes::new(SmolStr::new(prefix)).unwrap(); 4428 assert_eq!(scopes.len(), 1, "Failed to parse: {}", prefix); 4429 } 4430 } 4431 4432 #[test] 4433 fn test_scopes_borrow_borrowshare() { 4434 // AC3.4: borrow() produces Scopes<&str> with BorrowOrShare semantics. 4435 let original: Scopes<SmolStr> = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 4436 let borrowed: Scopes<&str> = original.borrow(); 4437 assert_eq!(borrowed.len(), original.len()); 4438 4439 // Both should iterate the same 4440 let orig_iter = original.iter().collect::<Vec<_>>(); 4441 let borrow_iter = borrowed.iter().collect::<Vec<_>>(); 4442 assert_eq!(orig_iter.len(), borrow_iter.len()); 4443 } 4444 4445 #[test] 4446 fn test_scopes_convert_type() { 4447 // AC3.5: convert() produces correct backing type. 4448 let original: Scopes<SmolStr> = Scopes::new(SmolStr::new_static("atproto")).unwrap(); 4449 let converted: Scopes<String> = original.convert(); 4450 assert_eq!(converted.len(), 1); 4451 assert!(matches!(converted.get(0), Some(Scope::Atproto))); 4452 } 4453 4454 #[test] 4455 fn test_scopes_bare_blob_defaults_to_all() { 4456 // Critical fix: bare `blob` token (without suffix) should default to MimePattern::All. 4457 // This tests that we don't store unsound byte ranges past the token. 4458 let scopes = Scopes::new(SmolStr::new("blob")).unwrap(); 4459 assert_eq!(scopes.len(), 1); 4460 4461 let scope = scopes.get(0).unwrap(); 4462 if let Scope::Blob(blob_scope) = scope { 4463 // Should accept all mime types. 4464 assert_eq!(blob_scope.accept.len(), 1); 4465 assert!(blob_scope.accept.contains(&MimePattern::All)); 4466 } else { 4467 panic!("Expected Scope::Blob, got {:?}", scope); 4468 } 4469 4470 // Verify reconstruction and normalization work. 4471 // Normalized form expands bare `blob` to explicit `blob:*/*`. 4472 let normalized = scopes.to_normalized_string(); 4473 assert_eq!(normalized, "blob:*/*"); 4474 } 4475 4476 #[test] 4477 fn test_scopes_bare_rpc_defaults_to_all() { 4478 // Critical fix: bare `rpc` token (without suffix) should default to all lexicons and audiences. 4479 // This tests that we don't store unsound byte ranges past the token. 4480 let scopes = Scopes::new(SmolStr::new("rpc")).unwrap(); 4481 assert_eq!(scopes.len(), 1); 4482 4483 let scope = scopes.get(0).unwrap(); 4484 if let Scope::Rpc(rpc_scope) = scope { 4485 // Should accept all lexicons and audiences. 4486 assert_eq!(rpc_scope.lxm.len(), 1); 4487 assert!(rpc_scope.lxm.contains(&RpcLexicon::All)); 4488 assert_eq!(rpc_scope.aud.len(), 1); 4489 assert!(rpc_scope.aud.contains(&RpcAudience::All)); 4490 } else { 4491 panic!("Expected Scope::Rpc, got {:?}", scope); 4492 } 4493 4494 // Verify reconstruction and normalization work. 4495 // Normalized form expands bare `rpc` to explicit `rpc:*`. 4496 let normalized = scopes.to_normalized_string(); 4497 assert_eq!(normalized, "rpc:*"); 4498 } 4499 4500 #[cfg(feature = "scope-check")] 4501 #[test] 4502 fn test_expand_permission_set_repo() { 4503 use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; 4504 4505 // Create a simple permission set with a repo permission 4506 let mut perms = Vec::new(); 4507 perms.push(LexPermission::Permission { 4508 resource: LexPermissionResource::Repo { 4509 collection: vec![ 4510 Nsid::new_static("app.bsky.feed.post").unwrap(), 4511 Nsid::new_static("app.bsky.graph.follow").unwrap(), 4512 ], 4513 action: Some(vec![RepoAction::Create]), 4514 }, 4515 }); 4516 4517 let perm_set = LexPermissionSet { 4518 title: None, 4519 title_lang: None, 4520 detail: None, 4521 detail_lang: None, 4522 permissions: perms, 4523 }; 4524 4525 let scopes = expand_permission_set(&perm_set, None).unwrap(); 4526 assert_eq!(scopes.len(), 2); 4527 4528 // Check that we got the expected repo scopes 4529 let mut found_post = false; 4530 let mut found_follow = false; 4531 4532 for scope in &scopes { 4533 if let Scope::Repo(repo_scope) = scope { 4534 if let RepoCollection::Nsid(nsid) = &repo_scope.collection { 4535 if nsid.as_ref() == "app.bsky.feed.post" { 4536 assert_eq!(repo_scope.actions.len(), 1); 4537 assert!(repo_scope.actions.contains(&RepoAction::Create)); 4538 found_post = true; 4539 } else if nsid.as_ref() == "app.bsky.graph.follow" { 4540 assert_eq!(repo_scope.actions.len(), 1); 4541 assert!(repo_scope.actions.contains(&RepoAction::Create)); 4542 found_follow = true; 4543 } 4544 } 4545 } 4546 } 4547 4548 assert!(found_post, "Expected post scope"); 4549 assert!(found_follow, "Expected follow scope"); 4550 } 4551 4552 #[cfg(feature = "scope-check")] 4553 #[test] 4554 fn test_expand_permission_set_identity() { 4555 use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; 4556 4557 let mut perms = Vec::new(); 4558 perms.push(LexPermission::Permission { 4559 resource: LexPermissionResource::Identity { 4560 attr: CowStr::Borrowed("handle"), 4561 }, 4562 }); 4563 4564 let perm_set = LexPermissionSet { 4565 title: None, 4566 title_lang: None, 4567 detail: None, 4568 detail_lang: None, 4569 permissions: perms, 4570 }; 4571 4572 let scopes = expand_permission_set(&perm_set, None).unwrap(); 4573 assert_eq!(scopes.len(), 1); 4574 4575 assert_eq!(scopes[0], Scope::Identity(IdentityScope::Handle)); 4576 } 4577 4578 #[cfg(feature = "scope-check")] 4579 #[test] 4580 fn test_expand_permission_set_account() { 4581 use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; 4582 4583 let mut perms = Vec::new(); 4584 perms.push(LexPermission::Permission { 4585 resource: LexPermissionResource::Account { 4586 attr: CowStr::Borrowed("email"), 4587 action: Some(vec![AccountAction::Manage]), 4588 }, 4589 }); 4590 4591 let perm_set = LexPermissionSet { 4592 title: None, 4593 title_lang: None, 4594 detail: None, 4595 detail_lang: None, 4596 permissions: perms, 4597 }; 4598 4599 let scopes = expand_permission_set(&perm_set, None).unwrap(); 4600 assert_eq!(scopes.len(), 1); 4601 4602 assert_eq!( 4603 scopes[0], 4604 Scope::Account(AccountScope { 4605 resource: AccountResource::Email, 4606 action: AccountAction::Manage, 4607 }) 4608 ); 4609 } 4610 4611 #[cfg(feature = "scope-check")] 4612 #[test] 4613 fn test_expand_permission_set_account_highest_privilege() { 4614 // Regression test: when both Read and Manage are in the action list, 4615 // the highest privilege (Manage) must be selected, not the first. 4616 use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; 4617 4618 let perm_set = LexPermissionSet { 4619 title: None, 4620 title_lang: None, 4621 detail: None, 4622 detail_lang: None, 4623 permissions: vec![LexPermission::Permission { 4624 resource: LexPermissionResource::Account { 4625 attr: CowStr::Borrowed("email"), 4626 action: Some(vec![AccountAction::Read, AccountAction::Manage]), 4627 }, 4628 }], 4629 }; 4630 4631 let scopes = expand_permission_set(&perm_set, None).unwrap(); 4632 assert_eq!(scopes.len(), 1); 4633 assert_eq!( 4634 scopes[0], 4635 Scope::Account(AccountScope { 4636 resource: AccountResource::Email, 4637 action: AccountAction::Manage, 4638 }), 4639 "should select Manage (highest privilege), not Read (first in list)" 4640 ); 4641 } 4642 4643 #[cfg(feature = "scope-check")] 4644 #[test] 4645 fn test_expand_permission_set_rpc_with_inherit_aud() { 4646 use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; 4647 4648 let mut perms = Vec::new(); 4649 perms.push(LexPermission::Permission { 4650 resource: LexPermissionResource::Rpc { 4651 lxm: vec![Nsid::new_static("app.bsky.feed.getTimeline").unwrap()], 4652 aud: None, 4653 inherit_aud: Some(true), 4654 }, 4655 }); 4656 4657 let perm_set = LexPermissionSet { 4658 title: None, 4659 title_lang: None, 4660 detail: None, 4661 detail_lang: None, 4662 permissions: perms, 4663 }; 4664 4665 let inherited_did = DidService::new_static("did:web:example.com").unwrap(); 4666 let scopes = expand_permission_set(&perm_set, Some(&inherited_did)).unwrap(); 4667 assert_eq!(scopes.len(), 1); 4668 4669 if let Scope::Rpc(rpc_scope) = &scopes[0] { 4670 assert_eq!(rpc_scope.lxm.len(), 1); 4671 assert_eq!(rpc_scope.aud.len(), 1); 4672 assert!( 4673 matches!(rpc_scope.aud.iter().next(), Some(RpcAudience::Did(d)) if d.as_ref() == "did:web:example.com") 4674 ); 4675 } else { 4676 panic!("Expected Rpc scope"); 4677 } 4678 } 4679 4680 #[cfg(feature = "scope-check")] 4681 #[test] 4682 fn test_expand_permission_set_rpc_explicit_aud() { 4683 use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; 4684 4685 let mut perms = Vec::new(); 4686 perms.push(LexPermission::Permission { 4687 resource: LexPermissionResource::Rpc { 4688 lxm: vec![Nsid::new_static("app.bsky.feed.getTimeline").unwrap()], 4689 aud: Some(DidService::new_static("did:web:custom.com").unwrap()), 4690 inherit_aud: None, 4691 }, 4692 }); 4693 4694 let perm_set = LexPermissionSet { 4695 title: None, 4696 title_lang: None, 4697 detail: None, 4698 detail_lang: None, 4699 permissions: perms, 4700 }; 4701 4702 let scopes = expand_permission_set(&perm_set, None).unwrap(); 4703 assert_eq!(scopes.len(), 1); 4704 4705 if let Scope::Rpc(rpc_scope) = &scopes[0] { 4706 assert_eq!(rpc_scope.aud.len(), 1); 4707 assert!( 4708 matches!(rpc_scope.aud.iter().next(), Some(RpcAudience::Did(d)) if d.as_ref() == "did:web:custom.com") 4709 ); 4710 } else { 4711 panic!("Expected Rpc scope"); 4712 } 4713 } 4714 4715 #[cfg(feature = "scope-check")] 4716 #[test] 4717 fn test_expand_permission_set_unknown_identity_attr() { 4718 use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; 4719 4720 let mut perms = Vec::new(); 4721 perms.push(LexPermission::Permission { 4722 resource: LexPermissionResource::Identity { 4723 attr: CowStr::Borrowed("invalid"), 4724 }, 4725 }); 4726 4727 let perm_set = LexPermissionSet { 4728 title: None, 4729 title_lang: None, 4730 detail: None, 4731 detail_lang: None, 4732 permissions: perms, 4733 }; 4734 4735 let result = expand_permission_set(&perm_set, None); 4736 assert!(matches!( 4737 result, 4738 Err(PermissionSetConversionError::UnknownIdentityAttr(_)) 4739 )); 4740 } 4741 4742 #[cfg(feature = "scope-check")] 4743 #[test] 4744 fn test_expand_permission_set_unknown_account_attr() { 4745 use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; 4746 4747 let mut perms = Vec::new(); 4748 perms.push(LexPermission::Permission { 4749 resource: LexPermissionResource::Account { 4750 attr: CowStr::Borrowed("invalid"), 4751 action: None, 4752 }, 4753 }); 4754 4755 let perm_set = LexPermissionSet { 4756 title: None, 4757 title_lang: None, 4758 detail: None, 4759 detail_lang: None, 4760 permissions: perms, 4761 }; 4762 4763 let result = expand_permission_set(&perm_set, None); 4764 assert!(matches!( 4765 result, 4766 Err(PermissionSetConversionError::UnknownAccountAttr(_)) 4767 )); 4768 } 4769 4770 #[cfg(feature = "scope-check")] 4771 #[test] 4772 fn test_expand_permission_set_blob() { 4773 use jacquard_common::types::blob::MimeType; 4774 use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; 4775 4776 // Test exact type 4777 let mut perms = Vec::new(); 4778 perms.push(LexPermission::Permission { 4779 resource: LexPermissionResource::Blob { 4780 accept: vec![MimeType::new(CowStr::Borrowed("image/png"))], 4781 max_size: None, 4782 }, 4783 }); 4784 4785 let perm_set = LexPermissionSet { 4786 title: None, 4787 title_lang: None, 4788 detail: None, 4789 detail_lang: None, 4790 permissions: perms, 4791 }; 4792 4793 let scopes = expand_permission_set(&perm_set, None).expect("should expand blob"); 4794 assert_eq!(scopes.len(), 1); 4795 match &scopes[0] { 4796 Scope::Blob(blob_scope) => { 4797 assert_eq!(blob_scope.accept.len(), 1); 4798 for pattern in &blob_scope.accept { 4799 if let MimePattern::Exact(s) = pattern { 4800 assert_eq!(s.as_ref() as &str, "image/png"); 4801 } else { 4802 panic!("expected Exact pattern"); 4803 } 4804 } 4805 } 4806 _ => panic!("expected Blob scope"), 4807 } 4808 4809 // Test type wildcard 4810 let mut perms = Vec::new(); 4811 perms.push(LexPermission::Permission { 4812 resource: LexPermissionResource::Blob { 4813 accept: vec![MimeType::new(CowStr::Borrowed("image/*"))], 4814 max_size: None, 4815 }, 4816 }); 4817 4818 let perm_set = LexPermissionSet { 4819 title: None, 4820 title_lang: None, 4821 detail: None, 4822 detail_lang: None, 4823 permissions: perms, 4824 }; 4825 4826 let scopes = expand_permission_set(&perm_set, None).expect("should expand blob"); 4827 assert_eq!(scopes.len(), 1); 4828 match &scopes[0] { 4829 Scope::Blob(blob_scope) => { 4830 assert_eq!(blob_scope.accept.len(), 1); 4831 // TypeWildcard should store only the type prefix (e.g., "image") 4832 for pattern in &blob_scope.accept { 4833 if let MimePattern::TypeWildcard(s) = pattern { 4834 assert_eq!(s.as_ref() as &str, "image"); 4835 } else { 4836 panic!("expected TypeWildcard pattern"); 4837 } 4838 } 4839 } 4840 _ => panic!("expected Blob scope"), 4841 } 4842 4843 // Test all wildcard 4844 let mut perms = Vec::new(); 4845 perms.push(LexPermission::Permission { 4846 resource: LexPermissionResource::Blob { 4847 accept: vec![MimeType::new(CowStr::Borrowed("*/*"))], 4848 max_size: None, 4849 }, 4850 }); 4851 4852 let perm_set = LexPermissionSet { 4853 title: None, 4854 title_lang: None, 4855 detail: None, 4856 detail_lang: None, 4857 permissions: perms, 4858 }; 4859 4860 let scopes = expand_permission_set(&perm_set, None).expect("should expand blob"); 4861 assert_eq!(scopes.len(), 1); 4862 match &scopes[0] { 4863 Scope::Blob(blob_scope) => { 4864 assert_eq!(blob_scope.accept.len(), 1); 4865 assert!( 4866 blob_scope 4867 .accept 4868 .iter() 4869 .any(|p| matches!(p, MimePattern::All)) 4870 ); 4871 } 4872 _ => panic!("expected Blob scope"), 4873 } 4874 } 4875 4876 #[cfg(feature = "scope-check")] 4877 #[test] 4878 fn test_expand_permission_set_blob_invalid_mime() { 4879 use jacquard_common::types::blob::MimeType; 4880 use jacquard_lexicon::lexicon::{LexPermission, LexPermissionResource, LexPermissionSet}; 4881 4882 let mut perms = Vec::new(); 4883 perms.push(LexPermission::Permission { 4884 resource: LexPermissionResource::Blob { 4885 accept: vec![MimeType::new(CowStr::Borrowed("invalid-mime-type"))], 4886 max_size: None, 4887 }, 4888 }); 4889 4890 let perm_set = LexPermissionSet { 4891 title: None, 4892 title_lang: None, 4893 detail: None, 4894 detail_lang: None, 4895 permissions: perms, 4896 }; 4897 4898 let result = expand_permission_set(&perm_set, None); 4899 assert!(matches!( 4900 result, 4901 Err(PermissionSetConversionError::InvalidMimePattern(_)) 4902 )); 4903 } 4904}