use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedScope {
Atproto,
TransitionGeneric,
TransitionChat,
TransitionEmail,
Repo(RepoScope),
Blob(BlobScope),
Rpc(RpcScope),
Account(AccountScope),
Identity(IdentityScope),
Include(IncludeScope),
Unknown(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncludeScope {
pub nsid: String,
pub aud: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoScope {
pub collection: Option<String>,
pub actions: HashSet<RepoAction>,
}
impl RepoScope {
pub fn to_scope_string(&self) -> String {
let mut actions: Vec<RepoAction> = self.actions.iter().copied().collect();
actions.sort();
let rendered: Vec<&str> = actions.iter().map(RepoAction::as_str).collect();
format!(
"repo:{}?action={}",
self.collection.as_deref().unwrap_or("*"),
rendered.join("&action=")
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RepoAction {
Create,
Update,
Delete,
}
impl RepoAction {
pub const ALL: [RepoAction; 3] = [Self::Create, Self::Update, Self::Delete];
pub fn parse_str(s: &str) -> Option<Self> {
match s {
"create" => Some(Self::Create),
"update" => Some(Self::Update),
"delete" => Some(Self::Delete),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Create => "create",
Self::Update => "update",
Self::Delete => "delete",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlobScope {
pub accept: HashSet<String>,
}
impl BlobScope {
pub fn matches_mime(&self, mime: &str) -> bool {
if self.accept.is_empty() || self.accept.contains("*/*") {
return true;
}
self.accept.iter().any(|pattern| {
pattern == mime
|| pattern.strip_suffix("/*").is_some_and(|prefix| {
mime.starts_with(prefix) && mime.chars().nth(prefix.len()) == Some('/')
})
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RpcScope {
pub lxm: String,
pub aud: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountScope {
pub attr: AccountAttr,
pub action: AccountAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AccountAttr {
Email,
Handle,
Repo,
Status,
Wildcard,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdentityScope {
pub attr: IdentityAttr,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IdentityAttr {
Handle,
Wildcard,
}
impl AccountAttr {
pub fn parse_str(s: &str) -> Option<Self> {
match s {
"email" => Some(Self::Email),
"handle" => Some(Self::Handle),
"repo" => Some(Self::Repo),
"status" => Some(Self::Status),
"*" => Some(Self::Wildcard),
_ => None,
}
}
}
impl IdentityAttr {
pub fn parse_str(s: &str) -> Option<Self> {
match s {
"handle" => Some(Self::Handle),
"*" => Some(Self::Wildcard),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AccountAction {
Read,
Manage,
}
impl AccountAction {
pub fn parse_str(s: &str) -> Option<Self> {
match s {
"read" => Some(Self::Read),
"manage" => Some(Self::Manage),
_ => None,
}
}
}
fn parse_query_params(query: &str) -> HashMap<String, Vec<String>> {
query
.split('&')
.filter_map(|part| part.split_once('='))
.fold(HashMap::new(), |mut acc, (key, value)| {
let decoded = urlencoding::decode(value)
.map(|s| s.into_owned())
.unwrap_or_else(|_| value.to_string());
acc.entry(key.to_string()).or_default().push(decoded);
acc
})
}
fn parse_repo_actions(params: &HashMap<String, Vec<String>>) -> Option<HashSet<RepoAction>> {
match params.get("action") {
None => Some(RepoAction::ALL.into_iter().collect()),
Some(values) if values.is_empty() => None,
Some(values) => values.iter().map(|s| RepoAction::parse_str(s)).collect(),
}
}
pub fn parse_scope(scope: &str) -> ParsedScope {
match scope {
"atproto" => return ParsedScope::Atproto,
"transition:generic" => return ParsedScope::TransitionGeneric,
"transition:chat.bsky" => return ParsedScope::TransitionChat,
"transition:email" => return ParsedScope::TransitionEmail,
_ => {}
}
let (base, query) = scope.split_once('?').unwrap_or((scope, ""));
let params = parse_query_params(query);
if let Some(rest) = base.strip_prefix("repo:") {
let collection = if rest == "*" || rest.is_empty() {
None
} else {
Some(rest.to_string())
};
let Some(actions) = parse_repo_actions(¶ms) else {
return ParsedScope::Unknown(scope.to_string());
};
return ParsedScope::Repo(RepoScope {
collection,
actions,
});
}
if base == "repo" {
let Some(actions) = parse_repo_actions(¶ms) else {
return ParsedScope::Unknown(scope.to_string());
};
return ParsedScope::Repo(RepoScope {
collection: None,
actions,
});
}
if base.starts_with("blob") {
let positional = base.strip_prefix("blob:").unwrap_or("");
let accept: HashSet<String> = std::iter::once(positional)
.filter(|s| !s.is_empty())
.map(String::from)
.chain(
params
.get("accept")
.into_iter()
.flatten()
.map(String::clone),
)
.collect();
return ParsedScope::Blob(BlobScope { accept });
}
if base.starts_with("rpc") {
let lxm_positional = base.strip_prefix("rpc:").map(|s| s.to_string());
let lxm = lxm_positional.or_else(|| params.get("lxm").and_then(|v| v.first().cloned()));
let aud = params.get("aud").and_then(|v| v.first().cloned());
let Some(lxm) = lxm else {
return ParsedScope::Unknown(scope.to_string());
};
if lxm == "*" && aud.as_deref() == Some("*") {
return ParsedScope::Unknown(scope.to_string());
}
return ParsedScope::Rpc(RpcScope { lxm, aud });
}
if let Some(attr_str) = base.strip_prefix("account:")
&& let Some(attr) = AccountAttr::parse_str(attr_str)
{
let action = params
.get("action")
.and_then(|v| v.first())
.and_then(|s| AccountAction::parse_str(s))
.unwrap_or(AccountAction::Read);
return ParsedScope::Account(AccountScope { attr, action });
}
if let Some(attr_str) = base.strip_prefix("identity:")
&& let Some(attr) = IdentityAttr::parse_str(attr_str)
{
return ParsedScope::Identity(IdentityScope { attr });
}
if let Some(nsid) = base.strip_prefix("include:") {
let aud = params.get("aud").and_then(|v| v.first().cloned());
return ParsedScope::Include(IncludeScope {
nsid: nsid.to_string(),
aud,
});
}
ParsedScope::Unknown(scope.to_string())
}
pub fn parse_scope_string(scope_str: &str) -> Vec<ParsedScope> {
scope_str.split_whitespace().map(parse_scope).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_atproto() {
assert_eq!(parse_scope("atproto"), ParsedScope::Atproto);
}
#[test]
fn test_parse_transition_scopes() {
assert_eq!(
parse_scope("transition:generic"),
ParsedScope::TransitionGeneric
);
assert_eq!(
parse_scope("transition:chat.bsky"),
ParsedScope::TransitionChat
);
assert_eq!(
parse_scope("transition:email"),
ParsedScope::TransitionEmail
);
}
#[test]
fn test_parse_repo_wildcard() {
let scope = parse_scope("repo:*?action=create");
match scope {
ParsedScope::Repo(r) => {
assert!(r.collection.is_none());
assert!(r.actions.contains(&RepoAction::Create));
assert!(!r.actions.contains(&RepoAction::Update));
}
_ => panic!("Expected Repo scope"),
}
}
#[test]
fn test_parse_repo_collection() {
let scope = parse_scope("repo:app.bsky.feed.post?action=create&action=delete");
match scope {
ParsedScope::Repo(r) => {
assert_eq!(r.collection, Some("app.bsky.feed.post".to_string()));
assert!(r.actions.contains(&RepoAction::Create));
assert!(r.actions.contains(&RepoAction::Delete));
assert!(!r.actions.contains(&RepoAction::Update));
}
_ => panic!("Expected Repo scope"),
}
}
#[test]
fn test_parse_repo_no_actions_means_all() {
let scope = parse_scope("repo:app.bsky.feed.post");
match scope {
ParsedScope::Repo(r) => {
assert!(r.actions.contains(&RepoAction::Create));
assert!(r.actions.contains(&RepoAction::Update));
assert!(r.actions.contains(&RepoAction::Delete));
}
_ => panic!("Expected Repo scope"),
}
}
#[test]
fn test_parse_repo_unrecognized_action_is_not_a_repo_scope() {
assert!(matches!(
parse_scope("repo:app.bsky.feed.post?action=read"),
ParsedScope::Unknown(_)
));
assert!(matches!(
parse_scope("repo:app.bsky.feed.post?action="),
ParsedScope::Unknown(_)
));
assert!(matches!(
parse_scope("repo?action=read"),
ParsedScope::Unknown(_)
));
}
#[test]
fn test_parse_blob_wildcard() {
let scope = parse_scope("blob:*/*");
match scope {
ParsedScope::Blob(b) => {
assert!(b.accept.contains("*/*"));
assert!(b.matches_mime("image/png"));
assert!(b.matches_mime("video/mp4"));
}
_ => panic!("Expected Blob scope"),
}
}
#[test]
fn test_parse_blob_specific() {
let scope = parse_scope("blob?accept=image/*&accept=video/*");
match scope {
ParsedScope::Blob(b) => {
assert!(b.matches_mime("image/png"));
assert!(b.matches_mime("image/jpeg"));
assert!(b.matches_mime("video/mp4"));
assert!(!b.matches_mime("text/plain"));
}
_ => panic!("Expected Blob scope"),
}
}
#[test]
fn test_parse_rpc() {
let scope = parse_scope("rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app");
match scope {
ParsedScope::Rpc(r) => {
assert_eq!(r.lxm, "app.bsky.feed.getTimeline");
assert_eq!(r.aud, Some("did:web:api.bsky.app".to_string()));
}
_ => panic!("Expected Rpc scope"),
}
}
#[test]
fn test_parse_account() {
let scope = parse_scope("account:email?action=read");
match scope {
ParsedScope::Account(a) => {
assert_eq!(a.attr, AccountAttr::Email);
assert_eq!(a.action, AccountAction::Read);
}
_ => panic!("Expected Account scope"),
}
let scope2 = parse_scope("account:repo?action=manage");
match scope2 {
ParsedScope::Account(a) => {
assert_eq!(a.attr, AccountAttr::Repo);
assert_eq!(a.action, AccountAction::Manage);
}
_ => panic!("Expected Account scope"),
}
}
#[test]
fn test_parse_scope_string() {
let scopes = parse_scope_string("atproto repo:*?action=create blob:*/*");
assert_eq!(scopes.len(), 3);
assert_eq!(scopes[0], ParsedScope::Atproto);
match &scopes[1] {
ParsedScope::Repo(_) => {}
_ => panic!("Expected Repo"),
}
match &scopes[2] {
ParsedScope::Blob(_) => {}
_ => panic!("Expected Blob"),
}
}
#[test]
fn test_parse_include() {
let scope = parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app");
match scope {
ParsedScope::Include(i) => {
assert_eq!(i.nsid, "app.bsky.authFullApp");
assert_eq!(i.aud, Some("did:web:api.bsky.app".to_string()));
}
_ => panic!("Expected Include scope"),
}
let scope2 = parse_scope("include:com.example.authBasicFeatures");
match scope2 {
ParsedScope::Include(i) => {
assert_eq!(i.nsid, "com.example.authBasicFeatures");
assert_eq!(i.aud, None);
}
_ => panic!("Expected Include scope"),
}
}
#[test]
fn test_parse_identity() {
let scope = parse_scope("identity:handle");
match scope {
ParsedScope::Identity(i) => {
assert_eq!(i.attr, IdentityAttr::Handle);
}
_ => panic!("Expected Identity scope"),
}
let scope2 = parse_scope("identity:*");
match scope2 {
ParsedScope::Identity(i) => {
assert_eq!(i.attr, IdentityAttr::Wildcard);
}
_ => panic!("Expected Identity scope"),
}
}
#[test]
fn test_parse_account_status() {
let scope = parse_scope("account:status?action=read");
match scope {
ParsedScope::Account(a) => {
assert_eq!(a.attr, AccountAttr::Status);
assert_eq!(a.action, AccountAction::Read);
}
_ => panic!("Expected Account scope"),
}
}
#[test]
fn test_rpc_wildcard_aud_forbidden() {
let scope = parse_scope("rpc:*?aud=*");
assert!(matches!(scope, ParsedScope::Unknown(_)));
let scope2 = parse_scope("rpc?aud=*");
assert!(matches!(scope2, ParsedScope::Unknown(_)));
let scope3 = parse_scope("rpc:app.bsky.feed.getTimeline?aud=*");
assert!(matches!(scope3, ParsedScope::Rpc(_)));
let scope4 = parse_scope("rpc:*?aud=did:web:api.bsky.app");
assert!(matches!(scope4, ParsedScope::Rpc(_)));
}
#[test]
fn test_rpc_lxm_required() {
let bare = parse_scope("rpc");
assert!(matches!(bare, ParsedScope::Unknown(_)));
}
#[test]
fn test_url_encoded_aud_with_fragment() {
let scope =
parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app%23bsky_appview");
match scope {
ParsedScope::Include(i) => {
assert_eq!(i.nsid, "app.bsky.authFullApp");
assert_eq!(i.aud, Some("did:web:api.bsky.app#bsky_appview".to_string()));
}
_ => panic!("Expected Include scope"),
}
let scope2 = parse_scope(
"rpc:com.atproto.moderation.createReport?aud=did:web:api.bsky.app%23bsky_appview",
);
match scope2 {
ParsedScope::Rpc(r) => {
assert_eq!(r.lxm, "com.atproto.moderation.createReport");
assert_eq!(r.aud, Some("did:web:api.bsky.app#bsky_appview".to_string()));
}
_ => panic!("Expected Rpc scope"),
}
}
}