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