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 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}