mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-02-08 21:30:08 +00:00
512 lines
15 KiB
Rust
512 lines
15 KiB
Rust
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>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum RepoAction {
|
|
Create,
|
|
Update,
|
|
Delete,
|
|
}
|
|
|
|
impl RepoAction {
|
|
pub fn parse_str(s: &str) -> Option<Self> {
|
|
match s {
|
|
"create" => Some(Self::Create),
|
|
"update" => Some(Self::Update),
|
|
"delete" => Some(Self::Delete),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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: Option<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
|
|
})
|
|
}
|
|
|
|
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 actions: HashSet<RepoAction> = params
|
|
.get("action")
|
|
.map(|action_values| {
|
|
action_values
|
|
.iter()
|
|
.filter_map(|s| RepoAction::parse_str(s))
|
|
.collect()
|
|
})
|
|
.filter(|set: &HashSet<RepoAction>| !set.is_empty())
|
|
.unwrap_or_else(|| {
|
|
[RepoAction::Create, RepoAction::Update, RepoAction::Delete]
|
|
.into_iter()
|
|
.collect()
|
|
});
|
|
|
|
return ParsedScope::Repo(RepoScope {
|
|
collection,
|
|
actions,
|
|
});
|
|
}
|
|
|
|
if base == "repo" {
|
|
let actions: HashSet<RepoAction> = params
|
|
.get("action")
|
|
.map(|action_values| {
|
|
action_values
|
|
.iter()
|
|
.filter_map(|s| RepoAction::parse_str(s))
|
|
.collect()
|
|
})
|
|
.filter(|set: &HashSet<RepoAction>| !set.is_empty())
|
|
.unwrap_or_else(|| {
|
|
[RepoAction::Create, RepoAction::Update, RepoAction::Delete]
|
|
.into_iter()
|
|
.collect()
|
|
});
|
|
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 is_lxm_wildcard = lxm.as_deref() == Some("*") || lxm.is_none();
|
|
let is_aud_wildcard = aud.as_deref() == Some("*");
|
|
if is_lxm_wildcard && is_aud_wildcard {
|
|
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_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, Some("app.bsky.feed.getTimeline".to_string()));
|
|
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_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,
|
|
Some("com.atproto.moderation.createReport".to_string())
|
|
);
|
|
assert_eq!(r.aud, Some("did:web:api.bsky.app#bsky_appview".to_string()));
|
|
}
|
|
_ => panic!("Expected Rpc scope"),
|
|
}
|
|
}
|
|
}
|