use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Scope {
Account(AccountScope),
Identity(IdentityScope),
Blob(BlobScope),
Repo(RepoScope),
Rpc(RpcScope),
Atproto,
Transition(TransitionScope),
Include(IncludeScope),
OpenId,
Profile,
Email,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AccountScope {
pub resource: AccountResource,
pub action: AccountAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AccountResource {
Email,
Repo,
Status,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AccountAction {
Read,
Manage,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum IdentityScope {
Handle,
All,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TransitionScope {
Generic,
Email,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct IncludeScope {
pub nsid: String,
pub aud: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct BlobScope {
pub accept: BTreeSet<MimePattern>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum MimePattern {
All,
TypeWildcard(String),
Exact(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RepoScope {
pub collection: RepoCollection,
pub actions: BTreeSet<RepoAction>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum RepoCollection {
All,
Nsid(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum RepoAction {
Create,
Update,
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RpcScope {
pub lxm: BTreeSet<RpcLexicon>,
pub aud: BTreeSet<RpcAudience>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum RpcLexicon {
All,
Nsid(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum RpcAudience {
All,
Did(String),
}
impl Scope {
pub fn parse_multiple(s: &str) -> Result<Vec<Self>, ParseError> {
if s.trim().is_empty() {
return Ok(Vec::new());
}
let mut scopes = Vec::new();
for scope_str in s.split_whitespace() {
scopes.push(Self::parse(scope_str)?);
}
Ok(scopes)
}
pub fn parse_multiple_reduced(s: &str) -> Result<Vec<Self>, ParseError> {
let all_scopes = Self::parse_multiple(s)?;
if all_scopes.is_empty() {
return Ok(Vec::new());
}
let mut result: Vec<Self> = Vec::new();
for scope in all_scopes {
let mut is_granted = false;
for existing in &result {
if existing.grants(&scope) && existing != &scope {
is_granted = true;
break;
}
}
if is_granted {
continue; }
let mut indices_to_remove = Vec::new();
for (i, existing) in result.iter().enumerate() {
if scope.grants(existing) && &scope != existing {
indices_to_remove.push(i);
}
}
for i in indices_to_remove.into_iter().rev() {
result.remove(i);
}
if !result.contains(&scope) {
result.push(scope);
}
}
Ok(result)
}
pub fn serialize_multiple(scopes: &[Self]) -> String {
if scopes.is_empty() {
return String::new();
}
let mut serialized: Vec<String> = scopes.iter().map(|scope| scope.to_string()).collect();
serialized.sort();
serialized.join(" ")
}
pub fn remove_scope(scopes: &[Self], scope_to_remove: &Self) -> Vec<Self> {
scopes
.iter()
.filter(|s| *s != scope_to_remove)
.cloned()
.collect()
}
pub fn parse(s: &str) -> Result<Self, ParseError> {
let prefixes = [
"account",
"identity",
"blob",
"repo",
"rpc",
"atproto",
"transition",
"include",
"openid",
"profile",
"email",
];
let mut found_prefix = None;
let mut suffix = None;
for prefix in &prefixes {
if let Some(remainder) = s.strip_prefix(prefix)
&& (remainder.is_empty()
|| remainder.starts_with(':')
|| remainder.starts_with('?'))
{
found_prefix = Some(*prefix);
if let Some(stripped) = remainder.strip_prefix(':') {
suffix = Some(stripped);
} else if remainder.starts_with('?') {
suffix = Some(remainder);
} else {
suffix = None;
}
break;
}
}
let prefix = found_prefix.ok_or_else(|| {
let end = s.find(':').or_else(|| s.find('?')).unwrap_or(s.len());
ParseError::UnknownPrefix(s[..end].to_string())
})?;
match prefix {
"account" => Self::parse_account(suffix),
"identity" => Self::parse_identity(suffix),
"blob" => Self::parse_blob(suffix),
"repo" => Self::parse_repo(suffix),
"rpc" => Self::parse_rpc(suffix),
"atproto" => Self::parse_atproto(suffix),
"transition" => Self::parse_transition(suffix),
"include" => Self::parse_include(suffix),
"openid" => Self::parse_openid(suffix),
"profile" => Self::parse_profile(suffix),
"email" => Self::parse_email(suffix),
_ => Err(ParseError::UnknownPrefix(prefix.to_string())),
}
}
fn parse_account(suffix: Option<&str>) -> Result<Self, ParseError> {
let (resource_str, params) = match suffix {
Some(s) => {
if let Some(pos) = s.find('?') {
(&s[..pos], Some(&s[pos + 1..]))
} else {
(s, None)
}
}
None => return Err(ParseError::MissingResource),
};
let resource = match resource_str {
"email" => AccountResource::Email,
"repo" => AccountResource::Repo,
"status" => AccountResource::Status,
_ => return Err(ParseError::InvalidResource(resource_str.to_string())),
};
let action = if let Some(params) = params {
let parsed_params = parse_query_string(params);
match parsed_params
.get("action")
.and_then(|v| v.first())
.map(|s| s.as_str())
{
Some("read") => AccountAction::Read,
Some("manage") => AccountAction::Manage,
Some(other) => return Err(ParseError::InvalidAction(other.to_string())),
None => AccountAction::Read,
}
} else {
AccountAction::Read
};
Ok(Scope::Account(AccountScope { resource, action }))
}
fn parse_identity(suffix: Option<&str>) -> Result<Self, ParseError> {
let scope = match suffix {
Some("handle") => IdentityScope::Handle,
Some("*") => IdentityScope::All,
Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
None => return Err(ParseError::MissingResource),
};
Ok(Scope::Identity(scope))
}
fn parse_blob(suffix: Option<&str>) -> Result<Self, ParseError> {
let mut accept = BTreeSet::new();
match suffix {
Some(s) if s.starts_with('?') => {
let params = parse_query_string(&s[1..]);
if let Some(values) = params.get("accept") {
for value in values {
accept.insert(MimePattern::from_str(value)?);
}
}
}
Some(s) => {
accept.insert(MimePattern::from_str(s)?);
}
None => {
accept.insert(MimePattern::All);
}
}
if accept.is_empty() {
accept.insert(MimePattern::All);
}
Ok(Scope::Blob(BlobScope { accept }))
}
fn parse_repo(suffix: Option<&str>) -> Result<Self, ParseError> {
let (collection_str, params) = match suffix {
Some(s) => {
if let Some(pos) = s.find('?') {
(Some(&s[..pos]), Some(&s[pos + 1..]))
} else {
(Some(s), None)
}
}
None => (None, None),
};
let collection = match collection_str {
Some("*") | None => RepoCollection::All,
Some(nsid) => RepoCollection::Nsid(nsid.to_string()),
};
let mut actions = BTreeSet::new();
if let Some(params) = params {
let parsed_params = parse_query_string(params);
if let Some(values) = parsed_params.get("action") {
for value in values {
match value.as_str() {
"create" => {
actions.insert(RepoAction::Create);
}
"update" => {
actions.insert(RepoAction::Update);
}
"delete" => {
actions.insert(RepoAction::Delete);
}
"*" => {
actions.insert(RepoAction::Create);
actions.insert(RepoAction::Update);
actions.insert(RepoAction::Delete);
}
other => return Err(ParseError::InvalidAction(other.to_string())),
}
}
}
}
if actions.is_empty() {
actions.insert(RepoAction::Create);
actions.insert(RepoAction::Update);
actions.insert(RepoAction::Delete);
}
Ok(Scope::Repo(RepoScope {
collection,
actions,
}))
}
fn parse_rpc(suffix: Option<&str>) -> Result<Self, ParseError> {
let mut lxm = BTreeSet::new();
let mut aud = BTreeSet::new();
match suffix {
Some("*") => {
lxm.insert(RpcLexicon::All);
aud.insert(RpcAudience::All);
}
Some(s) if s.starts_with('?') => {
let params = parse_query_string(&s[1..]);
if let Some(values) = params.get("lxm") {
for value in values {
if value == "*" {
lxm.insert(RpcLexicon::All);
} else {
lxm.insert(RpcLexicon::Nsid(value.to_string()));
}
}
}
if let Some(values) = params.get("aud") {
for value in values {
if value == "*" {
aud.insert(RpcAudience::All);
} else {
aud.insert(RpcAudience::Did(value.to_string()));
}
}
}
}
Some(s) => {
if let Some(pos) = s.find('?') {
let nsid = &s[..pos];
let params = parse_query_string(&s[pos + 1..]);
lxm.insert(RpcLexicon::Nsid(nsid.to_string()));
if let Some(values) = params.get("aud") {
for value in values {
if value == "*" {
aud.insert(RpcAudience::All);
} else {
aud.insert(RpcAudience::Did(value.to_string()));
}
}
}
} else {
lxm.insert(RpcLexicon::Nsid(s.to_string()));
}
}
None => {}
}
if lxm.is_empty() {
lxm.insert(RpcLexicon::All);
}
if aud.is_empty() {
aud.insert(RpcAudience::All);
}
Ok(Scope::Rpc(RpcScope { lxm, aud }))
}
fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> {
if suffix.is_some() {
return Err(ParseError::InvalidResource(
"atproto scope does not accept suffixes".to_string(),
));
}
Ok(Scope::Atproto)
}
fn parse_transition(suffix: Option<&str>) -> Result<Self, ParseError> {
let scope = match suffix {
Some("generic") => TransitionScope::Generic,
Some("email") => TransitionScope::Email,
Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
None => return Err(ParseError::MissingResource),
};
Ok(Scope::Transition(scope))
}
fn parse_include(suffix: Option<&str>) -> Result<Self, ParseError> {
let (nsid, params) = match suffix {
Some(s) => {
if let Some(pos) = s.find('?') {
(&s[..pos], Some(&s[pos + 1..]))
} else {
(s, None)
}
}
None => return Err(ParseError::MissingResource),
};
if nsid.is_empty() {
return Err(ParseError::MissingResource);
}
let aud = if let Some(params) = params {
let parsed_params = parse_query_string(params);
parsed_params
.get("aud")
.and_then(|v| v.first())
.map(|s| url_decode(s))
} else {
None
};
Ok(Scope::Include(IncludeScope {
nsid: nsid.to_string(),
aud,
}))
}
fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> {
if suffix.is_some() {
return Err(ParseError::InvalidResource(
"openid scope does not accept suffixes".to_string(),
));
}
Ok(Scope::OpenId)
}
fn parse_profile(suffix: Option<&str>) -> Result<Self, ParseError> {
if suffix.is_some() {
return Err(ParseError::InvalidResource(
"profile scope does not accept suffixes".to_string(),
));
}
Ok(Scope::Profile)
}
fn parse_email(suffix: Option<&str>) -> Result<Self, ParseError> {
if suffix.is_some() {
return Err(ParseError::InvalidResource(
"email scope does not accept suffixes".to_string(),
));
}
Ok(Scope::Email)
}
pub fn to_string_normalized(&self) -> String {
match self {
Scope::Account(scope) => {
let resource = match scope.resource {
AccountResource::Email => "email",
AccountResource::Repo => "repo",
AccountResource::Status => "status",
};
match scope.action {
AccountAction::Read => format!("account:{}", resource),
AccountAction::Manage => format!("account:{}?action=manage", resource),
}
}
Scope::Identity(scope) => match scope {
IdentityScope::Handle => "identity:handle".to_string(),
IdentityScope::All => "identity:*".to_string(),
},
Scope::Blob(scope) => {
if scope.accept.len() == 1
&& let Some(pattern) = scope.accept.iter().next()
{
match pattern {
MimePattern::All => "blob:*/*".to_string(),
MimePattern::TypeWildcard(t) => format!("blob:{}/*", t),
MimePattern::Exact(mime) => format!("blob:{}", mime),
}
} else {
let mut params = Vec::new();
for pattern in &scope.accept {
match pattern {
MimePattern::All => params.push("accept=*/*".to_string()),
MimePattern::TypeWildcard(t) => params.push(format!("accept={}/*", t)),
MimePattern::Exact(mime) => params.push(format!("accept={}", mime)),
}
}
params.sort();
format!("blob?{}", params.join("&"))
}
}
Scope::Repo(scope) => {
let collection = match &scope.collection {
RepoCollection::All => "*",
RepoCollection::Nsid(nsid) => nsid,
};
if scope.actions.len() == 3 {
format!("repo:{}", collection)
} else {
let mut params = Vec::new();
for action in &scope.actions {
match action {
RepoAction::Create => params.push("action=create"),
RepoAction::Update => params.push("action=update"),
RepoAction::Delete => params.push("action=delete"),
}
}
format!("repo:{}?{}", collection, params.join("&"))
}
}
Scope::Rpc(scope) => {
if scope.lxm.len() == 1
&& scope.lxm.contains(&RpcLexicon::All)
&& scope.aud.len() == 1
&& scope.aud.contains(&RpcAudience::All)
{
"rpc:*".to_string()
} else if scope.lxm.len() == 1
&& scope.aud.len() == 1
&& scope.aud.contains(&RpcAudience::All)
&& let Some(lxm) = scope.lxm.iter().next()
{
match lxm {
RpcLexicon::All => "rpc:*".to_string(),
RpcLexicon::Nsid(nsid) => format!("rpc:{}?aud=*", nsid),
}
} else if scope.lxm.len() == 1 && scope.aud.len() == 1 {
if let (Some(lxm), Some(aud)) =
(scope.lxm.iter().next(), scope.aud.iter().next())
{
match (lxm, aud) {
(RpcLexicon::Nsid(nsid), RpcAudience::Did(did)) => {
format!("rpc:{}?aud={}", nsid, did)
}
(RpcLexicon::All, RpcAudience::Did(did)) => {
format!("rpc:*?aud={}", did)
}
_ => "rpc:*".to_string(),
}
} else {
"rpc:*".to_string()
}
} else {
let mut params = Vec::new();
for lxm in &scope.lxm {
match lxm {
RpcLexicon::All => params.push("lxm=*".to_string()),
RpcLexicon::Nsid(nsid) => params.push(format!("lxm={}", nsid)),
}
}
for aud in &scope.aud {
match aud {
RpcAudience::All => params.push("aud=*".to_string()),
RpcAudience::Did(did) => params.push(format!("aud={}", did)),
}
}
params.sort();
if params.is_empty() {
"rpc:*".to_string()
} else {
format!("rpc?{}", params.join("&"))
}
}
}
Scope::Atproto => "atproto".to_string(),
Scope::Transition(scope) => match scope {
TransitionScope::Generic => "transition:generic".to_string(),
TransitionScope::Email => "transition:email".to_string(),
},
Scope::Include(scope) => {
if let Some(ref aud) = scope.aud {
format!("include:{}?aud={}", scope.nsid, url_encode(aud))
} else {
format!("include:{}", scope.nsid)
}
}
Scope::OpenId => "openid".to_string(),
Scope::Profile => "profile".to_string(),
Scope::Email => "email".to_string(),
}
}
pub fn grants(&self, other: &Scope) -> bool {
match (self, other) {
(Scope::Atproto, Scope::Atproto) => true,
(Scope::Atproto, _) => false,
(_, Scope::Atproto) => false,
(Scope::Transition(a), Scope::Transition(b)) => a == b,
(_, Scope::Transition(_)) => false,
(Scope::Transition(_), _) => false,
(Scope::Include(a), Scope::Include(b)) => a == b,
(_, Scope::Include(_)) => false,
(Scope::Include(_), _) => false,
(Scope::OpenId, Scope::OpenId) => true,
(Scope::OpenId, _) => false,
(_, Scope::OpenId) => false,
(Scope::Profile, Scope::Profile) => true,
(Scope::Profile, _) => false,
(_, Scope::Profile) => false,
(Scope::Email, Scope::Email) => true,
(Scope::Email, _) => false,
(_, Scope::Email) => false,
(Scope::Account(a), Scope::Account(b)) => {
a.resource == b.resource
&& matches!(
(a.action, b.action),
(AccountAction::Manage, _) | (AccountAction::Read, AccountAction::Read)
)
}
(Scope::Identity(a), Scope::Identity(b)) => matches!(
(a, b),
(IdentityScope::All, _) | (IdentityScope::Handle, IdentityScope::Handle)
),
(Scope::Blob(a), Scope::Blob(b)) => {
for b_pattern in &b.accept {
let mut granted = false;
for a_pattern in &a.accept {
if a_pattern.grants(b_pattern) {
granted = true;
break;
}
}
if !granted {
return false;
}
}
true
}
(Scope::Repo(a), Scope::Repo(b)) => {
let collection_match = match (&a.collection, &b.collection) {
(RepoCollection::All, _) => true,
(RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => {
a_nsid == b_nsid
}
_ => false,
};
if !collection_match {
return false;
}
b.actions.is_subset(&a.actions) || a.actions.len() == 3
}
(Scope::Rpc(a), Scope::Rpc(b)) => {
let lxm_match = if a.lxm.contains(&RpcLexicon::All) {
true
} else {
b.lxm.iter().all(|b_lxm| match b_lxm {
RpcLexicon::All => false,
RpcLexicon::Nsid(_) => a.lxm.contains(b_lxm),
})
};
let aud_match = if a.aud.contains(&RpcAudience::All) {
true
} else {
b.aud.iter().all(|b_aud| match b_aud {
RpcAudience::All => false,
RpcAudience::Did(_) => a.aud.contains(b_aud),
})
};
lxm_match && aud_match
}
_ => false,
}
}
}
impl MimePattern {
fn grants(&self, other: &MimePattern) -> bool {
match (self, other) {
(MimePattern::All, _) => true,
(MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => {
a_type == b_type
}
(MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => {
b_mime.starts_with(&format!("{}/", a_type))
}
(MimePattern::Exact(a), MimePattern::Exact(b)) => a == b,
_ => false,
}
}
}
impl FromStr for MimePattern {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == "*/*" {
Ok(MimePattern::All)
} else if let Some(stripped) = s.strip_suffix("/*") {
Ok(MimePattern::TypeWildcard(stripped.to_string()))
} else if s.contains('/') {
Ok(MimePattern::Exact(s.to_string()))
} else {
Err(ParseError::InvalidMimeType(s.to_string()))
}
}
}
impl FromStr for Scope {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl fmt::Display for Scope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_string_normalized())
}
}
fn parse_query_string(query: &str) -> BTreeMap<String, Vec<String>> {
let mut params = BTreeMap::new();
for pair in query.split('&') {
if let Some(pos) = pair.find('=') {
let key = &pair[..pos];
let value = &pair[pos + 1..];
params
.entry(key.to_string())
.or_insert_with(Vec::new)
.push(value.to_string());
}
}
params
}
fn url_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
let hex: String = chars.by_ref().take(2).collect();
if hex.len() == 2
&& let Ok(byte) = u8::from_str_radix(&hex, 16)
{
result.push(byte as char);
continue;
}
result.push('%');
result.push_str(&hex);
} else {
result.push(c);
}
}
result
}
fn url_encode(s: &str) -> String {
let mut result = String::with_capacity(s.len() * 3);
for c in s.chars() {
match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' | ':' => {
result.push(c);
}
_ => {
for byte in c.to_string().as_bytes() {
result.push_str(&format!("%{:02X}", byte));
}
}
}
}
result
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
UnknownPrefix(String),
MissingResource,
InvalidResource(String),
InvalidAction(String),
InvalidMimeType(String),
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix),
ParseError::MissingResource => write!(f, "Missing required resource"),
ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource),
ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action),
ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime),
}
}
}
impl std::error::Error for ParseError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_account_scope_parsing() {
let scope = Scope::parse("account:email").unwrap();
assert_eq!(
scope,
Scope::Account(AccountScope {
resource: AccountResource::Email,
action: AccountAction::Read,
})
);
let scope = Scope::parse("account:repo?action=manage").unwrap();
assert_eq!(
scope,
Scope::Account(AccountScope {
resource: AccountResource::Repo,
action: AccountAction::Manage,
})
);
let scope = Scope::parse("account:status?action=read").unwrap();
assert_eq!(
scope,
Scope::Account(AccountScope {
resource: AccountResource::Status,
action: AccountAction::Read,
})
);
}
#[test]
fn test_identity_scope_parsing() {
let scope = Scope::parse("identity:handle").unwrap();
assert_eq!(scope, Scope::Identity(IdentityScope::Handle));
let scope = Scope::parse("identity:*").unwrap();
assert_eq!(scope, Scope::Identity(IdentityScope::All));
}
#[test]
fn test_blob_scope_parsing() {
let scope = Scope::parse("blob:*/*").unwrap();
let mut accept = BTreeSet::new();
accept.insert(MimePattern::All);
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
let scope = Scope::parse("blob:image/png").unwrap();
let mut accept = BTreeSet::new();
accept.insert(MimePattern::Exact("image/png".to_string()));
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap();
let mut accept = BTreeSet::new();
accept.insert(MimePattern::Exact("image/png".to_string()));
accept.insert(MimePattern::Exact("image/jpeg".to_string()));
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
let scope = Scope::parse("blob:image/*").unwrap();
let mut accept = BTreeSet::new();
accept.insert(MimePattern::TypeWildcard("image".to_string()));
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
}
#[test]
fn test_repo_scope_parsing() {
let scope = Scope::parse("repo:*?action=create").unwrap();
let mut actions = BTreeSet::new();
actions.insert(RepoAction::Create);
assert_eq!(
scope,
Scope::Repo(RepoScope {
collection: RepoCollection::All,
actions,
})
);
let scope = Scope::parse("repo:foo.bar?action=create&action=update").unwrap();
let mut actions = BTreeSet::new();
actions.insert(RepoAction::Create);
actions.insert(RepoAction::Update);
assert_eq!(
scope,
Scope::Repo(RepoScope {
collection: RepoCollection::Nsid("foo.bar".to_string()),
actions,
})
);
let scope = Scope::parse("repo:foo.bar").unwrap();
let mut actions = BTreeSet::new();
actions.insert(RepoAction::Create);
actions.insert(RepoAction::Update);
actions.insert(RepoAction::Delete);
assert_eq!(
scope,
Scope::Repo(RepoScope {
collection: RepoCollection::Nsid("foo.bar".to_string()),
actions,
})
);
}
#[test]
fn test_rpc_scope_parsing() {
let scope = Scope::parse("rpc:*").unwrap();
let mut lxm = BTreeSet::new();
let mut aud = BTreeSet::new();
lxm.insert(RpcLexicon::All);
aud.insert(RpcAudience::All);
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
let scope = Scope::parse("rpc:com.example.service").unwrap();
let mut lxm = BTreeSet::new();
let mut aud = BTreeSet::new();
lxm.insert(RpcLexicon::Nsid("com.example.service".to_string()));
aud.insert(RpcAudience::All);
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
let scope = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap();
let mut lxm = BTreeSet::new();
let mut aud = BTreeSet::new();
lxm.insert(RpcLexicon::Nsid("com.example.service".to_string()));
aud.insert(RpcAudience::Did("did:example:123".to_string()));
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
let scope =
Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:example:123")
.unwrap();
let mut lxm = BTreeSet::new();
let mut aud = BTreeSet::new();
lxm.insert(RpcLexicon::Nsid("com.example.method1".to_string()));
lxm.insert(RpcLexicon::Nsid("com.example.method2".to_string()));
aud.insert(RpcAudience::Did("did:example:123".to_string()));
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
}
#[test]
fn test_scope_normalization() {
let tests = vec![
("account:email", "account:email"),
("account:email?action=read", "account:email"),
("account:email?action=manage", "account:email?action=manage"),
("blob:image/png", "blob:image/png"),
(
"blob?accept=image/jpeg&accept=image/png",
"blob?accept=image/jpeg&accept=image/png",
),
("repo:foo.bar", "repo:foo.bar"),
("repo:foo.bar?action=create", "repo:foo.bar?action=create"),
("rpc:*", "rpc:*"),
("rpc:com.example.service", "rpc:com.example.service?aud=*"),
(
"rpc:com.example.service?aud=did:example:123",
"rpc:com.example.service?aud=did:example:123",
),
];
for (input, expected) in tests {
let scope = Scope::parse(input).unwrap();
assert_eq!(scope.to_string_normalized(), expected);
}
}
#[test]
fn test_account_scope_grants() {
let manage = Scope::parse("account:email?action=manage").unwrap();
let read = Scope::parse("account:email?action=read").unwrap();
let other_read = Scope::parse("account:repo?action=read").unwrap();
assert!(manage.grants(&read));
assert!(manage.grants(&manage));
assert!(!read.grants(&manage));
assert!(read.grants(&read));
assert!(!read.grants(&other_read));
}
#[test]
fn test_identity_scope_grants() {
let all = Scope::parse("identity:*").unwrap();
let handle = Scope::parse("identity:handle").unwrap();
assert!(all.grants(&handle));
assert!(all.grants(&all));
assert!(!handle.grants(&all));
assert!(handle.grants(&handle));
}
#[test]
fn test_blob_scope_grants() {
let all = Scope::parse("blob:*/*").unwrap();
let image_all = Scope::parse("blob:image/*").unwrap();
let image_png = Scope::parse("blob:image/png").unwrap();
let text_plain = Scope::parse("blob:text/plain").unwrap();
assert!(all.grants(&image_all));
assert!(all.grants(&image_png));
assert!(all.grants(&text_plain));
assert!(image_all.grants(&image_png));
assert!(!image_all.grants(&text_plain));
assert!(!image_png.grants(&image_all));
}
#[test]
fn test_repo_scope_grants() {
let all_all = Scope::parse("repo:*").unwrap();
let all_create = Scope::parse("repo:*?action=create").unwrap();
let specific_all = Scope::parse("repo:foo.bar").unwrap();
let specific_create = Scope::parse("repo:foo.bar?action=create").unwrap();
let other_create = Scope::parse("repo:baz.qux?action=create").unwrap();
assert!(all_all.grants(&all_create));
assert!(all_all.grants(&specific_all));
assert!(all_all.grants(&specific_create));
assert!(all_create.grants(&all_create));
assert!(!all_create.grants(&specific_all));
assert!(specific_all.grants(&specific_create));
assert!(!specific_create.grants(&specific_all));
assert!(!specific_create.grants(&other_create));
}
#[test]
fn test_rpc_scope_grants() {
let all = Scope::parse("rpc:*").unwrap();
let specific_lxm = Scope::parse("rpc:com.example.service").unwrap();
let specific_both = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap();
assert!(all.grants(&specific_lxm));
assert!(all.grants(&specific_both));
assert!(specific_lxm.grants(&specific_both));
assert!(!specific_both.grants(&specific_lxm));
assert!(!specific_both.grants(&all));
}
#[test]
fn test_cross_scope_grants() {
let account = Scope::parse("account:email").unwrap();
let identity = Scope::parse("identity:handle").unwrap();
assert!(!account.grants(&identity));
assert!(!identity.grants(&account));
}
#[test]
fn test_parse_errors() {
assert!(matches!(
Scope::parse("unknown:test"),
Err(ParseError::UnknownPrefix(_))
));
assert!(matches!(
Scope::parse("account"),
Err(ParseError::MissingResource)
));
assert!(matches!(
Scope::parse("account:invalid"),
Err(ParseError::InvalidResource(_))
));
assert!(matches!(
Scope::parse("account:email?action=invalid"),
Err(ParseError::InvalidAction(_))
));
}
#[test]
fn test_query_parameter_sorting() {
let scope =
Scope::parse("blob?accept=image/png&accept=application/pdf&accept=image/jpeg").unwrap();
let normalized = scope.to_string_normalized();
assert!(normalized.contains("accept=application/pdf"));
assert!(normalized.contains("accept=image/jpeg"));
assert!(normalized.contains("accept=image/png"));
let pdf_pos = normalized.find("accept=application/pdf").unwrap();
let jpeg_pos = normalized.find("accept=image/jpeg").unwrap();
let png_pos = normalized.find("accept=image/png").unwrap();
assert!(pdf_pos < jpeg_pos);
assert!(jpeg_pos < png_pos);
}
#[test]
fn test_repo_action_wildcard() {
let scope = Scope::parse("repo:foo.bar?action=*").unwrap();
let mut actions = BTreeSet::new();
actions.insert(RepoAction::Create);
actions.insert(RepoAction::Update);
actions.insert(RepoAction::Delete);
assert_eq!(
scope,
Scope::Repo(RepoScope {
collection: RepoCollection::Nsid("foo.bar".to_string()),
actions,
})
);
}
#[test]
fn test_multiple_blob_accepts() {
let scope = Scope::parse("blob?accept=image/*&accept=text/plain").unwrap();
assert!(scope.grants(&Scope::parse("blob:image/png").unwrap()));
assert!(scope.grants(&Scope::parse("blob:text/plain").unwrap()));
assert!(!scope.grants(&Scope::parse("blob:application/json").unwrap()));
}
#[test]
fn test_rpc_default_wildcards() {
let scope = Scope::parse("rpc").unwrap();
let mut lxm = BTreeSet::new();
let mut aud = BTreeSet::new();
lxm.insert(RpcLexicon::All);
aud.insert(RpcAudience::All);
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
}
#[test]
fn test_atproto_scope_parsing() {
let scope = Scope::parse("atproto").unwrap();
assert_eq!(scope, Scope::Atproto);
assert!(Scope::parse("atproto:something").is_err());
assert!(Scope::parse("atproto?param=value").is_err());
}
#[test]
fn test_transition_scope_parsing() {
let scope = Scope::parse("transition:generic").unwrap();
assert_eq!(scope, Scope::Transition(TransitionScope::Generic));
let scope = Scope::parse("transition:email").unwrap();
assert_eq!(scope, Scope::Transition(TransitionScope::Email));
assert!(matches!(
Scope::parse("transition:invalid"),
Err(ParseError::InvalidResource(_))
));
assert!(matches!(
Scope::parse("transition"),
Err(ParseError::MissingResource)
));
assert!(matches!(
Scope::parse("transition:generic?param=value"),
Err(ParseError::InvalidResource(_))
));
}
#[test]
fn test_atproto_scope_normalization() {
let scope = Scope::parse("atproto").unwrap();
assert_eq!(scope.to_string_normalized(), "atproto");
}
#[test]
fn test_transition_scope_normalization() {
let tests = vec![
("transition:generic", "transition:generic"),
("transition:email", "transition:email"),
];
for (input, expected) in tests {
let scope = Scope::parse(input).unwrap();
assert_eq!(scope.to_string_normalized(), expected);
}
}
#[test]
fn test_atproto_scope_grants() {
let atproto = Scope::parse("atproto").unwrap();
let account = Scope::parse("account:email").unwrap();
let identity = Scope::parse("identity:handle").unwrap();
let blob = Scope::parse("blob:image/png").unwrap();
let repo = Scope::parse("repo:foo.bar").unwrap();
let rpc = Scope::parse("rpc:com.example.service").unwrap();
let transition_generic = Scope::parse("transition:generic").unwrap();
let transition_email = Scope::parse("transition:email").unwrap();
assert!(atproto.grants(&atproto));
assert!(!atproto.grants(&account));
assert!(!atproto.grants(&identity));
assert!(!atproto.grants(&blob));
assert!(!atproto.grants(&repo));
assert!(!atproto.grants(&rpc));
assert!(!atproto.grants(&transition_generic));
assert!(!atproto.grants(&transition_email));
assert!(!account.grants(&atproto));
assert!(!identity.grants(&atproto));
assert!(!blob.grants(&atproto));
assert!(!repo.grants(&atproto));
assert!(!rpc.grants(&atproto));
assert!(!transition_generic.grants(&atproto));
assert!(!transition_email.grants(&atproto));
}
#[test]
fn test_transition_scope_grants() {
let transition_generic = Scope::parse("transition:generic").unwrap();
let transition_email = Scope::parse("transition:email").unwrap();
let account = Scope::parse("account:email").unwrap();
assert!(transition_generic.grants(&transition_generic));
assert!(transition_email.grants(&transition_email));
assert!(!transition_generic.grants(&transition_email));
assert!(!transition_email.grants(&transition_generic));
assert!(!transition_generic.grants(&account));
assert!(!transition_email.grants(&account));
assert!(!account.grants(&transition_generic));
assert!(!account.grants(&transition_email));
}
#[test]
fn test_parse_multiple() {
let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
assert_eq!(scopes.len(), 2);
assert_eq!(scopes[0], Scope::Atproto);
assert_eq!(
scopes[1],
Scope::Repo(RepoScope {
collection: RepoCollection::All,
actions: {
let mut actions = BTreeSet::new();
actions.insert(RepoAction::Create);
actions.insert(RepoAction::Update);
actions.insert(RepoAction::Delete);
actions
}
})
);
let scopes = Scope::parse_multiple("account:email identity:handle blob:image/png").unwrap();
assert_eq!(scopes.len(), 3);
assert!(matches!(scopes[0], Scope::Account(_)));
assert!(matches!(scopes[1], Scope::Identity(_)));
assert!(matches!(scopes[2], Scope::Blob(_)));
let scopes = Scope::parse_multiple(
"account:email?action=manage repo:foo.bar?action=create transition:email",
)
.unwrap();
assert_eq!(scopes.len(), 3);
let scopes = Scope::parse_multiple("").unwrap();
assert_eq!(scopes.len(), 0);
let scopes = Scope::parse_multiple(" ").unwrap();
assert_eq!(scopes.len(), 0);
let scopes = Scope::parse_multiple(" atproto repo:* ").unwrap();
assert_eq!(scopes.len(), 2);
let scopes = Scope::parse_multiple("atproto").unwrap();
assert_eq!(scopes.len(), 1);
assert_eq!(scopes[0], Scope::Atproto);
assert!(Scope::parse_multiple("atproto invalid:scope").is_err());
assert!(Scope::parse_multiple("account:invalid repo:*").is_err());
}
#[test]
fn test_parse_multiple_reduced() {
let scopes = Scope::parse_multiple_reduced("atproto repo:foo.bar repo:*").unwrap();
assert_eq!(scopes.len(), 2);
assert!(scopes.contains(&Scope::Atproto));
assert!(scopes.contains(&Scope::Repo(RepoScope {
collection: RepoCollection::All,
actions: {
let mut actions = BTreeSet::new();
actions.insert(RepoAction::Create);
actions.insert(RepoAction::Update);
actions.insert(RepoAction::Delete);
actions
}
})));
let scopes = Scope::parse_multiple_reduced("atproto repo:* repo:foo.bar").unwrap();
assert_eq!(scopes.len(), 2);
assert!(scopes.contains(&Scope::Atproto));
assert!(scopes.contains(&Scope::Repo(RepoScope {
collection: RepoCollection::All,
actions: {
let mut actions = BTreeSet::new();
actions.insert(RepoAction::Create);
actions.insert(RepoAction::Update);
actions.insert(RepoAction::Delete);
actions
}
})));
let scopes =
Scope::parse_multiple_reduced("account:email account:email?action=manage").unwrap();
assert_eq!(scopes.len(), 1);
assert_eq!(
scopes[0],
Scope::Account(AccountScope {
resource: AccountResource::Email,
action: AccountAction::Manage,
})
);
let scopes = Scope::parse_multiple_reduced("identity:handle identity:*").unwrap();
assert_eq!(scopes.len(), 1);
assert_eq!(scopes[0], Scope::Identity(IdentityScope::All));
let scopes = Scope::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*").unwrap();
assert_eq!(scopes.len(), 1);
let mut accept = BTreeSet::new();
accept.insert(MimePattern::All);
assert_eq!(scopes[0], Scope::Blob(BlobScope { accept }));
let scopes =
Scope::parse_multiple_reduced("account:email identity:handle blob:image/png").unwrap();
assert_eq!(scopes.len(), 3);
let scopes =
Scope::parse_multiple_reduced("repo:foo.bar?action=create repo:foo.bar").unwrap();
assert_eq!(scopes.len(), 1);
assert_eq!(
scopes[0],
Scope::Repo(RepoScope {
collection: RepoCollection::Nsid("foo.bar".to_string()),
actions: {
let mut actions = BTreeSet::new();
actions.insert(RepoAction::Create);
actions.insert(RepoAction::Update);
actions.insert(RepoAction::Delete);
actions
}
})
);
let scopes = Scope::parse_multiple_reduced(
"rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*",
)
.unwrap();
assert_eq!(scopes.len(), 1);
assert_eq!(
scopes[0],
Scope::Rpc(RpcScope {
lxm: {
let mut lxm = BTreeSet::new();
lxm.insert(RpcLexicon::All);
lxm
},
aud: {
let mut aud = BTreeSet::new();
aud.insert(RpcAudience::All);
aud
}
})
);
let scopes = Scope::parse_multiple_reduced("atproto atproto atproto").unwrap();
assert_eq!(scopes.len(), 1);
assert_eq!(scopes[0], Scope::Atproto);
let scopes = Scope::parse_multiple_reduced("transition:generic transition:email").unwrap();
assert_eq!(scopes.len(), 2);
assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic)));
assert!(scopes.contains(&Scope::Transition(TransitionScope::Email)));
let scopes = Scope::parse_multiple_reduced("").unwrap();
assert_eq!(scopes.len(), 0);
let scopes = Scope::parse_multiple_reduced(
"account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle"
).unwrap();
assert_eq!(scopes.len(), 3);
assert!(scopes.contains(&Scope::Account(AccountScope {
resource: AccountResource::Email,
action: AccountAction::Manage,
})));
assert!(scopes.contains(&Scope::Account(AccountScope {
resource: AccountResource::Repo,
action: AccountAction::Read,
})));
assert!(scopes.contains(&Scope::Identity(IdentityScope::All)));
let scopes = Scope::parse_multiple_reduced("atproto account:email repo:*").unwrap();
assert_eq!(scopes.len(), 3);
assert!(scopes.contains(&Scope::Atproto));
assert!(scopes.contains(&Scope::Account(AccountScope {
resource: AccountResource::Email,
action: AccountAction::Read,
})));
assert!(scopes.contains(&Scope::Repo(RepoScope {
collection: RepoCollection::All,
actions: {
let mut actions = BTreeSet::new();
actions.insert(RepoAction::Create);
actions.insert(RepoAction::Update);
actions.insert(RepoAction::Delete);
actions
}
})));
}
#[test]
fn test_openid_connect_scope_parsing() {
let scope = Scope::parse("openid").unwrap();
assert_eq!(scope, Scope::OpenId);
let scope = Scope::parse("profile").unwrap();
assert_eq!(scope, Scope::Profile);
let scope = Scope::parse("email").unwrap();
assert_eq!(scope, Scope::Email);
assert!(Scope::parse("openid:something").is_err());
assert!(Scope::parse("profile:something").is_err());
assert!(Scope::parse("email:something").is_err());
assert!(Scope::parse("openid?param=value").is_err());
assert!(Scope::parse("profile?param=value").is_err());
assert!(Scope::parse("email?param=value").is_err());
}
#[test]
fn test_openid_connect_scope_normalization() {
let scope = Scope::parse("openid").unwrap();
assert_eq!(scope.to_string_normalized(), "openid");
let scope = Scope::parse("profile").unwrap();
assert_eq!(scope.to_string_normalized(), "profile");
let scope = Scope::parse("email").unwrap();
assert_eq!(scope.to_string_normalized(), "email");
}
#[test]
fn test_openid_connect_scope_grants() {
let openid = Scope::parse("openid").unwrap();
let profile = Scope::parse("profile").unwrap();
let email = Scope::parse("email").unwrap();
let account = Scope::parse("account:email").unwrap();
assert!(openid.grants(&openid));
assert!(!openid.grants(&profile));
assert!(!openid.grants(&email));
assert!(!openid.grants(&account));
assert!(profile.grants(&profile));
assert!(!profile.grants(&openid));
assert!(!profile.grants(&email));
assert!(!profile.grants(&account));
assert!(email.grants(&email));
assert!(!email.grants(&openid));
assert!(!email.grants(&profile));
assert!(!email.grants(&account));
assert!(!account.grants(&openid));
assert!(!account.grants(&profile));
assert!(!account.grants(&email));
}
#[test]
fn test_parse_multiple_with_openid_connect() {
let scopes = Scope::parse_multiple("openid profile email atproto").unwrap();
assert_eq!(scopes.len(), 4);
assert_eq!(scopes[0], Scope::OpenId);
assert_eq!(scopes[1], Scope::Profile);
assert_eq!(scopes[2], Scope::Email);
assert_eq!(scopes[3], Scope::Atproto);
let scopes = Scope::parse_multiple("openid account:email profile repo:*").unwrap();
assert_eq!(scopes.len(), 4);
assert!(scopes.contains(&Scope::OpenId));
assert!(scopes.contains(&Scope::Profile));
}
#[test]
fn test_parse_multiple_reduced_with_openid_connect() {
let scopes = Scope::parse_multiple_reduced("openid profile email openid").unwrap();
assert_eq!(scopes.len(), 3);
assert!(scopes.contains(&Scope::OpenId));
assert!(scopes.contains(&Scope::Profile));
assert!(scopes.contains(&Scope::Email));
let scopes = Scope::parse_multiple_reduced(
"openid account:email account:email?action=manage profile",
)
.unwrap();
assert_eq!(scopes.len(), 3);
assert!(scopes.contains(&Scope::OpenId));
assert!(scopes.contains(&Scope::Profile));
assert!(scopes.contains(&Scope::Account(AccountScope {
resource: AccountResource::Email,
action: AccountAction::Manage,
})));
}
#[test]
fn test_serialize_multiple() {
let scopes: Vec<Scope> = vec![];
assert_eq!(Scope::serialize_multiple(&scopes), "");
let scopes = vec![Scope::Atproto];
assert_eq!(Scope::serialize_multiple(&scopes), "atproto");
let scopes = vec![
Scope::parse("repo:*").unwrap(),
Scope::Atproto,
Scope::parse("account:email").unwrap(),
];
assert_eq!(
Scope::serialize_multiple(&scopes),
"account:email atproto repo:*"
);
let scopes = vec![
Scope::parse("identity:handle").unwrap(),
Scope::parse("blob:image/png").unwrap(),
Scope::parse("account:repo?action=manage").unwrap(),
];
assert_eq!(
Scope::serialize_multiple(&scopes),
"account:repo?action=manage blob:image/png identity:handle"
);
let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto];
assert_eq!(
Scope::serialize_multiple(&scopes),
"atproto email openid profile"
);
let scopes = vec![
Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(),
Scope::parse("repo:foo.bar?action=create&action=update").unwrap(),
Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(),
];
let result = Scope::serialize_multiple(&scopes);
assert!(result.starts_with("blob:"));
assert!(result.contains(" repo:"));
assert!(result.contains("rpc:com.example.service?aud=did:example:123"));
let scopes = vec![
Scope::Transition(TransitionScope::Email),
Scope::Transition(TransitionScope::Generic),
Scope::Atproto,
];
assert_eq!(
Scope::serialize_multiple(&scopes),
"atproto transition:email transition:generic"
);
let scopes = vec![
Scope::Atproto,
Scope::Atproto,
Scope::parse("account:email").unwrap(),
];
assert_eq!(
Scope::serialize_multiple(&scopes),
"account:email atproto atproto"
);
let scopes = vec![Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap()];
assert_eq!(
Scope::serialize_multiple(&scopes),
"blob?accept=image/jpeg&accept=image/png"
);
}
#[test]
fn test_serialize_multiple_roundtrip() {
let original = "account:email atproto blob:image/png identity:handle repo:*";
let scopes = Scope::parse_multiple(original).unwrap();
let serialized = Scope::serialize_multiple(&scopes);
assert_eq!(serialized, original);
let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*";
let scopes = Scope::parse_multiple(original).unwrap();
let serialized = Scope::serialize_multiple(&scopes);
let reparsed = Scope::parse_multiple(&serialized).unwrap();
assert_eq!(scopes, reparsed);
let original = "email openid profile";
let scopes = Scope::parse_multiple(original).unwrap();
let serialized = Scope::serialize_multiple(&scopes);
assert_eq!(serialized, original);
}
#[test]
fn test_remove_scope() {
let scopes = vec![
Scope::parse("repo:*").unwrap(),
Scope::Atproto,
Scope::parse("account:email").unwrap(),
];
let to_remove = Scope::Atproto;
let result = Scope::remove_scope(&scopes, &to_remove);
assert_eq!(result.len(), 2);
assert!(!result.contains(&to_remove));
assert!(result.contains(&Scope::parse("repo:*").unwrap()));
assert!(result.contains(&Scope::parse("account:email").unwrap()));
let scopes = vec![
Scope::parse("repo:*").unwrap(),
Scope::parse("account:email").unwrap(),
];
let to_remove = Scope::parse("identity:handle").unwrap();
let result = Scope::remove_scope(&scopes, &to_remove);
assert_eq!(result.len(), 2);
assert_eq!(result, scopes);
let scopes: Vec<Scope> = vec![];
let to_remove = Scope::Atproto;
let result = Scope::remove_scope(&scopes, &to_remove);
assert_eq!(result.len(), 0);
let scopes = vec![
Scope::Atproto,
Scope::parse("account:email").unwrap(),
Scope::Atproto,
Scope::parse("repo:*").unwrap(),
Scope::Atproto,
];
let to_remove = Scope::Atproto;
let result = Scope::remove_scope(&scopes, &to_remove);
assert_eq!(result.len(), 2);
assert!(!result.contains(&to_remove));
assert!(result.contains(&Scope::parse("account:email").unwrap()));
assert!(result.contains(&Scope::parse("repo:*").unwrap()));
let scopes = vec![
Scope::parse("account:email?action=manage").unwrap(),
Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(),
Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(),
];
let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); let result = Scope::remove_scope(&scopes, &to_remove);
assert_eq!(result.len(), 2);
assert!(!result.contains(&to_remove));
let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto];
let to_remove = Scope::Profile;
let result = Scope::remove_scope(&scopes, &to_remove);
assert_eq!(result.len(), 3);
assert!(!result.contains(&to_remove));
assert!(result.contains(&Scope::OpenId));
assert!(result.contains(&Scope::Email));
assert!(result.contains(&Scope::Atproto));
let scopes = vec![
Scope::Transition(TransitionScope::Generic),
Scope::Transition(TransitionScope::Email),
Scope::Atproto,
];
let to_remove = Scope::Transition(TransitionScope::Email);
let result = Scope::remove_scope(&scopes, &to_remove);
assert_eq!(result.len(), 2);
assert!(!result.contains(&to_remove));
assert!(result.contains(&Scope::Transition(TransitionScope::Generic)));
assert!(result.contains(&Scope::Atproto));
let scopes = vec![
Scope::parse("account:email").unwrap(),
Scope::parse("account:email?action=manage").unwrap(),
Scope::parse("account:repo").unwrap(),
];
let to_remove = Scope::parse("account:email").unwrap();
let result = Scope::remove_scope(&scopes, &to_remove);
assert_eq!(result.len(), 2);
assert!(!result.contains(&Scope::parse("account:email").unwrap()));
assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap()));
assert!(result.contains(&Scope::parse("account:repo").unwrap()));
}
#[test]
fn test_repo_nsid_with_wildcard_suffix() {
let scope = Scope::parse("repo:app.bsky.feed.*").unwrap();
assert_eq!(
scope,
Scope::Repo(RepoScope {
collection: RepoCollection::Nsid("app.bsky.feed.*".to_string()),
actions: {
let mut actions = BTreeSet::new();
actions.insert(RepoAction::Create);
actions.insert(RepoAction::Update);
actions.insert(RepoAction::Delete);
actions
}
})
);
assert_eq!(scope.to_string_normalized(), "repo:app.bsky.feed.*");
let specific_feed = Scope::parse("repo:app.bsky.feed.post").unwrap();
assert!(!scope.grants(&specific_feed));
let repo_all = Scope::parse("repo:*").unwrap();
assert!(repo_all.grants(&scope));
assert!(scope.grants(&scope));
let scope_with_create = Scope::parse("repo:app.bsky.feed.*?action=create").unwrap();
assert_eq!(
scope_with_create,
Scope::Repo(RepoScope {
collection: RepoCollection::Nsid("app.bsky.feed.*".to_string()),
actions: {
let mut actions = BTreeSet::new();
actions.insert(RepoAction::Create);
actions
}
})
);
assert!(scope.grants(&scope_with_create));
assert!(!scope_with_create.grants(&scope));
let scopes =
Scope::parse_multiple("repo:app.bsky.feed.* repo:app.bsky.graph.* repo:*").unwrap();
assert_eq!(scopes.len(), 3);
let reduced =
Scope::parse_multiple_reduced("repo:app.bsky.feed.* repo:app.bsky.graph.* repo:*")
.unwrap();
assert_eq!(reduced.len(), 1);
assert_eq!(reduced[0], repo_all);
}
#[test]
fn test_include_scope_parsing() {
let scope = Scope::parse("include:app.example.authFull").unwrap();
assert_eq!(
scope,
Scope::Include(IncludeScope {
nsid: "app.example.authFull".to_string(),
aud: None,
})
);
let scope =
Scope::parse("include:app.example.authFull?aud=did:web:api.example.com").unwrap();
assert_eq!(
scope,
Scope::Include(IncludeScope {
nsid: "app.example.authFull".to_string(),
aud: Some("did:web:api.example.com".to_string()),
})
);
let scope =
Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat")
.unwrap();
assert_eq!(
scope,
Scope::Include(IncludeScope {
nsid: "app.example.authFull".to_string(),
aud: Some("did:web:api.example.com#svc_chat".to_string()),
})
);
assert!(matches!(
Scope::parse("include"),
Err(ParseError::MissingResource)
));
assert!(matches!(
Scope::parse("include:?aud=did:example:123"),
Err(ParseError::MissingResource)
));
}
#[test]
fn test_include_scope_normalization() {
let scope = Scope::parse("include:com.example.authBasic").unwrap();
assert_eq!(
scope.to_string_normalized(),
"include:com.example.authBasic"
);
let scope = Scope::parse("include:com.example.authBasic?aud=did:plc:xyz123").unwrap();
assert_eq!(
scope.to_string_normalized(),
"include:com.example.authBasic?aud=did:plc:xyz123"
);
let scope =
Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat")
.unwrap();
let normalized = scope.to_string_normalized();
assert_eq!(
normalized,
"include:app.example.authFull?aud=did:web:api.example.com%23svc_chat"
);
}
#[test]
fn test_include_scope_grants() {
let include1 = Scope::parse("include:app.example.authFull").unwrap();
let include2 = Scope::parse("include:app.example.authBasic").unwrap();
let include1_with_aud =
Scope::parse("include:app.example.authFull?aud=did:plc:xyz").unwrap();
let account = Scope::parse("account:email").unwrap();
assert!(include1.grants(&include1));
assert!(!include1.grants(&include2));
assert!(!include1.grants(&include1_with_aud)); assert!(include1_with_aud.grants(&include1_with_aud));
assert!(!include1.grants(&account));
assert!(!account.grants(&include1));
let atproto = Scope::parse("atproto").unwrap();
let transition = Scope::parse("transition:generic").unwrap();
assert!(!include1.grants(&atproto));
assert!(!include1.grants(&transition));
assert!(!atproto.grants(&include1));
assert!(!transition.grants(&include1));
}
#[test]
fn test_parse_multiple_with_include() {
let scopes = Scope::parse_multiple("atproto include:app.example.auth repo:*").unwrap();
assert_eq!(scopes.len(), 3);
assert_eq!(scopes[0], Scope::Atproto);
assert!(matches!(scopes[1], Scope::Include(_)));
assert!(matches!(scopes[2], Scope::Repo(_)));
let scopes = Scope::parse_multiple(
"include:app.example.auth?aud=did:web:api.example.com%23svc account:email",
)
.unwrap();
assert_eq!(scopes.len(), 2);
if let Scope::Include(inc) = &scopes[0] {
assert_eq!(inc.nsid, "app.example.auth");
assert_eq!(inc.aud, Some("did:web:api.example.com#svc".to_string()));
} else {
panic!("Expected Include scope");
}
}
#[test]
fn test_parse_multiple_reduced_with_include() {
let scopes = Scope::parse_multiple_reduced(
"include:app.example.auth include:app.example.other include:app.example.auth",
)
.unwrap();
assert_eq!(scopes.len(), 2); assert!(scopes.contains(&Scope::Include(IncludeScope {
nsid: "app.example.auth".to_string(),
aud: None,
})));
assert!(scopes.contains(&Scope::Include(IncludeScope {
nsid: "app.example.other".to_string(),
aud: None,
})));
let scopes = Scope::parse_multiple_reduced(
"include:app.example.auth include:app.example.auth?aud=did:plc:xyz",
)
.unwrap();
assert_eq!(scopes.len(), 2);
}
#[test]
fn test_serialize_multiple_with_include() {
let scopes = vec![
Scope::parse("repo:*").unwrap(),
Scope::parse("include:app.example.authFull").unwrap(),
Scope::Atproto,
];
let result = Scope::serialize_multiple(&scopes);
assert_eq!(result, "atproto include:app.example.authFull repo:*");
let scopes = vec![Scope::Include(IncludeScope {
nsid: "app.example.auth".to_string(),
aud: Some("did:web:api.example.com#svc".to_string()),
})];
let result = Scope::serialize_multiple(&scopes);
assert_eq!(
result,
"include:app.example.auth?aud=did:web:api.example.com%23svc"
);
}
#[test]
fn test_remove_scope_with_include() {
let scopes = vec![
Scope::Atproto,
Scope::parse("include:app.example.auth").unwrap(),
Scope::parse("account:email").unwrap(),
];
let to_remove = Scope::parse("include:app.example.auth").unwrap();
let result = Scope::remove_scope(&scopes, &to_remove);
assert_eq!(result.len(), 2);
assert!(!result.contains(&to_remove));
assert!(result.contains(&Scope::Atproto));
}
#[test]
fn test_include_scope_roundtrip() {
let original =
"include:com.example.authBasicFeatures?aud=did:web:api.example.com%23svc_appview";
let scope = Scope::parse(original).unwrap();
let serialized = scope.to_string_normalized();
let reparsed = Scope::parse(&serialized).unwrap();
assert_eq!(scope, reparsed);
}
}