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}