A better Rust ATProto crate
0
fork

Configure Feed

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

[jacquard-oauth] add new scope types, index structures, and validation infrastructure

- Add TransitionScope::ChatBsky variant with parsing, serialisation, and grants
- Add IncludeScope<S> type and Scope::Include variant with percent-encoded audience
- Add scope index types (ScopeIndices, ScopeInnerIndices, RepoActionFlags, etc.)
- Make validate_did and validate_nsid public in jacquard-common
- Add MimePattern validation and unchecked constructor

+358 -11
+1
Cargo.lock
··· 2378 2378 "serde_html_form", 2379 2379 "serde_json", 2380 2380 "sha2", 2381 + "smallvec", 2381 2382 "smol_str", 2382 2383 "thiserror 2.0.18", 2383 2384 "tokio",
+1
Cargo.toml
··· 60 60 dashmap = "6.1" 61 61 mini-moka = "0.10" 62 62 indexmap = { version = "*", default-features = false, features = ["alloc"] } 63 + smallvec = { version = "1", features = ["const_generics"] } 63 64 64 65 # Proc macros 65 66 proc-macro2 = "1.0"
+5 -1
crates/jacquard-common/src/types/did.rs
··· 43 43 did.strip_prefix("at://").unwrap_or(did) 44 44 } 45 45 46 - pub(crate) fn validate_did(did: &str) -> Result<(), AtStrError> { 46 + /// Validate a DID string without constructing a `Did<S>`. 47 + /// 48 + /// Checks length (≤2048) and format against `DID_REGEX`. Returns `Ok(())` 49 + /// if valid. Use this when you need validation without allocation. 50 + pub fn validate_did(did: &str) -> Result<(), AtStrError> { 47 51 if did.len() > 2048 { 48 52 Err(AtStrError::too_long("did", did, 2048, did.len())) 49 53 } else if !DID_REGEX.is_match(did) {
+5 -1
crates/jacquard-common/src/types/nsid.rs
··· 30 30 Regex::new(r"^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z][a-zA-Z0-9]{0,62})$").unwrap() 31 31 }); 32 32 33 - pub(crate) fn validate_nsid(nsid: &str) -> Result<(), AtStrError> { 33 + /// Validate an NSID string without constructing an `Nsid<S>`. 34 + /// 35 + /// Checks length (≤317) and format against `NSID_REGEX`. Returns `Ok(())` 36 + /// if valid. Use this when you need validation without allocation. 37 + pub fn validate_nsid(nsid: &str) -> Result<(), AtStrError> { 34 38 if nsid.len() > 317 { 35 39 Err(AtStrError::too_long("nsid", nsid, 317, nsid.len())) 36 40 } else if !NSID_REGEX.is_match(nsid) {
+1
crates/jacquard-oauth/Cargo.toml
··· 48 48 n0-future = { workspace = true, optional = true } 49 49 webbrowser = { version = "1", optional = true } 50 50 tracing = { workspace = true, optional = true } 51 + smallvec.workspace = true 51 52 52 53 [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 53 54 tokio = { workspace = true, features = ["rt", "net", "time"] }
+345 -9
crates/jacquard-oauth/src/scopes.rs
··· 25 25 use std::str::FromStr; 26 26 27 27 use jacquard_common::bos::{BosStr, DefaultStr}; 28 + use jacquard_common::deps::fluent_uri::pct_enc::{EString, encoder::Query as EncQuery}; 28 29 use jacquard_common::types::did::Did; 29 30 use jacquard_common::types::nsid::Nsid; 30 31 use jacquard_common::types::string::AtStrError; 31 32 use jacquard_common::{Bos, FromStaticStr, IntoStatic}; 32 33 use serde::de::Visitor; 33 34 use serde::{Deserialize, Serialize}; 35 + use smallvec::SmallVec; 34 36 use smol_str::{SmolStr, SmolStrBuilder, ToSmolStr, format_smolstr}; 35 37 36 38 /// Represents an AT Protocol OAuth scope ··· 50 52 Atproto, 51 53 /// Transition scope for migration operations 52 54 Transition(TransitionScope), 55 + /// Include scope referencing a permission set 56 + Include(IncludeScope<S>), 53 57 /// OpenID Connect scope - required for OpenID Connect authentication 54 58 OpenId, 55 59 /// Profile scope - access to user profile information ··· 115 119 Scope::Rpc(scope) => Scope::Rpc(scope.into_static()), 116 120 Scope::Atproto => Scope::Atproto, 117 121 Scope::Transition(scope) => Scope::Transition(scope), 122 + Scope::Include(scope) => Scope::Include(scope.into_static()), 118 123 Scope::OpenId => Scope::OpenId, 119 124 Scope::Profile => Scope::Profile, 120 125 Scope::Email => Scope::Email, ··· 167 172 Generic, 168 173 /// Email transition operations 169 174 Email, 175 + /// Chat transition scope for chat.bsky operations 176 + ChatBsky, 177 + } 178 + 179 + /// Include scope referencing a permission set NSID with optional audience. 180 + /// 181 + /// Represents `include:<nsid>[?aud=<did>]` scopes. The audience is a plain 182 + /// validated string — a DID optionally followed by `#fragment`. Stored in 183 + /// decoded form; `#` is percent-encoded as `%23` on serialisation. 184 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 185 + pub struct IncludeScope<S: BosStr = DefaultStr> { 186 + /// The permission set NSID. 187 + pub nsid: Nsid<S>, 188 + /// Optional audience (decoded form). A DID optionally with a `#fragment`. 189 + pub audience: Option<S>, 190 + } 191 + 192 + impl<S: BosStr> IncludeScope<S> { 193 + /// Convert to an `IncludeScope` with a different backing type. 194 + pub fn convert<B: Bos<str> + From<S> + AsRef<str> + FromStaticStr>(self) -> IncludeScope<B> { 195 + IncludeScope { 196 + nsid: self.nsid.convert(), 197 + audience: self.audience.map(Into::into), 198 + } 199 + } 200 + } 201 + 202 + impl<S: BosStr + IntoStatic> IntoStatic for IncludeScope<S> 203 + where 204 + S::Output: BosStr, 205 + { 206 + type Output = IncludeScope<S::Output>; 207 + 208 + fn into_static(self) -> Self::Output { 209 + IncludeScope { 210 + nsid: self.nsid.into_static(), 211 + audience: self.audience.map(|s| s.into_static()), 212 + } 213 + } 170 214 } 171 215 172 216 /// Blob scope with mime type constraints ··· 199 243 } 200 244 } 201 245 246 + /// The kind of MIME pattern, without carrying string data. 247 + /// Used by validate_mime_pattern() to return the discriminant. 248 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 249 + pub(crate) enum MimePatternKind { 250 + All, 251 + TypeWildcard, 252 + Exact, 253 + } 254 + 255 + /// Validate a MIME pattern string without allocating. 256 + /// 257 + /// Returns the pattern kind. Valid patterns: 258 + /// - `*/*` → `MimePatternKind::All` 259 + /// - `<type>/*` (e.g., `image/*`) → `MimePatternKind::TypeWildcard` 260 + /// - `<type>/<subtype>` (e.g., `image/png`) → `MimePatternKind::Exact` 261 + pub(crate) fn validate_mime_pattern(s: &str) -> Result<MimePatternKind, ParseError> { 262 + if s == "*/*" { 263 + Ok(MimePatternKind::All) 264 + } else if let Some(slash) = s.find('/') { 265 + let type_part = &s[..slash]; 266 + let subtype_part = &s[slash + 1..]; 267 + if type_part.is_empty() || subtype_part.is_empty() { 268 + return Err(ParseError::InvalidMimeType(s.to_string())); 269 + } 270 + if subtype_part == "*" { 271 + Ok(MimePatternKind::TypeWildcard) 272 + } else { 273 + Ok(MimePatternKind::Exact) 274 + } 275 + } else { 276 + Err(ParseError::InvalidMimeType(s.to_string())) 277 + } 278 + } 279 + 202 280 /// MIME type pattern for blob scope 203 281 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 204 282 pub enum MimePattern<S: BosStr = DefaultStr> { ··· 219 297 MimePattern::Exact(s) => MimePattern::Exact(s.into()), 220 298 } 221 299 } 300 + 301 + /// Construct a MimePattern without validation. 302 + /// 303 + /// # Safety 304 + /// 305 + /// The caller must ensure `s` is a valid MIME pattern string 306 + /// and `kind` matches the pattern. 307 + pub(crate) unsafe fn unchecked(s: S, kind: MimePatternKind) -> Self { 308 + match kind { 309 + MimePatternKind::All => MimePattern::All, 310 + MimePatternKind::TypeWildcard => MimePattern::TypeWildcard(s), 311 + MimePatternKind::Exact => MimePattern::Exact(s), 312 + } 313 + } 222 314 } 223 315 224 316 impl<S: BosStr + IntoStatic> IntoStatic for MimePattern<S> ··· 414 506 } 415 507 } 416 508 509 + // ============================================================================ 510 + // Scope index types — internal infrastructure for Phase 2's Scopes<S> container. 511 + // All types are pub(crate), consumed by the Scopes<S> container. 512 + // ============================================================================ 513 + 514 + /// Byte-range indices for a single scope within a `Scopes` buffer. 515 + #[derive(Debug, Clone)] 516 + pub(crate) struct ScopeIndices { 517 + pub(crate) start: u16, 518 + pub(crate) end: u16, 519 + pub(crate) inner: ScopeInnerIndices, 520 + } 521 + 522 + /// Pre-parsed structure of a scope, storing only byte-range indices into the buffer. 523 + #[derive(Debug, Clone)] 524 + pub(crate) enum ScopeInnerIndices { 525 + Account { 526 + resource: AccountResource, 527 + action: AccountAction, 528 + }, 529 + Identity(IdentityScope), 530 + Transition(TransitionScope), 531 + Blob { 532 + accept: SmallVec<[(u16, u16); 2]>, 533 + }, 534 + Repo { 535 + collection: Option<(u16, u16)>, 536 + actions: RepoActionFlags, 537 + }, 538 + Rpc { 539 + lxm: SmallVec<[(u16, u16); 2]>, 540 + aud: SmallVec<[(u16, u16); 2]>, 541 + }, 542 + Include { 543 + nsid: (u16, u16), 544 + audience: Option<IncludeAudience>, 545 + }, 546 + /// Unit scopes: atproto, openid, profile, email. 547 + Unit(ScopeKind), 548 + } 549 + 550 + /// Discriminant for unit scopes (no string data). 551 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 552 + pub(crate) enum ScopeKind { 553 + Atproto, 554 + OpenId, 555 + Profile, 556 + Email, 557 + } 558 + 559 + /// Bitflag representation of repo actions for compact index storage. 560 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 561 + pub(crate) struct RepoActionFlags(u8); 562 + 563 + impl RepoActionFlags { 564 + pub(crate) const CREATE: u8 = 0b001; 565 + pub(crate) const UPDATE: u8 = 0b010; 566 + pub(crate) const DELETE: u8 = 0b100; 567 + pub(crate) const ALL: u8 = 0b111; 568 + 569 + pub(crate) fn from_actions(actions: &BTreeSet<RepoAction>) -> Self { 570 + let mut flags = 0u8; 571 + for action in actions { 572 + match action { 573 + RepoAction::Create => flags |= Self::CREATE, 574 + RepoAction::Update => flags |= Self::UPDATE, 575 + RepoAction::Delete => flags |= Self::DELETE, 576 + } 577 + } 578 + RepoActionFlags(flags) 579 + } 580 + 581 + pub(crate) fn contains(self, flag: u8) -> bool { 582 + self.0 & flag != 0 583 + } 584 + 585 + pub(crate) fn to_actions(self) -> BTreeSet<RepoAction> { 586 + let mut set = BTreeSet::new(); 587 + if self.contains(Self::CREATE) { 588 + set.insert(RepoAction::Create); 589 + } 590 + if self.contains(Self::UPDATE) { 591 + set.insert(RepoAction::Update); 592 + } 593 + if self.contains(Self::DELETE) { 594 + set.insert(RepoAction::Delete); 595 + } 596 + set 597 + } 598 + } 599 + 600 + /// Audience encoding state for include scope indices. 601 + /// 602 + /// Both variants store byte ranges into the buffer. The discriminant 603 + /// tells `grants()` whether to decode before comparing, and tells 604 + /// `to_string_normalized()` whether the raw form needs encoding. 605 + #[derive(Debug, Clone)] 606 + pub(crate) enum IncludeAudience { 607 + /// Audience in buffer is already decoded (no percent-encoding). 608 + /// `grants()` can compare directly. Serialisation must encode `#` → `%23`. 609 + Plain(u16, u16), 610 + /// Audience in buffer contains percent-encoding (e.g., `%23`). 611 + /// `grants()` must decode before comparing. Serialisation can pass through. 612 + Encoded(u16, u16), 613 + } 614 + 417 615 impl<S: BosStr + Ord> Scope<S> { 418 616 /// Convert to a `Scope` with a different backing type. 419 617 pub fn convert<B: Bos<str> + From<S> + AsRef<str> + FromStaticStr + Ord>(self) -> Scope<B> { ··· 425 623 Scope::Rpc(scope) => Scope::Rpc(scope.convert()), 426 624 Scope::Atproto => Scope::Atproto, 427 625 Scope::Transition(scope) => Scope::Transition(scope), 626 + Scope::Include(scope) => Scope::Include(scope.convert()), 428 627 Scope::OpenId => Scope::OpenId, 429 628 Scope::Profile => Scope::Profile, 430 629 Scope::Email => Scope::Email, ··· 436 635 /// # Examples 437 636 /// ``` 438 637 /// # use jacquard_oauth::scopes::Scope; 439 - /// let scopes = Scope::parse_multiple("atproto repo:*").unwrap(); 638 + /// # use smol_str::SmolStr; 639 + /// let scopes = Scope::<SmolStr>::parse_multiple("atproto repo:*").unwrap(); 440 640 /// assert_eq!(scopes.len(), 2); 441 641 /// ``` 442 642 pub fn parse_multiple<'a>(s: &'a str) -> Result<Vec<Self>, ParseError> ··· 464 664 /// # Examples 465 665 /// ``` 466 666 /// # use jacquard_oauth::scopes::Scope; 667 + /// # use smol_str::SmolStr; 467 668 /// // repo:* grants repo:foo.bar, so only repo:* is kept 468 - /// let scopes = Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap(); 669 + /// let scopes = Scope::<SmolStr>::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap(); 469 670 /// assert_eq!(scopes.len(), 2); // atproto and repo:* 470 671 /// ``` 471 672 pub fn parse_multiple_reduced<'a>(s: &'a str) -> Result<Vec<Self>, ParseError> ··· 525 726 /// # Examples 526 727 /// ``` 527 728 /// # use jacquard_oauth::scopes::Scope; 729 + /// # use smol_str::SmolStr; 528 730 /// let scopes = vec![ 529 - /// Scope::parse("repo:*").unwrap(), 530 - /// Scope::parse("atproto").unwrap(), 531 - /// Scope::parse("account:email").unwrap(), 731 + /// Scope::<SmolStr>::parse("repo:*").unwrap(), 732 + /// Scope::<SmolStr>::parse("atproto").unwrap(), 733 + /// Scope::<SmolStr>::parse("account:email").unwrap(), 532 734 /// ]; 533 735 /// let result = Scope::serialize_multiple(&scopes); 534 736 /// assert_eq!(result, "account:email atproto repo:*"); ··· 562 764 /// # Examples 563 765 /// ``` 564 766 /// # use jacquard_oauth::scopes::Scope; 767 + /// # use smol_str::SmolStr; 565 768 /// let scopes = vec![ 566 - /// Scope::parse("repo:*").unwrap(), 567 - /// Scope::parse("atproto").unwrap(), 568 - /// Scope::parse("account:email").unwrap(), 769 + /// Scope::<SmolStr>::parse("repo:*").unwrap(), 770 + /// Scope::<SmolStr>::parse("atproto").unwrap(), 771 + /// Scope::<SmolStr>::parse("account:email").unwrap(), 569 772 /// ]; 570 - /// let to_remove = Scope::parse("atproto").unwrap(); 773 + /// let to_remove = Scope::<SmolStr>::parse("atproto").unwrap(); 571 774 /// let result = Scope::remove_scope(&scopes, &to_remove); 572 775 /// assert_eq!(result.len(), 2); 573 776 /// assert!(!result.contains(&to_remove)); ··· 864 1067 let scope = match suffix { 865 1068 Some("generic") => TransitionScope::Generic, 866 1069 Some("email") => TransitionScope::Email, 1070 + Some("chat.bsky") => TransitionScope::ChatBsky, 867 1071 Some(other) => return Err(ParseError::InvalidResource(other.to_string())), 868 1072 None => return Err(ParseError::MissingResource), 869 1073 }; ··· 1014 1218 } 1015 1219 } 1016 1220 } 1221 + Scope::Include(scope) => { 1222 + if let Some(ref aud) = scope.audience { 1223 + // Encode audience using fluent-uri Query encoder. 1224 + // '#' is not in the Query table, so it gets encoded as %23. 1225 + // DID-safe characters (:, ., etc.) are in the Query table 1226 + // and pass through unencoded. 1227 + let mut encoded = EString::<EncQuery>::new(); 1228 + encoded.encode_str::<EncQuery>(aud.as_ref()); 1229 + format_smolstr!("include:{}?aud={}", scope.nsid, encoded.as_str()) 1230 + } else { 1231 + format_smolstr!("include:{}", scope.nsid) 1232 + } 1233 + }, 1017 1234 Scope::Atproto => "atproto".to_smolstr(), 1018 1235 Scope::Transition(scope) => match scope { 1019 1236 TransitionScope::Generic => "transition:generic".to_smolstr(), 1020 1237 TransitionScope::Email => "transition:email".to_smolstr(), 1238 + TransitionScope::ChatBsky => "transition:chat.bsky".to_smolstr(), 1021 1239 }, 1022 1240 Scope::OpenId => "openid".to_smolstr(), 1023 1241 Scope::Profile => "profile".to_smolstr(), ··· 1038 1256 // Other scopes don't grant transition scopes 1039 1257 (_, Scope::Transition(_)) => false, 1040 1258 (Scope::Transition(_), _) => false, 1259 + // Include scopes only grant exact match (opaque until resolved). 1260 + (Scope::Include(a), Scope::Include(b)) => { 1261 + a.nsid.as_ref() == b.nsid.as_ref() 1262 + && match (&a.audience, &b.audience) { 1263 + (Some(a_aud), Some(b_aud)) => a_aud.as_ref() == b_aud.as_ref(), 1264 + (None, None) => true, 1265 + _ => false, 1266 + } 1267 + } 1268 + (_, Scope::Include(_)) => false, 1269 + (Scope::Include(_), _) => false, 1041 1270 // OpenID Connect scopes only grant themselves 1042 1271 (Scope::OpenId, Scope::OpenId) => true, 1043 1272 (Scope::OpenId, _) => false, ··· 1619 1848 let scope = Scope::<SmolStr>::parse(input).unwrap(); 1620 1849 assert_eq!(scope.to_string_normalized(), expected); 1621 1850 } 1851 + } 1852 + 1853 + #[test] 1854 + fn test_transition_chat_bsky() { 1855 + // Test parsing. 1856 + let scope = Scope::<SmolStr>::parse("transition:chat.bsky").unwrap(); 1857 + assert_eq!(scope, Scope::Transition(TransitionScope::ChatBsky)); 1858 + 1859 + // Test serialization. 1860 + assert_eq!(scope.to_string_normalized(), "transition:chat.bsky"); 1861 + 1862 + // Test grants itself. 1863 + let other: Scope<SmolStr> = Scope::Transition(TransitionScope::ChatBsky); 1864 + assert!(scope.grants(&other)); 1865 + 1866 + // Test doesn't grant other transition scopes. 1867 + let generic: Scope<SmolStr> = Scope::Transition(TransitionScope::Generic); 1868 + let email: Scope<SmolStr> = Scope::Transition(TransitionScope::Email); 1869 + assert!(!scope.grants(&generic)); 1870 + assert!(!scope.grants(&email)); 1871 + 1872 + // Test other scopes don't grant ChatBsky. 1873 + assert!(!generic.grants(&scope)); 1874 + assert!(!email.grants(&scope)); 1875 + 1876 + // Test typo is rejected. 1877 + assert!(matches!( 1878 + Scope::<SmolStr>::parse("transition:chat.bsk"), 1879 + Err(ParseError::InvalidResource(_)) 1880 + )); 1881 + } 1882 + 1883 + #[test] 1884 + fn test_include_scope_serialisation() { 1885 + // Test with audience containing '#' — should encode as %23. 1886 + let scope: Scope<SmolStr> = Scope::Include(IncludeScope { 1887 + nsid: Nsid::new_static("app.bsky.full").unwrap(), 1888 + audience: Some(SmolStr::new_static("did:web:api.example.com#svc_appview")), 1889 + }); 1890 + assert_eq!( 1891 + scope.to_string_normalized(), 1892 + "include:app.bsky.full?aud=did:web:api.example.com%23svc_appview" 1893 + ); 1894 + 1895 + // Test without audience. 1896 + let scope: Scope<SmolStr> = Scope::Include(IncludeScope { 1897 + nsid: Nsid::new_static("app.bsky.authFull").unwrap(), 1898 + audience: None, 1899 + }); 1900 + assert_eq!(scope.to_string_normalized(), "include:app.bsky.authFull"); 1901 + 1902 + // Test with simple audience (no '#'). 1903 + let scope: Scope<SmolStr> = Scope::Include(IncludeScope { 1904 + nsid: Nsid::new_static("com.example.perm").unwrap(), 1905 + audience: Some(SmolStr::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g")), 1906 + }); 1907 + assert_eq!( 1908 + scope.to_string_normalized(), 1909 + "include:com.example.perm?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g" 1910 + ); 1911 + } 1912 + 1913 + #[test] 1914 + fn test_include_scope_grants() { 1915 + // Test identical include scopes grant each other. 1916 + let scope1: Scope<SmolStr> = Scope::Include(IncludeScope { 1917 + nsid: Nsid::new_static("app.bsky.full").unwrap(), 1918 + audience: Some(SmolStr::new_static("did:web:api.example.com")), 1919 + }); 1920 + let scope2: Scope<SmolStr> = Scope::Include(IncludeScope { 1921 + nsid: Nsid::new_static("app.bsky.full").unwrap(), 1922 + audience: Some(SmolStr::new_static("did:web:api.example.com")), 1923 + }); 1924 + assert!(scope1.grants(&scope2)); 1925 + 1926 + // Test different NSIDs don't grant. 1927 + let scope3: Scope<SmolStr> = Scope::Include(IncludeScope { 1928 + nsid: Nsid::new_static("app.bsky.authFull").unwrap(), 1929 + audience: Some(SmolStr::new_static("did:web:api.example.com")), 1930 + }); 1931 + assert!(!scope1.grants(&scope3)); 1932 + 1933 + // Test same NSID but different audiences don't grant. 1934 + let scope4: Scope<SmolStr> = Scope::Include(IncludeScope { 1935 + nsid: Nsid::new_static("app.bsky.full").unwrap(), 1936 + audience: Some(SmolStr::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g")), 1937 + }); 1938 + assert!(!scope1.grants(&scope4)); 1939 + 1940 + // Test audience vs no audience don't grant. 1941 + let scope5: Scope<SmolStr> = Scope::Include(IncludeScope { 1942 + nsid: Nsid::new_static("app.bsky.full").unwrap(), 1943 + audience: None, 1944 + }); 1945 + assert!(!scope1.grants(&scope5)); 1946 + 1947 + // Test no-audience scopes grant each other only if NSID matches. 1948 + let scope6: Scope<SmolStr> = Scope::Include(IncludeScope { 1949 + nsid: Nsid::new_static("app.bsky.full").unwrap(), 1950 + audience: None, 1951 + }); 1952 + assert!(scope5.grants(&scope6)); 1953 + 1954 + // Test non-include scopes don't grant include scopes and vice versa. 1955 + let account = Scope::<SmolStr>::parse("account:email").unwrap(); 1956 + assert!(!account.grants(&scope1)); 1957 + assert!(!scope1.grants(&account)); 1622 1958 } 1623 1959 1624 1960 #[test]