A better Rust ATProto crate
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}