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}