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