A better Rust ATProto crate
103
fork

Configure Feed

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

[jacquard-oauth] add Scopes<S> buffer+indices container

- Implement Scopes<S> with eager parse, scope reduction, and index computation
- Add scope reconstruction from buffer indices (unsafe, invariant-guarded)
- Add BorrowOrShare accessors (iter, get, get_owned, get_as)
- Add conversion methods (borrow, convert, into_static, to_normalized_string)
- Add Serialize/Deserialize with sorted normalised output
- Add convenience methods (len, is_empty, as_str, empty, Default, grants)
- Use fluent-uri EStr for percent-decoding in include scope parsing

+1813 -5
+1813 -5
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 + use jacquard_common::deps::fluent_uri::pct_enc::{EStr, EString, encoder::{Query, Query as EncQuery}}; 29 29 use jacquard_common::types::did::Did; 30 30 use jacquard_common::types::nsid::Nsid; 31 31 use jacquard_common::types::string::AtStrError; 32 - use jacquard_common::{Bos, FromStaticStr, IntoStatic}; 33 - use serde::de::Visitor; 32 + use jacquard_common::{Bos, BorrowOrShare, FromStaticStr, IntoStatic}; 33 + use serde::de::{Error as DeError, Visitor}; 34 34 use serde::{Deserialize, Serialize}; 35 35 use smallvec::SmallVec; 36 36 use smol_str::{SmolStr, SmolStrBuilder, ToSmolStr, format_smolstr}; ··· 172 172 Generic, 173 173 /// Email transition operations 174 174 Email, 175 - /// Chat transition scope for chat.bsky operations 175 + /// Chat transition scope for chat.bsky operations. 176 176 ChatBsky, 177 177 } 178 178 ··· 303 303 /// # Safety 304 304 /// 305 305 /// The caller must ensure `s` is a valid MIME pattern string 306 - /// and `kind` matches the pattern. 306 + /// and `kind` matches the pattern. `MimePattern`'s API assumes 307 + /// the invariant holds — violating it will produce incorrect 308 + /// results from downstream operations. 307 309 pub(crate) unsafe fn unchecked(s: S, kind: MimePatternKind) -> Self { 308 310 match kind { 309 311 MimePatternKind::All => MimePattern::All, ··· 612 614 Encoded(u16, u16), 613 615 } 614 616 617 + // ============================================================================ 618 + // Scopes<S> container — validated, zero-copy scope string with indices. 619 + // ============================================================================ 620 + 621 + /// Iterator over scopes in a `Scopes<S>` container. 622 + /// 623 + /// Yields `Scope<&'o str>` views that borrow from the shared buffer. 624 + /// This type is returned by `Scopes::iter()`. 625 + pub struct ScopesIter<'i, 'o> { 626 + buffer: &'o str, 627 + indices: std::slice::Iter<'i, ScopeIndices>, 628 + } 629 + 630 + impl<'i, 'o> Iterator for ScopesIter<'i, 'o> { 631 + type Item = Scope<&'o str>; 632 + 633 + fn next(&mut self) -> Option<Scope<&'o str>> { 634 + self.indices.next().map(|idx| { 635 + // Safety: indices computed at construction from the buffer. 636 + unsafe { reconstruct_scope(self.buffer, idx) } 637 + }) 638 + } 639 + 640 + fn size_hint(&self) -> (usize, Option<usize>) { 641 + self.indices.size_hint() 642 + } 643 + 644 + fn count(self) -> usize { 645 + self.indices.count() 646 + } 647 + } 648 + 649 + impl<'i, 'o> ExactSizeIterator for ScopesIter<'i, 'o> { 650 + fn len(&self) -> usize { 651 + self.indices.len() 652 + } 653 + } 654 + 655 + impl<'i, 'o> std::iter::FusedIterator for ScopesIter<'i, 'o> {} 656 + 657 + /// A validated container of space-separated OAuth scopes. 658 + /// 659 + /// Owns or borrows a single scope string and stores pre-computed byte-range 660 + /// indices. Typed `Scope<&str>` views are reconstructed on demand from the 661 + /// shared buffer. 662 + #[derive(Debug, Clone)] 663 + pub struct Scopes<S: Bos<str> + AsRef<str> = DefaultStr> { 664 + buffer: S, 665 + indices: Vec<ScopeIndices>, 666 + } 667 + 668 + impl<S: Bos<str> + AsRef<str>> Scopes<S> { 669 + /// Parse a space-separated scope string, validate each scope, and 670 + /// compute byte-range indices. 671 + /// 672 + /// Returns an empty `Scopes` for an empty string. Returns an error 673 + /// if any individual scope is malformed. 674 + pub fn new(buffer: S) -> Result<Self, ParseError> { 675 + let s = buffer.as_ref(); 676 + 677 + if s.is_empty() { 678 + return Ok(Scopes { 679 + buffer, 680 + indices: Vec::new(), 681 + }); 682 + } 683 + 684 + // Check u16 limit on buffer length. 685 + if s.len() > u16::MAX as usize { 686 + return Err(ParseError::InvalidResource( 687 + "scope string exceeds u16 byte limit".to_string(), 688 + )); 689 + } 690 + 691 + let mut indices = Vec::new(); 692 + let mut pos: usize = 0; 693 + 694 + for token in s.split(' ') { 695 + if token.is_empty() { 696 + pos += 1; // Advance past the space. 697 + continue; 698 + } 699 + 700 + let start = pos as u16; 701 + let end = start + token.len() as u16; 702 + 703 + let inner = parse_scope_indices(token, start)?; 704 + indices.push(ScopeIndices { start, end, inner }); 705 + 706 + pos = end as usize + 1; // +1 for the space delimiter. 707 + } 708 + 709 + // Reduce the set by removing indices for scopes already granted by a broader scope. 710 + indices = reduce_indices(s, indices)?; 711 + 712 + Ok(Scopes { buffer, indices }) 713 + } 714 + 715 + /// Return the number of scopes in this container. 716 + pub fn len(&self) -> usize { 717 + self.indices.len() 718 + } 719 + 720 + /// Return whether this container is empty. 721 + pub fn is_empty(&self) -> bool { 722 + self.indices.is_empty() 723 + } 724 + 725 + /// Iterate over scopes as `Scope<&'o str>` views borrowing from the buffer. 726 + /// 727 + /// The iterator borrows from the buffer via the `BorrowOrShare` split lifetimes. 728 + /// For example, when `S = &'a str`, the iterator yields `Scope<&'a str>` and 729 + /// can outlive the borrow of `self`. 730 + pub fn iter<'i, 'o>(&'i self) -> ScopesIter<'i, 'o> 731 + where 732 + S: BorrowOrShare<'i, 'o, str>, 733 + { 734 + let buffer: &'o str = self.buffer.borrow_or_share(); 735 + ScopesIter { 736 + buffer, 737 + indices: self.indices.iter(), 738 + } 739 + } 740 + 741 + /// Get a single scope by positional index. 742 + pub fn get<'i, 'o>(&'i self, index: usize) -> Option<Scope<&'o str>> 743 + where 744 + S: BorrowOrShare<'i, 'o, str>, 745 + { 746 + let idx = self.indices.get(index)?; 747 + let buffer: &'o str = self.buffer.borrow_or_share(); 748 + Some(unsafe { reconstruct_scope(buffer, idx) }) 749 + } 750 + 751 + /// Get a single scope with owned `SmolStr` backing, independent 752 + /// of the buffer's lifetime. 753 + pub fn get_owned(&self, index: usize) -> Option<Scope<SmolStr>> { 754 + let idx = self.indices.get(index)?; 755 + let buffer: &str = self.buffer.as_ref(); 756 + let scope = unsafe { reconstruct_scope(buffer, idx) }; 757 + Some(scope.convert()) 758 + } 759 + 760 + /// Get a single scope with caller-chosen backing type. 761 + pub fn get_as<B: BosStr + for<'a> From<&'a str>>(&self, index: usize) -> Option<Scope<B>> 762 + where 763 + B: Ord, 764 + { 765 + let idx = self.indices.get(index)?; 766 + let buffer: &str = self.buffer.as_ref(); 767 + let scope = unsafe { reconstruct_scope(buffer, idx) }; 768 + Some(scope.convert()) 769 + } 770 + 771 + /// Borrow as `Scopes<&str>`. 772 + /// 773 + /// Indices are cloned (they're small, no heap strings). The buffer 774 + /// is borrowed via `AsRef<str>`. 775 + pub fn borrow(&self) -> Scopes<&str> { 776 + Scopes { 777 + buffer: self.buffer.as_ref(), 778 + indices: self.indices.clone(), 779 + } 780 + } 781 + 782 + /// Convert to `Scopes` with a different backing type. 783 + /// 784 + /// Indices are moved (no clone). The buffer is converted via `From<S>`. 785 + /// Byte offsets remain valid because the buffer content is identical. 786 + pub fn convert<B: Bos<str> + AsRef<str> + From<S>>(self) -> Scopes<B> { 787 + Scopes { 788 + buffer: B::from(self.buffer), 789 + indices: self.indices, 790 + } 791 + } 792 + 793 + /// Produce the sorted, normalized space-separated scope string. 794 + /// 795 + /// Same output as `Serialize`, but returns `SmolStr` directly 796 + /// without going through a serializer. 797 + pub fn to_normalized_string(&self) -> SmolStr { 798 + if self.indices.is_empty() { 799 + return SmolStr::default(); 800 + } 801 + let buffer = self.buffer.as_ref(); 802 + let mut normalized: Vec<SmolStr> = self 803 + .indices 804 + .iter() 805 + .map(|idx| { 806 + let scope = unsafe { reconstruct_scope(buffer, idx) }; 807 + scope.to_string_normalized() 808 + }) 809 + .collect(); 810 + normalized.sort(); 811 + 812 + // Compute total length for pre-allocation. 813 + let total_len = normalized.iter().map(|s| s.len()).sum::<usize>() 814 + + if normalized.len() > 1 { normalized.len() - 1 } else { 0 }; // Add length for space separators. 815 + 816 + let mut result = String::with_capacity(total_len); 817 + for (i, s) in normalized.iter().enumerate() { 818 + if i > 0 { 819 + result.push(' '); 820 + } 821 + result.push_str(s); 822 + } 823 + result.to_smolstr() 824 + } 825 + 826 + /// Check if the container has a scope that grants the given scope. 827 + pub fn grants<T: BosStr>(&self, scope: &Scope<T>) -> bool { 828 + let buffer = self.buffer.as_ref(); 829 + self.indices.iter().any(|idx| { 830 + let s = unsafe { reconstruct_scope(buffer, idx) }; 831 + s.grants(scope) 832 + }) 833 + } 834 + 835 + /// Return the raw buffer as a string slice. 836 + pub fn as_str(&self) -> &str { 837 + self.buffer.as_ref() 838 + } 839 + } 840 + 841 + impl<S: Bos<str> + AsRef<str>> Serialize for Scopes<S> { 842 + fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error> 843 + where 844 + Ser: serde::Serializer, 845 + { 846 + serializer.serialize_str(&self.to_normalized_string()) 847 + } 848 + } 849 + 850 + impl<'de, S> Deserialize<'de> for Scopes<S> 851 + where 852 + S: Bos<str> + AsRef<str> + Deserialize<'de>, 853 + { 854 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 855 + where 856 + D: serde::Deserializer<'de>, 857 + { 858 + let s = S::deserialize(deserializer)?; 859 + Scopes::new(s).map_err(D::Error::custom) 860 + } 861 + } 862 + 863 + impl Scopes<SmolStr> { 864 + /// Create an empty `Scopes` with `SmolStr` backing. 865 + pub fn empty() -> Self { 866 + Scopes { 867 + buffer: SmolStr::default(), 868 + indices: Vec::new(), 869 + } 870 + } 871 + } 872 + 873 + impl<S: Bos<str> + AsRef<str> + Default> Default for Scopes<S> { 874 + fn default() -> Self { 875 + Scopes { 876 + buffer: S::default(), 877 + indices: Vec::new(), 878 + } 879 + } 880 + } 881 + 882 + impl<S: Bos<str> + AsRef<str> + IntoStatic> IntoStatic for Scopes<S> 883 + where 884 + S::Output: Bos<str> + AsRef<str>, 885 + { 886 + type Output = Scopes<S::Output>; 887 + 888 + fn into_static(self) -> Scopes<S::Output> { 889 + Scopes { 890 + buffer: self.buffer.into_static(), 891 + indices: self.indices, 892 + } 893 + } 894 + } 895 + 896 + /// Parse a single scope token into index structure. 897 + /// 898 + /// `base` is the byte offset of `token` within the outer buffer. 899 + /// All `(u16, u16)` ranges in the returned indices are absolute 900 + /// offsets into the outer buffer, NOT relative to `token`. 901 + fn parse_scope_indices(token: &str, base: u16) -> Result<ScopeInnerIndices, ParseError> { 902 + // Determine the prefix by checking for known prefixes. 903 + let prefixes = [ 904 + "account", "identity", "blob", "repo", "rpc", "atproto", "transition", "include", 905 + "openid", "profile", "email", 906 + ]; 907 + 908 + let mut found_prefix = None; 909 + let mut suffix = None; 910 + 911 + for prefix in &prefixes { 912 + if let Some(remainder) = token.strip_prefix(prefix) { 913 + if remainder.is_empty() || remainder.starts_with(':') || remainder.starts_with('?') { 914 + found_prefix = Some(*prefix); 915 + if let Some(stripped) = remainder.strip_prefix(':') { 916 + suffix = Some(stripped); 917 + } else if remainder.starts_with('?') { 918 + suffix = Some(remainder); 919 + } else { 920 + suffix = None; 921 + } 922 + break; 923 + } 924 + } 925 + } 926 + 927 + let prefix = 928 + found_prefix.ok_or_else(|| ParseError::UnknownPrefix(token[..token.find(':').unwrap_or(token.len())].to_string()))?; 929 + 930 + match prefix { 931 + "account" => parse_account_indices(suffix), 932 + "identity" => parse_identity_indices(suffix), 933 + "blob" => parse_blob_indices(token, suffix, base), 934 + "repo" => parse_repo_indices(token, suffix, base), 935 + "rpc" => parse_rpc_indices(token, suffix, base), 936 + "atproto" => parse_atproto_indices(suffix), 937 + "transition" => parse_transition_indices(suffix), 938 + "include" => parse_include_indices(token, suffix, base), 939 + "openid" => parse_openid_indices(suffix), 940 + "profile" => parse_profile_indices(suffix), 941 + "email" => parse_email_indices(suffix), 942 + _ => Err(ParseError::UnknownPrefix(prefix.to_string())), 943 + } 944 + } 945 + 946 + /// Parse account scope indices. 947 + fn parse_account_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 948 + let (resource_str, params) = match suffix { 949 + Some(s) => { 950 + if let Some(pos) = s.find('?') { 951 + (&s[..pos], Some(&s[pos + 1..])) 952 + } else { 953 + (s, None) 954 + } 955 + } 956 + None => return Err(ParseError::MissingResource), 957 + }; 958 + 959 + let resource = match resource_str { 960 + "email" => AccountResource::Email, 961 + "repo" => AccountResource::Repo, 962 + "status" => AccountResource::Status, 963 + _ => return Err(ParseError::InvalidResource(resource_str.to_string())), 964 + }; 965 + 966 + let action = if let Some(params) = params { 967 + let parsed_params = parse_query_string(params); 968 + match parsed_params 969 + .get("action") 970 + .and_then(|v| v.first()) 971 + .map(|s| s.as_ref()) 972 + { 973 + Some("read") => AccountAction::Read, 974 + Some("manage") => AccountAction::Manage, 975 + Some(other) => return Err(ParseError::InvalidAction(other.to_string())), 976 + None => AccountAction::Read, 977 + } 978 + } else { 979 + AccountAction::Read 980 + }; 981 + 982 + Ok(ScopeInnerIndices::Account { resource, action }) 983 + } 984 + 985 + /// Parse identity scope indices. 986 + fn parse_identity_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 987 + let scope = match suffix { 988 + Some("handle") => IdentityScope::Handle, 989 + Some("*") => IdentityScope::All, 990 + Some(other) => return Err(ParseError::InvalidResource(other.to_string())), 991 + None => return Err(ParseError::MissingResource), 992 + }; 993 + 994 + Ok(ScopeInnerIndices::Identity(scope)) 995 + } 996 + 997 + /// Parse transition scope indices. 998 + fn parse_transition_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 999 + let scope = match suffix { 1000 + Some("generic") => TransitionScope::Generic, 1001 + Some("email") => TransitionScope::Email, 1002 + Some("chat.bsky") => TransitionScope::ChatBsky, 1003 + Some(other) => return Err(ParseError::InvalidResource(other.to_string())), 1004 + None => return Err(ParseError::MissingResource), 1005 + }; 1006 + 1007 + Ok(ScopeInnerIndices::Transition(scope)) 1008 + } 1009 + 1010 + /// Parse atproto scope indices (unit scope, no suffix allowed). 1011 + fn parse_atproto_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 1012 + if suffix.is_some() { 1013 + return Err(ParseError::InvalidResource( 1014 + "atproto scope does not accept suffixes".to_string(), 1015 + )); 1016 + } 1017 + Ok(ScopeInnerIndices::Unit(ScopeKind::Atproto)) 1018 + } 1019 + 1020 + /// Parse openid scope indices (unit scope, no suffix allowed). 1021 + fn parse_openid_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 1022 + if suffix.is_some() { 1023 + return Err(ParseError::InvalidResource( 1024 + "openid scope does not accept suffixes".to_string(), 1025 + )); 1026 + } 1027 + Ok(ScopeInnerIndices::Unit(ScopeKind::OpenId)) 1028 + } 1029 + 1030 + /// Parse profile scope indices (unit scope, no suffix allowed). 1031 + fn parse_profile_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 1032 + if suffix.is_some() { 1033 + return Err(ParseError::InvalidResource( 1034 + "profile scope does not accept suffixes".to_string(), 1035 + )); 1036 + } 1037 + Ok(ScopeInnerIndices::Unit(ScopeKind::Profile)) 1038 + } 1039 + 1040 + /// Parse email scope indices (unit scope, no suffix allowed). 1041 + fn parse_email_indices(suffix: Option<&str>) -> Result<ScopeInnerIndices, ParseError> { 1042 + if suffix.is_some() { 1043 + return Err(ParseError::InvalidResource( 1044 + "email scope does not accept suffixes".to_string(), 1045 + )); 1046 + } 1047 + Ok(ScopeInnerIndices::Unit(ScopeKind::Email)) 1048 + } 1049 + 1050 + /// Parse blob scope indices, storing byte ranges of MIME patterns. 1051 + fn parse_blob_indices( 1052 + token: &str, 1053 + suffix: Option<&str>, 1054 + base: u16, 1055 + ) -> Result<ScopeInnerIndices, ParseError> { 1056 + let mut accept: SmallVec<[(u16, u16); 2]> = SmallVec::new(); 1057 + 1058 + match suffix { 1059 + Some(s) if s.starts_with('?') => { 1060 + let params = parse_query_string(&s[1..]); 1061 + if let Some(values) = params.get("accept") { 1062 + for value in values { 1063 + validate_mime_pattern(value)?; 1064 + // Find the byte position of this value in the token. 1065 + if let Some(pos) = token.find(value) { 1066 + let start = base + pos as u16; 1067 + let end = start + value.len() as u16; 1068 + accept.push((start, end)); 1069 + } 1070 + } 1071 + } 1072 + } 1073 + Some(s) => { 1074 + validate_mime_pattern(s)?; 1075 + let start = base + ("blob:".len() as u16); 1076 + let end = start + s.len() as u16; 1077 + accept.push((start, end)); 1078 + } 1079 + None => { 1080 + // Default to all patterns (bare `blob` token). 1081 + // Store empty SmallVec to signal "all wildcards" on reconstruction. 1082 + } 1083 + } 1084 + 1085 + // Empty accept SmallVec signals MimePattern::All on reconstruction. 1086 + 1087 + Ok(ScopeInnerIndices::Blob { accept }) 1088 + } 1089 + 1090 + /// Parse repo scope indices, storing byte range of collection NSID if present. 1091 + fn parse_repo_indices( 1092 + token: &str, 1093 + suffix: Option<&str>, 1094 + base: u16, 1095 + ) -> Result<ScopeInnerIndices, ParseError> { 1096 + let (collection_str, params) = match suffix { 1097 + Some(s) => { 1098 + if let Some(pos) = s.find('?') { 1099 + (Some(&s[..pos]), Some(&s[pos + 1..])) 1100 + } else { 1101 + (Some(s), None) 1102 + } 1103 + } 1104 + None => (None, None), 1105 + }; 1106 + 1107 + let collection = match collection_str { 1108 + Some("*") | None => None, 1109 + Some(nsid_str) => { 1110 + jacquard_common::types::nsid::validate_nsid(nsid_str)?; 1111 + // Find position of the NSID in the token. 1112 + if let Some(pos) = token.find(nsid_str) { 1113 + let start = base + pos as u16; 1114 + let end = start + nsid_str.len() as u16; 1115 + Some((start, end)) 1116 + } else { 1117 + return Err(ParseError::InvalidResource(nsid_str.to_string())); 1118 + } 1119 + } 1120 + }; 1121 + 1122 + let mut actions = RepoActionFlags(RepoActionFlags::ALL); 1123 + 1124 + if let Some(params) = params { 1125 + let parsed_params = parse_query_string(params); 1126 + if let Some(values) = parsed_params.get("action") { 1127 + let mut flags = 0u8; 1128 + for value in values { 1129 + match value.as_ref() { 1130 + "create" => flags |= RepoActionFlags::CREATE, 1131 + "update" => flags |= RepoActionFlags::UPDATE, 1132 + "delete" => flags |= RepoActionFlags::DELETE, 1133 + "*" => flags = RepoActionFlags::ALL, 1134 + other => return Err(ParseError::InvalidAction(other.to_string())), 1135 + } 1136 + } 1137 + actions = RepoActionFlags(flags); 1138 + } 1139 + } 1140 + 1141 + Ok(ScopeInnerIndices::Repo { collection, actions }) 1142 + } 1143 + 1144 + /// Parse RPC scope indices, storing byte ranges of lexicon and audience values. 1145 + fn parse_rpc_indices( 1146 + token: &str, 1147 + suffix: Option<&str>, 1148 + base: u16, 1149 + ) -> Result<ScopeInnerIndices, ParseError> { 1150 + let mut lxm: SmallVec<[(u16, u16); 2]> = SmallVec::new(); 1151 + let mut aud: SmallVec<[(u16, u16); 2]> = SmallVec::new(); 1152 + 1153 + match suffix { 1154 + Some("*") => { 1155 + let wildcard_pos = token.rfind('*').unwrap_or(token.len() - 1); 1156 + let start = base + wildcard_pos as u16; 1157 + lxm.push((start, start + 1)); 1158 + aud.push((start, start + 1)); 1159 + } 1160 + Some(s) if s.starts_with('?') => { 1161 + let params = parse_query_string(&s[1..]); 1162 + 1163 + if let Some(values) = params.get("lxm") { 1164 + for value in values { 1165 + if *value == "*" { 1166 + if let Some(pos) = token.rfind('*') { 1167 + let start = base + pos as u16; 1168 + lxm.push((start, start + 1)); 1169 + } 1170 + } else { 1171 + jacquard_common::types::nsid::validate_nsid(value)?; 1172 + if let Some(pos) = token.find(value) { 1173 + let start = base + pos as u16; 1174 + let end = start + value.len() as u16; 1175 + lxm.push((start, end)); 1176 + } 1177 + } 1178 + } 1179 + } 1180 + 1181 + if let Some(values) = params.get("aud") { 1182 + for value in values { 1183 + if *value == "*" { 1184 + if let Some(pos) = token.rfind('*') { 1185 + let start = base + pos as u16; 1186 + aud.push((start, start + 1)); 1187 + } 1188 + } else { 1189 + jacquard_common::types::did::validate_did(value)?; 1190 + if let Some(pos) = token.find(value) { 1191 + let start = base + pos as u16; 1192 + let end = start + value.len() as u16; 1193 + aud.push((start, end)); 1194 + } 1195 + } 1196 + } 1197 + } 1198 + } 1199 + Some(s) => { 1200 + // Single NSID, possibly with query params. 1201 + if let Some(pos) = s.find('?') { 1202 + let nsid_str = &s[..pos]; 1203 + let params = parse_query_string(&s[pos + 1..]); 1204 + 1205 + jacquard_common::types::nsid::validate_nsid(nsid_str)?; 1206 + if let Some(token_pos) = token.find(nsid_str) { 1207 + let start = base + token_pos as u16; 1208 + let end = start + nsid_str.len() as u16; 1209 + lxm.push((start, end)); 1210 + } 1211 + 1212 + if let Some(values) = params.get("aud") { 1213 + for value in values { 1214 + if *value == "*" { 1215 + if let Some(pos) = token.rfind('*') { 1216 + let start = base + pos as u16; 1217 + aud.push((start, start + 1)); 1218 + } 1219 + } else { 1220 + jacquard_common::types::did::validate_did(value)?; 1221 + if let Some(pos) = token.find(value) { 1222 + let start = base + pos as u16; 1223 + let end = start + value.len() as u16; 1224 + aud.push((start, end)); 1225 + } 1226 + } 1227 + } 1228 + } 1229 + } else { 1230 + // Just an NSID, no query params. 1231 + jacquard_common::types::nsid::validate_nsid(s)?; 1232 + if let Some(pos) = token.find(s) { 1233 + let start = base + pos as u16; 1234 + let end = start + s.len() as u16; 1235 + lxm.push((start, end)); 1236 + } 1237 + // aud remains empty, which means wildcard on reconstruction. 1238 + } 1239 + } 1240 + None => { 1241 + // Empty suffix, default to all. 1242 + // Leave both lxm and aud empty to signal wildcards on reconstruction. 1243 + } 1244 + } 1245 + 1246 + // Empty lxm SmallVec signals RpcLexicon::All on reconstruction. 1247 + // Empty aud SmallVec signals RpcAudience::All on reconstruction (already handled). 1248 + 1249 + Ok(ScopeInnerIndices::Rpc { lxm, aud }) 1250 + } 1251 + 1252 + /// Parse include scope indices, validating NSID and optional audience. 1253 + fn parse_include_indices( 1254 + token: &str, 1255 + suffix: Option<&str>, 1256 + base: u16, 1257 + ) -> Result<ScopeInnerIndices, ParseError> { 1258 + let (nsid_str, params) = match suffix { 1259 + Some(s) => { 1260 + if let Some(pos) = s.find('?') { 1261 + (&s[..pos], Some(&s[pos + 1..])) 1262 + } else { 1263 + (s, None) 1264 + } 1265 + } 1266 + None => return Err(ParseError::MissingResource), 1267 + }; 1268 + 1269 + // Validate the NSID. 1270 + jacquard_common::types::nsid::validate_nsid(nsid_str)?; 1271 + 1272 + // Find the NSID's byte position in the token. 1273 + let nsid_pos = token 1274 + .find(nsid_str) 1275 + .ok_or_else(|| ParseError::InvalidResource(nsid_str.to_string()))?; 1276 + let nsid_start = base + nsid_pos as u16; 1277 + let nsid_end = nsid_start + nsid_str.len() as u16; 1278 + 1279 + let audience = if let Some(params) = params { 1280 + let parsed_params = parse_query_string(params); 1281 + if let Some(values) = parsed_params.get("aud") { 1282 + if let Some(aud_value) = values.first() { 1283 + // Check if value contains percent-encoding. 1284 + let has_encoding = aud_value.contains('%'); 1285 + 1286 + if has_encoding { 1287 + // Validate and decode the percent-encoded value using fluent-uri. 1288 + let estr = EStr::<Query>::new(aud_value) 1289 + .ok_or_else(|| ParseError::InvalidResource( 1290 + "include audience has invalid percent-encoding".to_string(), 1291 + ))?; 1292 + 1293 + let decoded = estr.decode().to_string() 1294 + .map_err(|_| ParseError::InvalidResource( 1295 + "include audience contains invalid UTF-8 sequence".to_string(), 1296 + ))?; 1297 + 1298 + // Validate the DID portion (before any #). 1299 + let did_part = decoded.split('#').next().unwrap_or(""); 1300 + jacquard_common::types::did::validate_did(did_part)?; 1301 + if decoded.contains('#') { 1302 + let frag = decoded.split('#').nth(1).unwrap_or(""); 1303 + if frag.is_empty() { 1304 + return Err(ParseError::InvalidResource( 1305 + "include audience fragment cannot be empty".to_string(), 1306 + )); 1307 + } 1308 + } 1309 + } else { 1310 + // Unencoded: validate the DID portion before `#`. 1311 + let did_part = aud_value.split('#').next().unwrap_or(""); 1312 + jacquard_common::types::did::validate_did(did_part)?; 1313 + if aud_value.contains('#') { 1314 + let frag = aud_value.split('#').nth(1).unwrap_or(""); 1315 + if frag.is_empty() { 1316 + return Err(ParseError::InvalidResource( 1317 + "include audience fragment cannot be empty".to_string(), 1318 + )); 1319 + } 1320 + } 1321 + } 1322 + 1323 + // Find the audience's byte position in the token. 1324 + let aud_pos = token 1325 + .find(aud_value) 1326 + .ok_or_else(|| ParseError::InvalidResource(aud_value.to_string()))?; 1327 + let aud_start = base + aud_pos as u16; 1328 + let aud_end = aud_start + aud_value.len() as u16; 1329 + 1330 + if has_encoding { 1331 + Some(IncludeAudience::Encoded(aud_start, aud_end)) 1332 + } else { 1333 + Some(IncludeAudience::Plain(aud_start, aud_end)) 1334 + } 1335 + } else { 1336 + None 1337 + } 1338 + } else { 1339 + None 1340 + } 1341 + } else { 1342 + None 1343 + }; 1344 + 1345 + Ok(ScopeInnerIndices::Include { 1346 + nsid: (nsid_start, nsid_end), 1347 + audience, 1348 + }) 1349 + } 1350 + 1351 + /// Reduce scope indices by removing those already granted by broader scopes. 1352 + fn reduce_indices( 1353 + buffer: &str, 1354 + indices: Vec<ScopeIndices>, 1355 + ) -> Result<Vec<ScopeIndices>, ParseError> { 1356 + if indices.is_empty() { 1357 + return Ok(indices); 1358 + } 1359 + 1360 + // Partition indices by scope kind. 1361 + let mut unit_or_account_or_identity_or_transition: Vec<_> = Vec::new(); 1362 + let mut repo_indices: Vec<_> = Vec::new(); 1363 + let mut rpc_indices: Vec<_> = Vec::new(); 1364 + let mut blob_indices: Vec<_> = Vec::new(); 1365 + let mut include_indices: Vec<_> = Vec::new(); 1366 + 1367 + for indices in indices.into_iter() { 1368 + match &indices.inner { 1369 + ScopeInnerIndices::Unit(_) 1370 + | ScopeInnerIndices::Account { .. } 1371 + | ScopeInnerIndices::Identity(_) 1372 + | ScopeInnerIndices::Transition(_) => { 1373 + unit_or_account_or_identity_or_transition.push(indices); 1374 + } 1375 + ScopeInnerIndices::Repo { .. } => repo_indices.push(indices), 1376 + ScopeInnerIndices::Rpc { .. } => rpc_indices.push(indices), 1377 + ScopeInnerIndices::Blob { .. } => blob_indices.push(indices), 1378 + ScopeInnerIndices::Include { .. } => include_indices.push(indices), 1379 + } 1380 + } 1381 + 1382 + // Deduplicate unit/account/identity/transition scopes. 1383 + let mut seen = std::collections::HashSet::new(); 1384 + unit_or_account_or_identity_or_transition 1385 + .retain(|idx| { 1386 + let scope = unsafe { reconstruct_scope(buffer, idx) }; 1387 + seen.insert(scope.to_string_normalized()) 1388 + }); 1389 + 1390 + // Pairwise reduction for repo scopes. 1391 + repo_indices = reduce_pairwise(buffer, repo_indices); 1392 + 1393 + // Pairwise reduction for rpc scopes. 1394 + rpc_indices = reduce_pairwise(buffer, rpc_indices); 1395 + 1396 + // Combine back. 1397 + let mut result = unit_or_account_or_identity_or_transition; 1398 + result.extend(repo_indices); 1399 + result.extend(rpc_indices); 1400 + result.extend(blob_indices); 1401 + result.extend(include_indices); 1402 + 1403 + Ok(result) 1404 + } 1405 + 1406 + /// Perform pairwise reduction on a set of indices, removing those granted by others. 1407 + fn reduce_pairwise(buffer: &str, indices: Vec<ScopeIndices>) -> Vec<ScopeIndices> { 1408 + let mut result: Vec<ScopeIndices> = Vec::new(); 1409 + 1410 + for candidate_idx in indices { 1411 + // Reconstruct the candidate scope. 1412 + let candidate = unsafe { reconstruct_scope(buffer, &candidate_idx) }; 1413 + 1414 + // Check if it's already granted by something in result. 1415 + let mut is_granted = false; 1416 + for existing_idx in &result { 1417 + let existing = unsafe { reconstruct_scope(buffer, existing_idx) }; 1418 + if existing.grants(&candidate) && existing != candidate { 1419 + is_granted = true; 1420 + break; 1421 + } 1422 + } 1423 + 1424 + if is_granted { 1425 + continue; 1426 + } 1427 + 1428 + // Check if it grants any existing scopes. 1429 + let mut indices_to_remove = Vec::new(); 1430 + for (i, existing_idx) in result.iter().enumerate() { 1431 + let existing = unsafe { reconstruct_scope(buffer, existing_idx) }; 1432 + if candidate.grants(&existing) && candidate != existing { 1433 + indices_to_remove.push(i); 1434 + } 1435 + } 1436 + 1437 + for i in indices_to_remove.into_iter().rev() { 1438 + result.remove(i); 1439 + } 1440 + 1441 + // Add candidate if not a duplicate. 1442 + if !result.iter().any(|idx| { 1443 + let existing = unsafe { reconstruct_scope(buffer, idx) }; 1444 + existing == candidate 1445 + }) { 1446 + result.push(candidate_idx); 1447 + } 1448 + } 1449 + 1450 + result 1451 + } 1452 + 1453 + /// Reconstruct a typed `Scope<&str>` from pre-computed indices. 1454 + /// 1455 + /// # Safety 1456 + /// 1457 + /// `indices` must have been computed from `buffer` during `Scopes::new()`. 1458 + /// All byte ranges in `indices` must be valid for `buffer`. 1459 + unsafe fn reconstruct_scope<'a>( 1460 + buffer: &'a str, 1461 + indices: &ScopeIndices, 1462 + ) -> Scope<&'a str> { 1463 + match &indices.inner { 1464 + ScopeInnerIndices::Unit(kind) => match kind { 1465 + ScopeKind::Atproto => Scope::Atproto, 1466 + ScopeKind::OpenId => Scope::OpenId, 1467 + ScopeKind::Profile => Scope::Profile, 1468 + ScopeKind::Email => Scope::Email, 1469 + }, 1470 + 1471 + ScopeInnerIndices::Account { resource, action } => { 1472 + Scope::Account(AccountScope { 1473 + resource: *resource, 1474 + action: *action, 1475 + }) 1476 + } 1477 + 1478 + ScopeInnerIndices::Identity(scope) => Scope::Identity(scope.clone()), 1479 + 1480 + ScopeInnerIndices::Transition(scope) => Scope::Transition(scope.clone()), 1481 + 1482 + ScopeInnerIndices::Blob { accept } => { 1483 + let mut patterns = BTreeSet::new(); 1484 + if accept.is_empty() { 1485 + // Empty accept SmallVec signals MimePattern::All (bare `blob` token). 1486 + patterns.insert(MimePattern::All); 1487 + } else { 1488 + for &(start, end) in accept.iter() { 1489 + let s = &buffer[start as usize..end as usize]; 1490 + let pattern = if s == "*/*" { 1491 + MimePattern::All 1492 + } else if s.ends_with("/*") { 1493 + MimePattern::TypeWildcard(&s[..s.len() - 2]) 1494 + } else { 1495 + MimePattern::Exact(s) 1496 + }; 1497 + patterns.insert(pattern); 1498 + } 1499 + } 1500 + Scope::Blob(BlobScope { accept: patterns }) 1501 + } 1502 + 1503 + ScopeInnerIndices::Repo { collection, actions } => { 1504 + let collection = match collection { 1505 + None => RepoCollection::All, 1506 + Some((start, end)) => { 1507 + let s = &buffer[*start as usize..*end as usize]; 1508 + RepoCollection::Nsid(unsafe { Nsid::unchecked(s) }) 1509 + } 1510 + }; 1511 + Scope::Repo(RepoScope { 1512 + collection, 1513 + actions: actions.to_actions(), 1514 + }) 1515 + } 1516 + 1517 + ScopeInnerIndices::Rpc { lxm, aud } => { 1518 + let mut lxm_set = BTreeSet::new(); 1519 + let mut aud_set = BTreeSet::new(); 1520 + 1521 + if lxm.is_empty() { 1522 + // Empty lxm means wildcard (bare `rpc` token). 1523 + lxm_set.insert(RpcLexicon::All); 1524 + } else { 1525 + for &(start, end) in lxm.iter() { 1526 + let s = &buffer[start as usize..end as usize]; 1527 + if s == "*" { 1528 + lxm_set.insert(RpcLexicon::All); 1529 + } else { 1530 + lxm_set.insert(RpcLexicon::Nsid(unsafe { Nsid::unchecked(s) })); 1531 + } 1532 + } 1533 + } 1534 + 1535 + if aud.is_empty() { 1536 + // Empty aud means wildcard. 1537 + aud_set.insert(RpcAudience::All); 1538 + } else { 1539 + for &(start, end) in aud.iter() { 1540 + let s = &buffer[start as usize..end as usize]; 1541 + if s == "*" { 1542 + aud_set.insert(RpcAudience::All); 1543 + } else { 1544 + aud_set.insert(RpcAudience::Did(unsafe { Did::unchecked(s) })); 1545 + } 1546 + } 1547 + } 1548 + 1549 + Scope::Rpc(RpcScope { 1550 + lxm: lxm_set, 1551 + aud: aud_set, 1552 + }) 1553 + } 1554 + 1555 + ScopeInnerIndices::Include { nsid, audience } => { 1556 + let (ns, ne) = *nsid; 1557 + let nsid_str = &buffer[ns as usize..ne as usize]; 1558 + 1559 + let aud = match audience { 1560 + None => None, 1561 + Some(IncludeAudience::Plain(start, end)) 1562 + | Some(IncludeAudience::Encoded(start, end)) => { 1563 + Some(&buffer[*start as usize..*end as usize]) 1564 + } 1565 + }; 1566 + 1567 + Scope::Include(IncludeScope { 1568 + nsid: unsafe { Nsid::unchecked(nsid_str) }, 1569 + audience: aud, 1570 + }) 1571 + } 1572 + } 1573 + } 1574 + 615 1575 impl<S: BosStr + Ord> Scope<S> { 616 1576 /// Convert to a `Scope` with a different backing type. 617 1577 pub fn convert<B: Bos<str> + From<S> + AsRef<str> + FromStaticStr + Ord>(self) -> Scope<B> { ··· 2543 3503 assert!(!result.contains(&Scope::parse("account:email").unwrap())); 2544 3504 assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap())); 2545 3505 assert!(result.contains(&Scope::parse("account:repo").unwrap())); 3506 + } 3507 + 3508 + // ======================================================================== 3509 + // Tests for Task 1: Scopes<S> container and constructor 3510 + // ======================================================================== 3511 + 3512 + #[test] 3513 + fn test_scopes_new_multiple() { 3514 + // Test AC3.1: Parse multiple scopes and create indices. 3515 + let scopes = Scopes::new(SmolStr::new_static("atproto rpc:* repo:app.bsky.feed.post")).unwrap(); 3516 + assert_eq!(scopes.len(), 3); 3517 + } 3518 + 3519 + #[test] 3520 + fn test_scopes_new_empty() { 3521 + // Test AC3.7: Empty string produces empty Scopes. 3522 + let scopes = Scopes::new(SmolStr::new_static("")).unwrap(); 3523 + assert!(scopes.is_empty()); 3524 + } 3525 + 3526 + #[test] 3527 + fn test_scopes_new_with_spaces() { 3528 + // Test consecutive spaces are handled. 3529 + let scopes = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 3530 + assert_eq!(scopes.len(), 2); 3531 + } 3532 + 3533 + #[test] 3534 + fn test_scopes_new_invalid_scope() { 3535 + // Test AC3.8: Invalid scope is rejected. 3536 + let result = Scopes::new(SmolStr::new_static("atproto badscope")); 3537 + assert!(result.is_err()); 3538 + match result.unwrap_err() { 3539 + ParseError::UnknownPrefix(_) => {} 3540 + e => panic!("expected UnknownPrefix error, got {:?}", e), 3541 + } 3542 + } 3543 + 3544 + #[test] 3545 + fn test_scopes_buffer_size_limit() { 3546 + // Test buffer exceeding u16 limit is rejected. 3547 + let too_long = "a".repeat(u16::MAX as usize + 1); 3548 + let smol = SmolStr::from(too_long.as_str()); 3549 + let result = Scopes::new(smol); 3550 + assert!(result.is_err()); 3551 + } 3552 + 3553 + #[test] 3554 + fn test_scopes_unit_scope_parsing() { 3555 + // Test each unit scope parses correctly. 3556 + let test_cases = vec![ 3557 + ("atproto", 1), 3558 + ("openid", 1), 3559 + ("profile", 1), 3560 + ("email", 1), 3561 + ("atproto openid profile", 3), 3562 + ]; 3563 + 3564 + for (input, expected_count) in test_cases { 3565 + let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3566 + assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3567 + } 3568 + } 3569 + 3570 + #[test] 3571 + fn test_scopes_account_scope_parsing() { 3572 + // Test account scopes parse correctly. 3573 + let test_cases = vec![ 3574 + ("account:email", 1), 3575 + ("account:repo", 1), 3576 + ("account:status", 1), 3577 + ("account:email?action=manage", 1), 3578 + ("account:email?action=read", 1), 3579 + ]; 3580 + 3581 + for (input, expected_count) in test_cases { 3582 + let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3583 + assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3584 + } 3585 + } 3586 + 3587 + #[test] 3588 + fn test_scopes_identity_scope_parsing() { 3589 + // Test identity scopes parse correctly. 3590 + let test_cases = vec![ 3591 + ("identity:handle", 1), 3592 + ("identity:*", 1), 3593 + ("identity:handle identity:*", 2), 3594 + ]; 3595 + 3596 + for (input, expected_count) in test_cases { 3597 + let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3598 + assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3599 + } 3600 + } 3601 + 3602 + #[test] 3603 + fn test_scopes_blob_scope_parsing() { 3604 + // Test blob scopes parse correctly. 3605 + let test_cases = vec![ 3606 + ("blob:*/*", 1), 3607 + ("blob:image/png", 1), 3608 + ("blob:image/*", 1), 3609 + ("blob?accept=image/png&accept=image/jpeg", 1), 3610 + ]; 3611 + 3612 + for (input, expected_count) in test_cases { 3613 + let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3614 + assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3615 + } 3616 + } 3617 + 3618 + #[test] 3619 + fn test_scopes_repo_scope_parsing() { 3620 + // Test repo scopes parse correctly. 3621 + let test_cases = vec![ 3622 + ("repo:*", 1), 3623 + ("repo:app.bsky.feed.post", 1), 3624 + ("repo:app.bsky.feed.post?action=create", 1), 3625 + ("repo:app.bsky.feed.post?action=create&action=update", 1), 3626 + ]; 3627 + 3628 + for (input, expected_count) in test_cases { 3629 + let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3630 + assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3631 + } 3632 + } 3633 + 3634 + #[test] 3635 + fn test_scopes_rpc_scope_parsing() { 3636 + // Test rpc scopes parse correctly. 3637 + let test_cases = vec![ 3638 + ("rpc:*", 1), 3639 + ("rpc:com.example.service", 1), 3640 + ("rpc:com.example.service?aud=did:web:example.com", 1), 3641 + ("rpc?lxm=com.example.service&aud=did:web:example.com", 1), 3642 + ]; 3643 + 3644 + for (input, expected_count) in test_cases { 3645 + let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3646 + assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3647 + } 3648 + } 3649 + 3650 + #[test] 3651 + fn test_scopes_include_scope_parsing() { 3652 + // Test include scopes parse correctly. 3653 + let test_cases = vec![ 3654 + ("include:app.bsky.authFull", 1), 3655 + ("include:app.bsky.full?aud=did:web:api.example.com", 1), 3656 + ("include:app.bsky.full?aud=did:web:api.example.com%23svc_appview", 1), 3657 + ]; 3658 + 3659 + for (input, expected_count) in test_cases { 3660 + let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3661 + assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3662 + } 3663 + } 3664 + 3665 + #[test] 3666 + fn test_scopes_include_missing_nsid() { 3667 + // Test AC2.6: include: with no NSID is rejected. 3668 + let result = Scopes::new(SmolStr::new_static("include:")); 3669 + assert!(result.is_err()); 3670 + } 3671 + 3672 + #[test] 3673 + fn test_scopes_include_invalid_audience_did() { 3674 + // Test AC2.7: include scope with invalid DID audience is rejected. 3675 + let result = Scopes::new(SmolStr::new_static("include:app.bsky.authFull?aud=notadid")); 3676 + assert!(result.is_err()); 3677 + } 3678 + 3679 + #[test] 3680 + fn test_scopes_transition_scope_parsing() { 3681 + // Test transition scopes parse correctly. 3682 + let test_cases = vec![ 3683 + ("transition:generic", 1), 3684 + ("transition:email", 1), 3685 + ("transition:chat.bsky", 1), 3686 + ("transition:generic transition:email", 2), 3687 + ]; 3688 + 3689 + for (input, expected_count) in test_cases { 3690 + let scopes = Scopes::new(SmolStr::new_static(input)).unwrap(); 3691 + assert_eq!(scopes.len(), expected_count, "failed for: {}", input); 3692 + } 3693 + } 3694 + 3695 + #[test] 3696 + fn test_scopes_reduction_removes_broader_scope() { 3697 + // Test that broader scopes subsume narrower ones. 3698 + // repo:* grants repo:app.bsky.feed.post, so only repo:* should remain. 3699 + let scopes = Scopes::new(SmolStr::new_static("repo:app.bsky.feed.post repo:*")).unwrap(); 3700 + assert_eq!(scopes.len(), 1); 3701 + } 3702 + 3703 + // ======================================================================== 3704 + // Tests for Task 2: Scope reconstruction from indices (via Scopes container) 3705 + // ======================================================================== 3706 + 3707 + #[test] 3708 + fn test_scopes_reconstruction_unit() { 3709 + // Reconstruct unit scopes from indices. 3710 + let scopes = Scopes::new(SmolStr::new_static("atproto openid")).unwrap(); 3711 + assert_eq!(scopes.len(), 2); 3712 + } 3713 + 3714 + #[test] 3715 + fn test_scopes_reconstruction_account() { 3716 + // Reconstruct account scopes from indices. 3717 + let scopes = Scopes::new(SmolStr::new_static("account:email account:repo?action=manage")).unwrap(); 3718 + assert_eq!(scopes.len(), 2); 3719 + } 3720 + 3721 + #[test] 3722 + fn test_scopes_reconstruction_identity() { 3723 + // Reconstruct identity scopes from indices. 3724 + let scopes = Scopes::new(SmolStr::new_static("identity:handle identity:*")).unwrap(); 3725 + assert_eq!(scopes.len(), 2); 3726 + } 3727 + 3728 + #[test] 3729 + fn test_scopes_reconstruction_blob() { 3730 + // Reconstruct blob scopes from indices. 3731 + let scopes = Scopes::new(SmolStr::new_static("blob:image/png blob:*/*")).unwrap(); 3732 + assert_eq!(scopes.len(), 2); 3733 + } 3734 + 3735 + #[test] 3736 + fn test_scopes_reconstruction_repo() { 3737 + // Reconstruct repo scopes from indices. 3738 + let scopes = Scopes::new(SmolStr::new_static("repo:app.bsky.feed.post repo:*")).unwrap(); 3739 + // repo:* grants repo:app.bsky.feed.post, so only repo:* should remain. 3740 + assert_eq!(scopes.len(), 1); 3741 + } 3742 + 3743 + #[test] 3744 + fn test_scopes_reconstruction_rpc() { 3745 + // Reconstruct rpc scopes from indices. 3746 + let scopes = Scopes::new(SmolStr::new_static("rpc:com.example.service rpc:*")).unwrap(); 3747 + // rpc:* grants rpc:com.example.service, so only rpc:* should remain. 3748 + assert_eq!(scopes.len(), 1); 3749 + } 3750 + 3751 + #[test] 3752 + fn test_scopes_reconstruction_include() { 3753 + // Reconstruct include scopes from indices. 3754 + let scopes = Scopes::new(SmolStr::new_static("include:app.bsky.authFull include:app.bsky.full?aud=did:web:api.example.com")).unwrap(); 3755 + assert_eq!(scopes.len(), 2); 3756 + } 3757 + 3758 + // ======================================================================== 3759 + // Task 3: Accessor Tests 3760 + // ======================================================================== 3761 + 3762 + #[test] 3763 + fn test_scopes_iter() { 3764 + // oauth-scopes-rework.AC3.2: `iter()` yields correctly typed `Scope<&str>` 3765 + // views borrowing from the buffer. 3766 + let scopes = Scopes::new(SmolStr::new_static("atproto rpc:* repo:app.bsky.feed.post")).unwrap(); 3767 + 3768 + let collected: Vec<_> = scopes.iter().collect(); 3769 + 3770 + // Verify we got scopes back 3771 + assert!(!collected.is_empty()); 3772 + 3773 + // Verify we can iterate and get expected scope types 3774 + let has_atproto = collected.iter().any(|s| matches!(s, Scope::Atproto)); 3775 + let has_rpc = collected.iter().any(|s| matches!(s, Scope::Rpc(_))); 3776 + let has_repo = collected.iter().any(|s| matches!(s, Scope::Repo(_))); 3777 + 3778 + assert!(has_atproto, "Should contain Atproto scope"); 3779 + assert!(has_rpc, "Should contain Rpc scope"); 3780 + assert!(has_repo, "Should contain Repo scope"); 3781 + } 3782 + 3783 + #[test] 3784 + fn test_scopes_get() { 3785 + // Test `get()` accessor for positional index access. 3786 + let scopes = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 3787 + assert_eq!(scopes.len(), 2); 3788 + 3789 + let first = scopes.get(0).expect("First scope should exist"); 3790 + match first { 3791 + Scope::Atproto => (), 3792 + _ => panic!("Expected Atproto scope"), 3793 + } 3794 + 3795 + let second = scopes.get(1).expect("Second scope should exist"); 3796 + match second { 3797 + Scope::Rpc(_) => (), 3798 + _ => panic!("Expected Rpc scope"), 3799 + } 3800 + 3801 + let third = scopes.get(2); 3802 + assert!(third.is_none(), "Third scope should not exist"); 3803 + } 3804 + 3805 + #[test] 3806 + fn test_scopes_get_owned() { 3807 + // oauth-scopes-rework.AC3.3: `get_owned()` returns `Scope<SmolStr>` 3808 + // independent of the buffer's lifetime. 3809 + let scopes = Scopes::new(SmolStr::new_static("atproto repo:app.bsky.feed.post")).unwrap(); 3810 + assert_eq!(scopes.len(), 2); 3811 + 3812 + let owned = scopes.get_owned(0).expect("First scope should exist"); 3813 + match owned { 3814 + Scope::Atproto => (), 3815 + _ => panic!("Expected Atproto scope"), 3816 + } 3817 + 3818 + let repo_owned = scopes.get_owned(1).expect("Second scope should exist"); 3819 + match repo_owned { 3820 + Scope::Repo(_) => (), 3821 + _ => panic!("Expected Repo scope"), 3822 + } 3823 + 3824 + let none = scopes.get_owned(99); 3825 + assert!(none.is_none(), "Out-of-bounds access should return None"); 3826 + } 3827 + 3828 + #[test] 3829 + fn test_scopes_get_as() { 3830 + // Test `get_as()` with caller-chosen backing type. 3831 + let scopes = Scopes::new(SmolStr::new_static("atproto")).unwrap(); 3832 + 3833 + // Convert to String backing 3834 + let as_string: Option<Scope<String>> = scopes.get_as(0); 3835 + assert!(as_string.is_some()); 3836 + match as_string { 3837 + Some(Scope::Atproto) => (), 3838 + _ => panic!("Expected Atproto scope as String"), 3839 + } 3840 + 3841 + // Verify get_as handles out of bounds 3842 + let out_of_bounds: Option<Scope<String>> = scopes.get_as(10); 3843 + assert!(out_of_bounds.is_none()); 3844 + } 3845 + 3846 + #[test] 3847 + fn test_scopes_iter_multiple() { 3848 + // Verify iterator works with multiple scope types. 3849 + let scopes = Scopes::new(SmolStr::new_static( 3850 + "atproto rpc:* repo:app.bsky.feed.post account:email identity:handle", 3851 + )) 3852 + .unwrap(); 3853 + 3854 + let mut count = 0; 3855 + for scope in scopes.iter() { 3856 + count += 1; 3857 + // Just verify we can iterate and get back a Scope 3858 + let _ = scope; 3859 + } 3860 + 3861 + assert_eq!(count, scopes.len()); 3862 + } 3863 + 3864 + #[test] 3865 + fn test_scopes_iter_empty() { 3866 + // Verify iterator works on empty Scopes. 3867 + let scopes = Scopes::new(SmolStr::new_static("")).unwrap(); 3868 + assert!(scopes.is_empty()); 3869 + 3870 + let collected: Vec<_> = scopes.iter().collect(); 3871 + assert_eq!(collected.len(), 0); 3872 + } 3873 + 3874 + // ======================================================================== 3875 + // Task 4: Conversion Tests 3876 + // ======================================================================== 3877 + 3878 + #[test] 3879 + fn test_scopes_borrow() { 3880 + // oauth-scopes-rework.AC3.4: `borrow()` produces `Scopes<&str>` cheaply. 3881 + let original: Scopes<SmolStr> = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 3882 + assert_eq!(original.len(), 2); 3883 + 3884 + let borrowed: Scopes<&str> = original.borrow(); 3885 + assert_eq!(borrowed.len(), 2); 3886 + 3887 + // Verify the borrowed version has the same scopes 3888 + let iter_count: usize = borrowed.iter().count(); 3889 + assert_eq!(iter_count, 2); 3890 + 3891 + // Verify content matches 3892 + let orig_iter = original.iter().collect::<Vec<_>>(); 3893 + let borrow_iter = borrowed.iter().collect::<Vec<_>>(); 3894 + assert_eq!(orig_iter.len(), borrow_iter.len()); 3895 + } 3896 + 3897 + #[test] 3898 + fn test_scopes_convert() { 3899 + // oauth-scopes-rework.AC3.5: `convert()` produces correct backing type conversions. 3900 + let original: Scopes<SmolStr> = Scopes::new(SmolStr::new_static("atproto repo:*")).unwrap(); 3901 + assert_eq!(original.len(), 2); 3902 + 3903 + // Convert to String 3904 + let converted: Scopes<String> = original.convert(); 3905 + assert_eq!(converted.len(), 2); 3906 + 3907 + // Verify content is preserved 3908 + let converted_iter = converted.iter().collect::<Vec<_>>(); 3909 + assert_eq!(converted_iter.len(), 2); 3910 + 3911 + match &converted_iter[0] { 3912 + Scope::Atproto => (), 3913 + _ => panic!("Expected Atproto scope"), 3914 + } 3915 + } 3916 + 3917 + #[test] 3918 + fn test_scopes_into_static() { 3919 + // Test IntoStatic trait implementation. 3920 + use jacquard_common::CowStr; 3921 + 3922 + let original = Scopes::new(CowStr::copy_from_str("atproto rpc:*")).unwrap(); 3923 + assert_eq!(original.len(), 2); 3924 + 3925 + let static_scopes = original.into_static(); 3926 + assert_eq!(static_scopes.len(), 2); 3927 + 3928 + // Verify content is preserved 3929 + let iter_count: usize = static_scopes.iter().count(); 3930 + assert_eq!(iter_count, 2); 3931 + } 3932 + 3933 + #[test] 3934 + fn test_scopes_conversions_preserve_content() { 3935 + // Verify that all conversion methods preserve scope content. 3936 + let input = "atproto repo:app.bsky.feed.post?action=create account:repo"; 3937 + let original: Scopes<SmolStr> = Scopes::new(SmolStr::new(input)).unwrap(); 3938 + let original_count = original.len(); 3939 + 3940 + // Test borrow 3941 + let borrowed = original.borrow(); 3942 + assert_eq!(borrowed.len(), original_count); 3943 + 3944 + // Verify both have the same normalized output before converting 3945 + let orig_normalized = original.to_normalized_string(); 3946 + let borrow_normalized = borrowed.to_normalized_string(); 3947 + assert_eq!(orig_normalized, borrow_normalized); 3948 + 3949 + // Test convert (this moves original) 3950 + let converted: Scopes<String> = original.convert(); 3951 + assert_eq!(converted.len(), original_count); 3952 + 3953 + let conv_normalized = converted.to_normalized_string(); 3954 + assert_eq!(orig_normalized, conv_normalized); 3955 + } 3956 + 3957 + // ======================================================================== 3958 + // Task 5: Serialize Tests 3959 + // ======================================================================== 3960 + 3961 + #[test] 3962 + fn test_scopes_serialize_single() { 3963 + // Test serialization of a single scope. 3964 + let scopes = Scopes::new(SmolStr::new_static("atproto")).unwrap(); 3965 + let json = serde_json::to_string(&scopes).unwrap(); 3966 + assert_eq!(json, "\"atproto\""); 3967 + } 3968 + 3969 + #[test] 3970 + fn test_scopes_serialize_multiple_sorted() { 3971 + // oauth-scopes-rework.AC3.6: Serialize produces sorted output 3972 + // regardless of input order. 3973 + let scopes = Scopes::new(SmolStr::new_static("rpc:* atproto repo:app.bsky.feed.post")).unwrap(); 3974 + let json = serde_json::to_string(&scopes).unwrap(); 3975 + // Should be sorted: atproto, repo:app.bsky.feed.post, rpc:* 3976 + assert_eq!(json, "\"atproto repo:app.bsky.feed.post rpc:*\""); 3977 + } 3978 + 3979 + #[test] 3980 + fn test_scopes_serialize_empty() { 3981 + // Test serialization of empty Scopes. 3982 + let scopes: Scopes<SmolStr> = Scopes::empty(); 3983 + let json = serde_json::to_string(&scopes).unwrap(); 3984 + assert_eq!(json, "\"\""); 3985 + } 3986 + 3987 + #[test] 3988 + fn test_scopes_serialize_with_reduction() { 3989 + // Test serialization when scopes reduce (e.g., repo:* includes repo:app.bsky.feed.post). 3990 + let scopes = Scopes::new(SmolStr::new_static("repo:* repo:app.bsky.feed.post")).unwrap(); 3991 + // Should reduce to just repo:* 3992 + assert_eq!(scopes.len(), 1); 3993 + let json = serde_json::to_string(&scopes).unwrap(); 3994 + assert_eq!(json, "\"repo:*\""); 3995 + } 3996 + 3997 + #[test] 3998 + fn test_scopes_serialize_includes_include_scope() { 3999 + // Test serialization with include scope. 4000 + let scopes = Scopes::new(SmolStr::new_static("atproto include:app.bsky.authFull")).unwrap(); 4001 + let json = serde_json::to_string(&scopes).unwrap(); 4002 + // Should be sorted and include normalized form 4003 + assert_eq!(json, "\"atproto include:app.bsky.authFull\""); 4004 + } 4005 + 4006 + // ======================================================================== 4007 + // Task 6: Deserialize Tests 4008 + // ======================================================================== 4009 + 4010 + #[test] 4011 + fn test_scopes_deserialize_single() { 4012 + // Test deserialization of a single scope. 4013 + let json = "\"atproto\""; 4014 + let scopes: Scopes<SmolStr> = serde_json::from_str(json).unwrap(); 4015 + assert_eq!(scopes.len(), 1); 4016 + match scopes.get(0) { 4017 + Some(Scope::Atproto) => (), 4018 + _ => panic!("Expected Atproto scope"), 4019 + } 4020 + } 4021 + 4022 + #[test] 4023 + fn test_scopes_deserialize_multiple() { 4024 + // Test deserialization of multiple scopes. 4025 + let json = "\"atproto rpc:* repo:app.bsky.feed.post\""; 4026 + let scopes: Scopes<SmolStr> = serde_json::from_str(json).unwrap(); 4027 + assert_eq!(scopes.len(), 3); 4028 + } 4029 + 4030 + #[test] 4031 + fn test_scopes_deserialize_empty() { 4032 + // Test deserialization of empty string. 4033 + let json = "\"\""; 4034 + let scopes: Scopes<SmolStr> = serde_json::from_str(json).unwrap(); 4035 + assert_eq!(scopes.len(), 0); 4036 + assert!(scopes.is_empty()); 4037 + } 4038 + 4039 + #[test] 4040 + fn test_scopes_serde_roundtrip() { 4041 + // oauth-scopes-rework.AC3.6: Round-trip test with sorting verification. 4042 + let input = "rpc:* atproto repo:app.bsky.feed.post account:email"; 4043 + let scopes: Scopes<SmolStr> = Scopes::new(SmolStr::new(input)).unwrap(); 4044 + 4045 + // Serialize 4046 + let json = serde_json::to_string(&scopes).unwrap(); 4047 + 4048 + // Should be sorted 4049 + assert_eq!(json, "\"account:email atproto repo:app.bsky.feed.post rpc:*\""); 4050 + 4051 + // Deserialize 4052 + let deserialized: Scopes<SmolStr> = serde_json::from_str(&json).unwrap(); 4053 + 4054 + // Should have same len (reduction applied) 4055 + assert_eq!(deserialized.len(), scopes.len()); 4056 + 4057 + // Verify content matches by collecting scopes 4058 + let orig_normalized = scopes.to_normalized_string(); 4059 + let deser_normalized = deserialized.to_normalized_string(); 4060 + assert_eq!(orig_normalized, deser_normalized); 4061 + } 4062 + 4063 + #[test] 4064 + fn test_scopes_deserialize_invalid() { 4065 + // Test deserialization of invalid scope. 4066 + let json = "\"invalid:notagoodscope\""; 4067 + let result: Result<Scopes<SmolStr>, _> = serde_json::from_str(json); 4068 + assert!(result.is_err()); 4069 + } 4070 + 4071 + #[test] 4072 + fn test_scopes_roundtrip_with_encoded_audience() { 4073 + // AC2.3: include scope with audience (including special chars) can be serialized and deserialized. 4074 + // Create an include scope with audience containing special character 4075 + let input = "include:app.bsky.authFull?aud=did:web:example.com%23svc"; 4076 + let scopes: Scopes<SmolStr> = Scopes::new(SmolStr::new(input)).unwrap(); 4077 + assert_eq!(scopes.len(), 1); 4078 + 4079 + // Serialize to JSON - should not panic 4080 + let json = serde_json::to_string(&scopes).unwrap(); 4081 + assert!(json.contains("include:app.bsky.authFull")); 4082 + 4083 + // Deserialize back - should not panic 4084 + let deserialized: Scopes<SmolStr> = serde_json::from_str(&json).unwrap(); 4085 + 4086 + // Scopes should have the same length 4087 + assert_eq!(scopes.len(), deserialized.len()); 4088 + assert_eq!(deserialized.len(), 1); 4089 + } 4090 + 4091 + // ======================================================================== 4092 + // Task 7: Convenience Methods Tests 4093 + // ======================================================================== 4094 + 4095 + #[test] 4096 + fn test_scopes_len() { 4097 + // AC3.1: len() returns correct count after reduction. 4098 + let scopes = Scopes::new(SmolStr::new_static("atproto repo:* repo:app.bsky.feed.post")).unwrap(); 4099 + // repo:* should reduce the more specific one 4100 + assert_eq!(scopes.len(), 2); // atproto + repo:* 4101 + } 4102 + 4103 + #[test] 4104 + fn test_scopes_is_empty() { 4105 + // AC3.7: is_empty() returns true for empty Scopes. 4106 + let empty: Scopes<SmolStr> = Scopes::empty(); 4107 + assert!(empty.is_empty()); 4108 + 4109 + let nonempty = Scopes::new(SmolStr::new_static("atproto")).unwrap(); 4110 + assert!(!nonempty.is_empty()); 4111 + } 4112 + 4113 + #[test] 4114 + fn test_scopes_as_str() { 4115 + // Test as_str() returns the raw buffer. 4116 + let scopes = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 4117 + let s = scopes.as_str(); 4118 + assert_eq!(s, "atproto rpc:*"); 4119 + } 4120 + 4121 + #[test] 4122 + fn test_scopes_to_normalized_string() { 4123 + // Test to_normalized_string() produces same output as serialize. 4124 + let scopes = Scopes::new(SmolStr::new_static("rpc:* atproto")).unwrap(); 4125 + let normalized = scopes.to_normalized_string(); 4126 + assert_eq!(normalized, "atproto rpc:*"); 4127 + 4128 + // Serialize should match 4129 + let json = serde_json::to_string(&scopes).unwrap(); 4130 + assert_eq!(json, "\"atproto rpc:*\""); 4131 + } 4132 + 4133 + #[test] 4134 + fn test_scopes_empty_constructor() { 4135 + // Test Scopes::<SmolStr>::empty() creates empty container. 4136 + let empty = Scopes::empty(); 4137 + assert_eq!(empty.len(), 0); 4138 + assert!(empty.is_empty()); 4139 + assert_eq!(empty.to_normalized_string(), SmolStr::default()); 4140 + } 4141 + 4142 + #[test] 4143 + fn test_scopes_default() { 4144 + // Test Default trait for Scopes. 4145 + let default: Scopes<SmolStr> = Default::default(); 4146 + assert_eq!(default.len(), 0); 4147 + assert!(default.is_empty()); 4148 + } 4149 + 4150 + #[test] 4151 + fn test_scopes_grants_single() { 4152 + // Test grants() method with single scope. 4153 + let scopes = Scopes::new(SmolStr::new_static("repo:*")).unwrap(); 4154 + let queried: Scope<SmolStr> = Scope::parse("repo:app.bsky.feed.post").unwrap(); 4155 + assert!(scopes.grants(&queried)); 4156 + 4157 + let queried2: Scope<SmolStr> = Scope::parse("atproto").unwrap(); 4158 + assert!(!scopes.grants(&queried2)); 4159 + } 4160 + 4161 + #[test] 4162 + fn test_scopes_grants_multiple() { 4163 + // Test grants() with multiple scopes. 4164 + let scopes = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 4165 + let queried: Scope<SmolStr> = Scope::parse("rpc:com.atproto.server.createSession").unwrap(); 4166 + assert!(scopes.grants(&queried)); 4167 + } 4168 + 4169 + #[test] 4170 + fn test_scopes_construction() { 4171 + // AC3.1: Construct multi-scope string, verify len and individual scopes. 4172 + let scopes = Scopes::new(SmolStr::new_static("atproto rpc:* repo:app.bsky.feed.post")).unwrap(); 4173 + assert_eq!(scopes.len(), 3); 4174 + 4175 + // Verify individual scopes 4176 + match scopes.get(0) { 4177 + Some(Scope::Atproto) => (), 4178 + _ => panic!("Expected Atproto at index 0"), 4179 + } 4180 + assert!(scopes.get(1).is_some()); 4181 + assert!(scopes.get(2).is_some()); 4182 + assert!(scopes.get(3).is_none()); 4183 + } 4184 + 4185 + #[test] 4186 + fn test_scopes_empty_string() { 4187 + // AC3.7: Empty string produces empty Scopes. 4188 + let scopes = Scopes::new(SmolStr::new_static("")).unwrap(); 4189 + assert_eq!(scopes.len(), 0); 4190 + assert!(scopes.is_empty()); 4191 + } 4192 + 4193 + #[test] 4194 + fn test_scopes_invalid_scope() { 4195 + // AC3.8: Invalid scope in string causes construction failure. 4196 + let result = Scopes::new(SmolStr::new("invalid:nosuchprefix")); 4197 + assert!(result.is_err()); 4198 + } 4199 + 4200 + #[test] 4201 + fn test_scopes_iter_collection() { 4202 + // AC3.2: Iterate, collect, verify typed views. 4203 + let scopes = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 4204 + let collected: Vec<_> = scopes.iter().collect(); 4205 + assert_eq!(collected.len(), 2); 4206 + assert!(matches!(collected[0], Scope::Atproto)); 4207 + } 4208 + 4209 + 4210 + #[test] 4211 + fn test_scopes_consecutive_spaces() { 4212 + // Test handling of multiple spaces between scopes. 4213 + let scopes = Scopes::new(SmolStr::new("atproto rpc:*")).unwrap(); 4214 + assert_eq!(scopes.len(), 2); 4215 + } 4216 + 4217 + #[test] 4218 + fn test_scopes_reduction() { 4219 + // Test scope reduction (broader scope removes more specific ones). 4220 + let scopes = Scopes::new(SmolStr::new_static("repo:* repo:app.bsky.feed.post")).unwrap(); 4221 + assert_eq!(scopes.len(), 1); // Should reduce to just repo:* 4222 + } 4223 + 4224 + #[test] 4225 + fn test_scopes_include_no_audience() { 4226 + // AC2.1: include scope with no audience parses correctly. 4227 + let scopes = Scopes::new(SmolStr::new_static("include:app.bsky.authFull")).unwrap(); 4228 + assert_eq!(scopes.len(), 1); 4229 + match scopes.get(0) { 4230 + Some(Scope::Include(inc)) => { 4231 + assert_eq!(inc.nsid.as_ref(), "app.bsky.authFull"); 4232 + assert_eq!(inc.audience, None); 4233 + } 4234 + _ => panic!("Expected Include scope"), 4235 + } 4236 + } 4237 + 4238 + #[test] 4239 + fn test_scopes_include_plain_audience() { 4240 + // AC2.2: include scope with plain unencoded audience. 4241 + let scopes = Scopes::new(SmolStr::new_static("include:app.bsky.authFull?aud=did:web:api.example.com")).unwrap(); 4242 + assert_eq!(scopes.len(), 1); 4243 + match scopes.get(0) { 4244 + Some(Scope::Include(inc)) => { 4245 + assert_eq!(inc.nsid.as_ref(), "app.bsky.authFull"); 4246 + assert!(inc.audience.is_some()); 4247 + } 4248 + _ => panic!("Expected Include scope"), 4249 + } 4250 + } 4251 + 4252 + #[test] 4253 + fn test_scopes_include_empty_nsid() { 4254 + // AC2.6: include with no NSID is rejected. 4255 + let result = Scopes::new(SmolStr::new("include:")); 4256 + assert!(result.is_err()); 4257 + } 4258 + 4259 + #[test] 4260 + fn test_scopes_include_invalid_did_audience() { 4261 + // AC2.7: include with invalid DID audience is rejected. 4262 + let result = Scopes::new(SmolStr::new("include:app.bsky.authFull?aud=notadid")); 4263 + assert!(result.is_err()); 4264 + } 4265 + 4266 + #[test] 4267 + fn test_scopes_all_prefixes() { 4268 + // Test every scope prefix parses correctly in a Scopes container. 4269 + let prefixes = vec![ 4270 + "account:email", 4271 + "identity:handle", 4272 + "blob:*/*", 4273 + "repo:*", 4274 + "rpc:*", 4275 + "atproto", 4276 + "transition:generic", 4277 + "openid", 4278 + "profile", 4279 + "email", 4280 + ]; 4281 + 4282 + for prefix in prefixes { 4283 + let scopes = Scopes::new(SmolStr::new(prefix)).unwrap(); 4284 + assert_eq!(scopes.len(), 1, "Failed to parse: {}", prefix); 4285 + } 4286 + } 4287 + 4288 + #[test] 4289 + fn test_scopes_borrow_borrowshare() { 4290 + // AC3.4: borrow() produces Scopes<&str> with BorrowOrShare semantics. 4291 + let original: Scopes<SmolStr> = Scopes::new(SmolStr::new_static("atproto rpc:*")).unwrap(); 4292 + let borrowed: Scopes<&str> = original.borrow(); 4293 + assert_eq!(borrowed.len(), original.len()); 4294 + 4295 + // Both should iterate the same 4296 + let orig_iter = original.iter().collect::<Vec<_>>(); 4297 + let borrow_iter = borrowed.iter().collect::<Vec<_>>(); 4298 + assert_eq!(orig_iter.len(), borrow_iter.len()); 4299 + } 4300 + 4301 + #[test] 4302 + fn test_scopes_convert_type() { 4303 + // AC3.5: convert() produces correct backing type. 4304 + let original: Scopes<SmolStr> = Scopes::new(SmolStr::new_static("atproto")).unwrap(); 4305 + let converted: Scopes<String> = original.convert(); 4306 + assert_eq!(converted.len(), 1); 4307 + assert!(matches!(converted.get(0), Some(Scope::Atproto))); 4308 + } 4309 + 4310 + #[test] 4311 + fn test_scopes_bare_blob_defaults_to_all() { 4312 + // Critical fix: bare `blob` token (without suffix) should default to MimePattern::All. 4313 + // This tests that we don't store unsound byte ranges past the token. 4314 + let scopes = Scopes::new(SmolStr::new("blob")).unwrap(); 4315 + assert_eq!(scopes.len(), 1); 4316 + 4317 + let scope = scopes.get(0).unwrap(); 4318 + if let Scope::Blob(blob_scope) = scope { 4319 + // Should accept all mime types. 4320 + assert_eq!(blob_scope.accept.len(), 1); 4321 + assert!(blob_scope.accept.contains(&MimePattern::All)); 4322 + } else { 4323 + panic!("Expected Scope::Blob, got {:?}", scope); 4324 + } 4325 + 4326 + // Verify reconstruction and normalization work. 4327 + // Normalized form expands bare `blob` to explicit `blob:*/*`. 4328 + let normalized = scopes.to_normalized_string(); 4329 + assert_eq!(normalized, "blob:*/*"); 4330 + } 4331 + 4332 + #[test] 4333 + fn test_scopes_bare_rpc_defaults_to_all() { 4334 + // Critical fix: bare `rpc` token (without suffix) should default to all lexicons and audiences. 4335 + // This tests that we don't store unsound byte ranges past the token. 4336 + let scopes = Scopes::new(SmolStr::new("rpc")).unwrap(); 4337 + assert_eq!(scopes.len(), 1); 4338 + 4339 + let scope = scopes.get(0).unwrap(); 4340 + if let Scope::Rpc(rpc_scope) = scope { 4341 + // Should accept all lexicons and audiences. 4342 + assert_eq!(rpc_scope.lxm.len(), 1); 4343 + assert!(rpc_scope.lxm.contains(&RpcLexicon::All)); 4344 + assert_eq!(rpc_scope.aud.len(), 1); 4345 + assert!(rpc_scope.aud.contains(&RpcAudience::All)); 4346 + } else { 4347 + panic!("Expected Scope::Rpc, got {:?}", scope); 4348 + } 4349 + 4350 + // Verify reconstruction and normalization work. 4351 + // Normalized form expands bare `rpc` to explicit `rpc:*`. 4352 + let normalized = scopes.to_normalized_string(); 4353 + assert_eq!(normalized, "rpc:*"); 2546 4354 } 2547 4355 }