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