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 query 144 .split('&') 145 .filter_map(|part| part.split_once('=')) 146 .fold(HashMap::new(), |mut acc, (key, value)| { 147 acc.entry(key.to_string()).or_default().push(value.to_string()); 148 acc 149 }) 150} 151 152pub fn parse_scope(scope: &str) -> ParsedScope { 153 match scope { 154 "atproto" => return ParsedScope::Atproto, 155 "transition:generic" => return ParsedScope::TransitionGeneric, 156 "transition:chat.bsky" => return ParsedScope::TransitionChat, 157 "transition:email" => return ParsedScope::TransitionEmail, 158 _ => {} 159 } 160 161 let (base, query) = scope.split_once('?').unwrap_or((scope, "")); 162 let params = parse_query_params(query); 163 164 if let Some(rest) = base.strip_prefix("repo:") { 165 let collection = if rest == "*" || rest.is_empty() { 166 None 167 } else { 168 Some(rest.to_string()) 169 }; 170 171 let mut actions = HashSet::new(); 172 if let Some(action_values) = params.get("action") { 173 for action_str in action_values { 174 if let Some(action) = RepoAction::parse_str(action_str) { 175 actions.insert(action); 176 } 177 } 178 } 179 if actions.is_empty() { 180 actions.insert(RepoAction::Create); 181 actions.insert(RepoAction::Update); 182 actions.insert(RepoAction::Delete); 183 } 184 185 return ParsedScope::Repo(RepoScope { 186 collection, 187 actions, 188 }); 189 } 190 191 if base == "repo" { 192 let mut actions = HashSet::new(); 193 if let Some(action_values) = params.get("action") { 194 for action_str in action_values { 195 if let Some(action) = RepoAction::parse_str(action_str) { 196 actions.insert(action); 197 } 198 } 199 } 200 if actions.is_empty() { 201 actions.insert(RepoAction::Create); 202 actions.insert(RepoAction::Update); 203 actions.insert(RepoAction::Delete); 204 } 205 return ParsedScope::Repo(RepoScope { 206 collection: None, 207 actions, 208 }); 209 } 210 211 if base.starts_with("blob") { 212 let positional = base.strip_prefix("blob:").unwrap_or(""); 213 let mut accept = HashSet::new(); 214 215 if !positional.is_empty() { 216 accept.insert(positional.to_string()); 217 } 218 if let Some(accept_values) = params.get("accept") { 219 for v in accept_values { 220 accept.insert(v.to_string()); 221 } 222 } 223 224 return ParsedScope::Blob(BlobScope { accept }); 225 } 226 227 if base.starts_with("rpc") { 228 let lxm_positional = base.strip_prefix("rpc:").map(|s| s.to_string()); 229 let lxm = lxm_positional.or_else(|| params.get("lxm").and_then(|v| v.first().cloned())); 230 let aud = params.get("aud").and_then(|v| v.first().cloned()); 231 232 let is_lxm_wildcard = lxm.as_deref() == Some("*") || lxm.is_none(); 233 let is_aud_wildcard = aud.as_deref() == Some("*"); 234 if is_lxm_wildcard && is_aud_wildcard { 235 return ParsedScope::Unknown(scope.to_string()); 236 } 237 238 return ParsedScope::Rpc(RpcScope { lxm, aud }); 239 } 240 241 if let Some(attr_str) = base.strip_prefix("account:") 242 && let Some(attr) = AccountAttr::parse_str(attr_str) 243 { 244 let action = params 245 .get("action") 246 .and_then(|v| v.first()) 247 .and_then(|s| AccountAction::parse_str(s)) 248 .unwrap_or(AccountAction::Read); 249 250 return ParsedScope::Account(AccountScope { attr, action }); 251 } 252 253 if let Some(attr_str) = base.strip_prefix("identity:") 254 && let Some(attr) = IdentityAttr::parse_str(attr_str) 255 { 256 return ParsedScope::Identity(IdentityScope { attr }); 257 } 258 259 if let Some(nsid) = base.strip_prefix("include:") { 260 let aud = params.get("aud").and_then(|v| v.first().cloned()); 261 return ParsedScope::Include(IncludeScope { 262 nsid: nsid.to_string(), 263 aud, 264 }); 265 } 266 267 ParsedScope::Unknown(scope.to_string()) 268} 269 270pub fn parse_scope_string(scope_str: &str) -> Vec<ParsedScope> { 271 scope_str.split_whitespace().map(parse_scope).collect() 272} 273 274#[cfg(test)] 275mod tests { 276 use super::*; 277 278 #[test] 279 fn test_parse_atproto() { 280 assert_eq!(parse_scope("atproto"), ParsedScope::Atproto); 281 } 282 283 #[test] 284 fn test_parse_transition_scopes() { 285 assert_eq!( 286 parse_scope("transition:generic"), 287 ParsedScope::TransitionGeneric 288 ); 289 assert_eq!( 290 parse_scope("transition:chat.bsky"), 291 ParsedScope::TransitionChat 292 ); 293 assert_eq!( 294 parse_scope("transition:email"), 295 ParsedScope::TransitionEmail 296 ); 297 } 298 299 #[test] 300 fn test_parse_repo_wildcard() { 301 let scope = parse_scope("repo:*?action=create"); 302 match scope { 303 ParsedScope::Repo(r) => { 304 assert!(r.collection.is_none()); 305 assert!(r.actions.contains(&RepoAction::Create)); 306 assert!(!r.actions.contains(&RepoAction::Update)); 307 } 308 _ => panic!("Expected Repo scope"), 309 } 310 } 311 312 #[test] 313 fn test_parse_repo_collection() { 314 let scope = parse_scope("repo:app.bsky.feed.post?action=create&action=delete"); 315 match scope { 316 ParsedScope::Repo(r) => { 317 assert_eq!(r.collection, Some("app.bsky.feed.post".to_string())); 318 assert!(r.actions.contains(&RepoAction::Create)); 319 assert!(r.actions.contains(&RepoAction::Delete)); 320 assert!(!r.actions.contains(&RepoAction::Update)); 321 } 322 _ => panic!("Expected Repo scope"), 323 } 324 } 325 326 #[test] 327 fn test_parse_repo_no_actions_means_all() { 328 let scope = parse_scope("repo:app.bsky.feed.post"); 329 match scope { 330 ParsedScope::Repo(r) => { 331 assert!(r.actions.contains(&RepoAction::Create)); 332 assert!(r.actions.contains(&RepoAction::Update)); 333 assert!(r.actions.contains(&RepoAction::Delete)); 334 } 335 _ => panic!("Expected Repo scope"), 336 } 337 } 338 339 #[test] 340 fn test_parse_blob_wildcard() { 341 let scope = parse_scope("blob:*/*"); 342 match scope { 343 ParsedScope::Blob(b) => { 344 assert!(b.accept.contains("*/*")); 345 assert!(b.matches_mime("image/png")); 346 assert!(b.matches_mime("video/mp4")); 347 } 348 _ => panic!("Expected Blob scope"), 349 } 350 } 351 352 #[test] 353 fn test_parse_blob_specific() { 354 let scope = parse_scope("blob?accept=image/*&accept=video/*"); 355 match scope { 356 ParsedScope::Blob(b) => { 357 assert!(b.matches_mime("image/png")); 358 assert!(b.matches_mime("image/jpeg")); 359 assert!(b.matches_mime("video/mp4")); 360 assert!(!b.matches_mime("text/plain")); 361 } 362 _ => panic!("Expected Blob scope"), 363 } 364 } 365 366 #[test] 367 fn test_parse_rpc() { 368 let scope = parse_scope("rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app"); 369 match scope { 370 ParsedScope::Rpc(r) => { 371 assert_eq!(r.lxm, Some("app.bsky.feed.getTimeline".to_string())); 372 assert_eq!(r.aud, Some("did:web:api.bsky.app".to_string())); 373 } 374 _ => panic!("Expected Rpc scope"), 375 } 376 } 377 378 #[test] 379 fn test_parse_account() { 380 let scope = parse_scope("account:email?action=read"); 381 match scope { 382 ParsedScope::Account(a) => { 383 assert_eq!(a.attr, AccountAttr::Email); 384 assert_eq!(a.action, AccountAction::Read); 385 } 386 _ => panic!("Expected Account scope"), 387 } 388 389 let scope2 = parse_scope("account:repo?action=manage"); 390 match scope2 { 391 ParsedScope::Account(a) => { 392 assert_eq!(a.attr, AccountAttr::Repo); 393 assert_eq!(a.action, AccountAction::Manage); 394 } 395 _ => panic!("Expected Account scope"), 396 } 397 } 398 399 #[test] 400 fn test_parse_scope_string() { 401 let scopes = parse_scope_string("atproto repo:*?action=create blob:*/*"); 402 assert_eq!(scopes.len(), 3); 403 assert_eq!(scopes[0], ParsedScope::Atproto); 404 match &scopes[1] { 405 ParsedScope::Repo(_) => {} 406 _ => panic!("Expected Repo"), 407 } 408 match &scopes[2] { 409 ParsedScope::Blob(_) => {} 410 _ => panic!("Expected Blob"), 411 } 412 } 413 414 #[test] 415 fn test_parse_include() { 416 let scope = parse_scope("include:app.bsky.authFullApp?aud=did:web:api.bsky.app"); 417 match scope { 418 ParsedScope::Include(i) => { 419 assert_eq!(i.nsid, "app.bsky.authFullApp"); 420 assert_eq!(i.aud, Some("did:web:api.bsky.app".to_string())); 421 } 422 _ => panic!("Expected Include scope"), 423 } 424 425 let scope2 = parse_scope("include:com.example.authBasicFeatures"); 426 match scope2 { 427 ParsedScope::Include(i) => { 428 assert_eq!(i.nsid, "com.example.authBasicFeatures"); 429 assert_eq!(i.aud, None); 430 } 431 _ => panic!("Expected Include scope"), 432 } 433 } 434 435 #[test] 436 fn test_parse_identity() { 437 let scope = parse_scope("identity:handle"); 438 match scope { 439 ParsedScope::Identity(i) => { 440 assert_eq!(i.attr, IdentityAttr::Handle); 441 } 442 _ => panic!("Expected Identity scope"), 443 } 444 445 let scope2 = parse_scope("identity:*"); 446 match scope2 { 447 ParsedScope::Identity(i) => { 448 assert_eq!(i.attr, IdentityAttr::Wildcard); 449 } 450 _ => panic!("Expected Identity scope"), 451 } 452 } 453 454 #[test] 455 fn test_parse_account_status() { 456 let scope = parse_scope("account:status?action=read"); 457 match scope { 458 ParsedScope::Account(a) => { 459 assert_eq!(a.attr, AccountAttr::Status); 460 assert_eq!(a.action, AccountAction::Read); 461 } 462 _ => panic!("Expected Account scope"), 463 } 464 } 465 466 #[test] 467 fn test_rpc_wildcard_aud_forbidden() { 468 let scope = parse_scope("rpc:*?aud=*"); 469 assert!(matches!(scope, ParsedScope::Unknown(_))); 470 471 let scope2 = parse_scope("rpc?aud=*"); 472 assert!(matches!(scope2, ParsedScope::Unknown(_))); 473 474 let scope3 = parse_scope("rpc:app.bsky.feed.getTimeline?aud=*"); 475 assert!(matches!(scope3, ParsedScope::Rpc(_))); 476 477 let scope4 = parse_scope("rpc:*?aud=did:web:api.bsky.app"); 478 assert!(matches!(scope4, ParsedScope::Rpc(_))); 479 } 480}