this repo has no description
1use std::collections::{HashMap, HashSet}; 2 3#[derive(Debug, Clone, PartialEq, Eq)] 4pub enum ParsedScope { 5 Atproto, 6 TransitionGeneric, 7 TransitionChat, 8 TransitionEmail, 9 Repo(RepoScope), 10 Blob(BlobScope), 11 Rpc(RpcScope), 12 Account(AccountScope), 13 Identity(IdentityScope), 14 Include(IncludeScope), 15 Unknown(String), 16} 17 18#[derive(Debug, Clone, PartialEq, Eq)] 19pub struct IncludeScope { 20 pub nsid: String, 21 pub aud: Option<String>, 22} 23 24#[derive(Debug, Clone, PartialEq, Eq)] 25pub struct RepoScope { 26 pub collection: Option<String>, 27 pub actions: HashSet<RepoAction>, 28} 29 30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 31pub enum RepoAction { 32 Create, 33 Update, 34 Delete, 35} 36 37impl RepoAction { 38 pub fn parse_str(s: &str) -> Option<Self> { 39 match s { 40 "create" => Some(Self::Create), 41 "update" => Some(Self::Update), 42 "delete" => Some(Self::Delete), 43 _ => None, 44 } 45 } 46} 47 48#[derive(Debug, Clone, PartialEq, Eq)] 49pub struct BlobScope { 50 pub accept: HashSet<String>, 51} 52 53impl BlobScope { 54 pub fn matches_mime(&self, mime: &str) -> bool { 55 if self.accept.is_empty() || self.accept.contains("*/*") { 56 return true; 57 } 58 for pattern in &self.accept { 59 if pattern == mime { 60 return true; 61 } 62 if let Some(prefix) = pattern.strip_suffix("/*") 63 && mime.starts_with(prefix) 64 && mime.chars().nth(prefix.len()) == Some('/') 65 { 66 return true; 67 } 68 } 69 false 70 } 71} 72 73#[derive(Debug, Clone, PartialEq, Eq)] 74pub struct RpcScope { 75 pub lxm: Option<String>, 76 pub aud: Option<String>, 77} 78 79#[derive(Debug, Clone, PartialEq, Eq)] 80pub struct AccountScope { 81 pub attr: AccountAttr, 82 pub action: AccountAction, 83} 84 85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 86pub enum AccountAttr { 87 Email, 88 Handle, 89 Repo, 90 Status, 91} 92 93#[derive(Debug, Clone, PartialEq, Eq)] 94pub struct IdentityScope { 95 pub attr: IdentityAttr, 96} 97 98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 99pub enum IdentityAttr { 100 Handle, 101 Wildcard, 102} 103 104impl AccountAttr { 105 pub fn parse_str(s: &str) -> Option<Self> { 106 match s { 107 "email" => Some(Self::Email), 108 "handle" => Some(Self::Handle), 109 "repo" => Some(Self::Repo), 110 "status" => Some(Self::Status), 111 _ => None, 112 } 113 } 114} 115 116impl IdentityAttr { 117 pub fn parse_str(s: &str) -> Option<Self> { 118 match s { 119 "handle" => Some(Self::Handle), 120 "*" => Some(Self::Wildcard), 121 _ => None, 122 } 123 } 124} 125 126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 127pub enum AccountAction { 128 Read, 129 Manage, 130} 131 132impl AccountAction { 133 pub fn parse_str(s: &str) -> Option<Self> { 134 match s { 135 "read" => Some(Self::Read), 136 "manage" => Some(Self::Manage), 137 _ => None, 138 } 139 } 140} 141 142fn parse_query_params(query: &str) -> HashMap<String, Vec<String>> { 143 let mut params: HashMap<String, Vec<String>> = HashMap::new(); 144 for part in query.split('&') { 145 if let Some((key, value)) = part.split_once('=') { 146 params 147 .entry(key.to_string()) 148 .or_default() 149 .push(value.to_string()); 150 } 151 } 152 params 153} 154 155pub fn parse_scope(scope: &str) -> ParsedScope { 156 match scope { 157 "atproto" => return ParsedScope::Atproto, 158 "transition:generic" => return ParsedScope::TransitionGeneric, 159 "transition:chat.bsky" => return ParsedScope::TransitionChat, 160 "transition:email" => return ParsedScope::TransitionEmail, 161 _ => {} 162 } 163 164 let (base, query) = scope.split_once('?').unwrap_or((scope, "")); 165 let params = parse_query_params(query); 166 167 if let Some(rest) = base.strip_prefix("repo:") { 168 let collection = if rest == "*" || rest.is_empty() { 169 None 170 } else { 171 Some(rest.to_string()) 172 }; 173 174 let mut actions = HashSet::new(); 175 if let Some(action_values) = params.get("action") { 176 for action_str in action_values { 177 if let Some(action) = RepoAction::parse_str(action_str) { 178 actions.insert(action); 179 } 180 } 181 } 182 if actions.is_empty() { 183 actions.insert(RepoAction::Create); 184 actions.insert(RepoAction::Update); 185 actions.insert(RepoAction::Delete); 186 } 187 188 return ParsedScope::Repo(RepoScope { 189 collection, 190 actions, 191 }); 192 } 193 194 if base == "repo" { 195 let mut actions = HashSet::new(); 196 if let Some(action_values) = params.get("action") { 197 for action_str in action_values { 198 if let Some(action) = RepoAction::parse_str(action_str) { 199 actions.insert(action); 200 } 201 } 202 } 203 if actions.is_empty() { 204 actions.insert(RepoAction::Create); 205 actions.insert(RepoAction::Update); 206 actions.insert(RepoAction::Delete); 207 } 208 return ParsedScope::Repo(RepoScope { 209 collection: None, 210 actions, 211 }); 212 } 213 214 if base.starts_with("blob") { 215 let positional = base.strip_prefix("blob:").unwrap_or(""); 216 let mut accept = HashSet::new(); 217 218 if !positional.is_empty() { 219 accept.insert(positional.to_string()); 220 } 221 if let Some(accept_values) = params.get("accept") { 222 for v in accept_values { 223 accept.insert(v.to_string()); 224 } 225 } 226 227 return ParsedScope::Blob(BlobScope { accept }); 228 } 229 230 if base.starts_with("rpc") { 231 let lxm_positional = base.strip_prefix("rpc:").map(|s| s.to_string()); 232 let lxm = lxm_positional.or_else(|| params.get("lxm").and_then(|v| v.first().cloned())); 233 let aud = params.get("aud").and_then(|v| v.first().cloned()); 234 235 let is_lxm_wildcard = lxm.as_deref() == Some("*") || lxm.is_none(); 236 let is_aud_wildcard = aud.as_deref() == Some("*"); 237 if is_lxm_wildcard && is_aud_wildcard { 238 return ParsedScope::Unknown(scope.to_string()); 239 } 240 241 return ParsedScope::Rpc(RpcScope { lxm, aud }); 242 } 243 244 if let Some(attr_str) = base.strip_prefix("account:") 245 && let Some(attr) = AccountAttr::parse_str(attr_str) 246 { 247 let action = params 248 .get("action") 249 .and_then(|v| v.first()) 250 .and_then(|s| AccountAction::parse_str(s)) 251 .unwrap_or(AccountAction::Read); 252 253 return ParsedScope::Account(AccountScope { attr, action }); 254 } 255 256 if let Some(attr_str) = base.strip_prefix("identity:") 257 && let Some(attr) = IdentityAttr::parse_str(attr_str) 258 { 259 return ParsedScope::Identity(IdentityScope { attr }); 260 } 261 262 if let Some(nsid) = base.strip_prefix("include:") { 263 let aud = params.get("aud").and_then(|v| v.first().cloned()); 264 return ParsedScope::Include(IncludeScope { 265 nsid: nsid.to_string(), 266 aud, 267 }); 268 } 269 270 ParsedScope::Unknown(scope.to_string()) 271} 272 273pub fn parse_scope_string(scope_str: &str) -> Vec<ParsedScope> { 274 scope_str.split_whitespace().map(parse_scope).collect() 275} 276 277#[cfg(test)] 278mod tests { 279 use super::*; 280 281 #[test] 282 fn test_parse_atproto() { 283 assert_eq!(parse_scope("atproto"), ParsedScope::Atproto); 284 } 285 286 #[test] 287 fn test_parse_transition_scopes() { 288 assert_eq!( 289 parse_scope("transition:generic"), 290 ParsedScope::TransitionGeneric 291 ); 292 assert_eq!( 293 parse_scope("transition:chat.bsky"), 294 ParsedScope::TransitionChat 295 ); 296 assert_eq!( 297 parse_scope("transition:email"), 298 ParsedScope::TransitionEmail 299 ); 300 } 301 302 #[test] 303 fn test_parse_repo_wildcard() { 304 let scope = parse_scope("repo:*?action=create"); 305 match scope { 306 ParsedScope::Repo(r) => { 307 assert!(r.collection.is_none()); 308 assert!(r.actions.contains(&RepoAction::Create)); 309 assert!(!r.actions.contains(&RepoAction::Update)); 310 } 311 _ => panic!("Expected Repo scope"), 312 } 313 } 314 315 #[test] 316 fn test_parse_repo_collection() { 317 let scope = parse_scope("repo:app.bsky.feed.post?action=create&action=delete"); 318 match scope { 319 ParsedScope::Repo(r) => { 320 assert_eq!(r.collection, Some("app.bsky.feed.post".to_string())); 321 assert!(r.actions.contains(&RepoAction::Create)); 322 assert!(r.actions.contains(&RepoAction::Delete)); 323 assert!(!r.actions.contains(&RepoAction::Update)); 324 } 325 _ => panic!("Expected Repo scope"), 326 } 327 } 328 329 #[test] 330 fn test_parse_repo_no_actions_means_all() { 331 let scope = parse_scope("repo:app.bsky.feed.post"); 332 match scope { 333 ParsedScope::Repo(r) => { 334 assert!(r.actions.contains(&RepoAction::Create)); 335 assert!(r.actions.contains(&RepoAction::Update)); 336 assert!(r.actions.contains(&RepoAction::Delete)); 337 } 338 _ => panic!("Expected Repo scope"), 339 } 340 } 341 342 #[test] 343 fn test_parse_blob_wildcard() { 344 let scope = parse_scope("blob:*/*"); 345 match scope { 346 ParsedScope::Blob(b) => { 347 assert!(b.accept.contains("*/*")); 348 assert!(b.matches_mime("image/png")); 349 assert!(b.matches_mime("video/mp4")); 350 } 351 _ => panic!("Expected Blob scope"), 352 } 353 } 354 355 #[test] 356 fn test_parse_blob_specific() { 357 let scope = parse_scope("blob?accept=image/*&accept=video/*"); 358 match scope { 359 ParsedScope::Blob(b) => { 360 assert!(b.matches_mime("image/png")); 361 assert!(b.matches_mime("image/jpeg")); 362 assert!(b.matches_mime("video/mp4")); 363 assert!(!b.matches_mime("text/plain")); 364 } 365 _ => panic!("Expected Blob scope"), 366 } 367 } 368 369 #[test] 370 fn test_parse_rpc() { 371 let scope = parse_scope("rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app"); 372 match scope { 373 ParsedScope::Rpc(r) => { 374 assert_eq!(r.lxm, Some("app.bsky.feed.getTimeline".to_string())); 375 assert_eq!(r.aud, Some("did:web:api.bsky.app".to_string())); 376 } 377 _ => panic!("Expected Rpc scope"), 378 } 379 } 380 381 #[test] 382 fn test_parse_account() { 383 let scope = parse_scope("account:email?action=read"); 384 match scope { 385 ParsedScope::Account(a) => { 386 assert_eq!(a.attr, AccountAttr::Email); 387 assert_eq!(a.action, AccountAction::Read); 388 } 389 _ => panic!("Expected Account scope"), 390 } 391 392 let scope2 = parse_scope("account:repo?action=manage"); 393 match scope2 { 394 ParsedScope::Account(a) => { 395 assert_eq!(a.attr, AccountAttr::Repo); 396 assert_eq!(a.action, AccountAction::Manage); 397 } 398 _ => panic!("Expected Account scope"), 399 } 400 } 401 402 #[test] 403 fn test_parse_scope_string() { 404 let scopes = parse_scope_string("atproto repo:*?action=create blob:*/*"); 405 assert_eq!(scopes.len(), 3); 406 assert_eq!(scopes[0], ParsedScope::Atproto); 407 match &scopes[1] { 408 ParsedScope::Repo(_) => {} 409 _ => panic!("Expected Repo"), 410 } 411 match &scopes[2] { 412 ParsedScope::Blob(_) => {} 413 _ => panic!("Expected Blob"), 414 } 415 } 416 417 #[test] 418 fn test_parse_include() { 419 let scope = parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app"); 420 match scope { 421 ParsedScope::Include(i) => { 422 assert_eq!(i.nsid, "app.bsky.authFullApp"); 423 assert_eq!(i.aud, Some("did:web:api.bsky.app".to_string())); 424 } 425 _ => panic!("Expected Include scope"), 426 } 427 428 let scope2 = parse_scope("include:com.example.authBasicFeatures"); 429 match scope2 { 430 ParsedScope::Include(i) => { 431 assert_eq!(i.nsid, "com.example.authBasicFeatures"); 432 assert_eq!(i.aud, None); 433 } 434 _ => panic!("Expected Include scope"), 435 } 436 } 437 438 #[test] 439 fn test_parse_identity() { 440 let scope = parse_scope("identity:handle"); 441 match scope { 442 ParsedScope::Identity(i) => { 443 assert_eq!(i.attr, IdentityAttr::Handle); 444 } 445 _ => panic!("Expected Identity scope"), 446 } 447 448 let scope2 = parse_scope("identity:*"); 449 match scope2 { 450 ParsedScope::Identity(i) => { 451 assert_eq!(i.attr, IdentityAttr::Wildcard); 452 } 453 _ => panic!("Expected Identity scope"), 454 } 455 } 456 457 #[test] 458 fn test_parse_account_status() { 459 let scope = parse_scope("account:status?action=read"); 460 match scope { 461 ParsedScope::Account(a) => { 462 assert_eq!(a.attr, AccountAttr::Status); 463 assert_eq!(a.action, AccountAction::Read); 464 } 465 _ => panic!("Expected Account scope"), 466 } 467 } 468 469 #[test] 470 fn test_rpc_wildcard_aud_forbidden() { 471 let scope = parse_scope("rpc:*?aud=*"); 472 assert!(matches!(scope, ParsedScope::Unknown(_))); 473 474 let scope2 = parse_scope("rpc?aud=*"); 475 assert!(matches!(scope2, ParsedScope::Unknown(_))); 476 477 let scope3 = parse_scope("rpc:app.bsky.feed.getTimeline?aud=*"); 478 assert!(matches!(scope3, ParsedScope::Rpc(_))); 479 480 let scope4 = parse_scope("rpc:*?aud=did:web:api.bsky.app"); 481 assert!(matches!(scope4, ParsedScope::Rpc(_))); 482 } 483}