Something went wrong. Try again.
Our Personal Data Server from scratch!
Something went wrong. Try again.
16 kB · 539 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540use 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: 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 })}
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 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_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, 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"), } }}