+9
-2
crates/atproto-jetstream/src/consumer.rs
+9
-2
crates/atproto-jetstream/src/consumer.rs
···
153
153
pub(crate) enum SubscriberSourcedMessage {
154
154
#[serde(rename = "options_update")]
155
155
Update {
156
-
#[serde(rename = "wantedCollections", skip_serializing_if = "Vec::is_empty", default)]
156
+
#[serde(
157
+
rename = "wantedCollections",
158
+
skip_serializing_if = "Vec::is_empty",
159
+
default
160
+
)]
157
161
wanted_collections: Vec<String>,
158
162
159
163
#[serde(rename = "wantedDids", skip_serializing_if = "Vec::is_empty", default)]
···
264
268
// Add wantedCollections if specified (each collection as a separate query parameter)
265
269
if !self.config.collections.is_empty() && !self.config.require_hello {
266
270
for collection in &self.config.collections {
267
-
query_params.push(format!("wantedCollections={}", urlencoding::encode(collection)));
271
+
query_params.push(format!(
272
+
"wantedCollections={}",
273
+
urlencoding::encode(collection)
274
+
));
268
275
}
269
276
}
270
277
+4
-3
crates/atproto-oauth-axum/src/handler_metadata.rs
+4
-3
crates/atproto-oauth-axum/src/handler_metadata.rs
···
66
66
let mut jwks_keys = Vec::new();
67
67
for key_data in &oauth_client_config.signing_keys {
68
68
if let Ok(public_key_data) = to_public(key_data)
69
-
&& let Ok(jwk) = generate(&public_key_data) {
70
-
jwks_keys.push(jwk);
71
-
}
69
+
&& let Ok(jwk) = generate(&public_key_data)
70
+
{
71
+
jwks_keys.push(jwk);
72
+
}
72
73
}
73
74
(None, Some(WrappedJsonWebKeySet { keys: jwks_keys }))
74
75
};
+4
-3
crates/atproto-oauth/src/dpop.rs
+4
-3
crates/atproto-oauth/src/dpop.rs
···
752
752
// exp (expiration) - validate if present
753
753
if let Some(exp_value) = claims.get("exp")
754
754
&& let Some(exp) = exp_value.as_u64()
755
-
&& config.now as u64 >= exp {
756
-
return Err(JWTError::TokenExpired.into());
757
-
}
755
+
&& config.now as u64 >= exp
756
+
{
757
+
return Err(JWTError::TokenExpired.into());
758
+
}
758
759
759
760
// 3. VERIFY SIGNATURE
760
761
let content = format!("{}.{}", encoded_header, encoded_payload);
+8
-6
crates/atproto-oauth/src/jwt.rs
+8
-6
crates/atproto-oauth/src/jwt.rs
···
215
215
216
216
// Validate expiration time if present
217
217
if let Some(exp) = claims.jose.expiration
218
-
&& now >= exp {
219
-
return Err(JWTError::TokenExpired.into());
220
-
}
218
+
&& now >= exp
219
+
{
220
+
return Err(JWTError::TokenExpired.into());
221
+
}
221
222
222
223
// Validate not-before time if present
223
224
if let Some(nbf) = claims.jose.not_before
224
-
&& now < nbf {
225
-
return Err(JWTError::TokenNotValidYet.into());
226
-
}
225
+
&& now < nbf
226
+
{
227
+
return Err(JWTError::TokenNotValidYet.into());
228
+
}
227
229
228
230
// Return validated claims
229
231
Ok(claims)
+4
crates/atproto-oauth/src/lib.rs
+4
crates/atproto-oauth/src/lib.rs
···
6
6
#![forbid(unsafe_code)]
7
7
#![warn(missing_docs)]
8
8
9
+
/// DPoP (Demonstrating Proof of Possession) implementation for OAuth 2.0.
9
10
pub mod dpop;
10
11
/// Base64 encoding and decoding utilities.
11
12
pub mod encoding;
···
17
18
pub mod jwt;
18
19
/// PKCE (Proof Key for Code Exchange) implementation for OAuth 2.0 security.
19
20
pub mod pkce;
21
+
/// OAuth resource and authorization server management.
20
22
pub mod resources;
21
23
/// OAuth request storage abstraction for CRUD operations.
22
24
pub mod storage;
···
25
27
pub mod storage_lru;
26
28
/// OAuth workflow implementation for AT Protocol authorization flows.
27
29
pub mod workflow;
30
+
/// OAuth 2.0 scope definitions and parsing for AT Protocol.
31
+
pub mod scopes;
+1841
crates/atproto-oauth/src/scopes.rs
+1841
crates/atproto-oauth/src/scopes.rs
···
1
+
//! AT Protocol OAuth scopes module
2
+
//!
3
+
//! This module provides comprehensive support for AT Protocol OAuth scopes,
4
+
//! including parsing, serialization, normalization, and permission checking.
5
+
//!
6
+
//! Scopes in AT Protocol follow a prefix-based format with optional query parameters:
7
+
//! - `account`: Access to account information (email, repo, status)
8
+
//! - `identity`: Access to identity information (handle)
9
+
//! - `blob`: Access to blob operations with mime type constraints
10
+
//! - `repo`: Repository operations with collection and action constraints
11
+
//! - `rpc`: RPC method access with lexicon and audience constraints
12
+
//! - `atproto`: Required scope to indicate that other AT Protocol scopes will be used
13
+
//! - `transition`: Migration operations (generic or email)
14
+
//!
15
+
//! Standard OpenID Connect scopes (no suffixes or query parameters):
16
+
//! - `openid`: Required for OpenID Connect authentication
17
+
//! - `profile`: Access to user profile information
18
+
//! - `email`: Access to user email address
19
+
20
+
use std::collections::{BTreeMap, BTreeSet};
21
+
use std::fmt;
22
+
use std::str::FromStr;
23
+
24
+
/// Represents an AT Protocol OAuth scope
25
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26
+
pub enum Scope {
27
+
/// Account scope for accessing account information
28
+
Account(AccountScope),
29
+
/// Identity scope for accessing identity information
30
+
Identity(IdentityScope),
31
+
/// Blob scope for blob operations with mime type constraints
32
+
Blob(BlobScope),
33
+
/// Repository scope for collection operations
34
+
Repo(RepoScope),
35
+
/// RPC scope for method access
36
+
Rpc(RpcScope),
37
+
/// AT Protocol scope - required to indicate that other AT Protocol scopes will be used
38
+
Atproto,
39
+
/// Transition scope for migration operations
40
+
Transition(TransitionScope),
41
+
/// OpenID Connect scope - required for OpenID Connect authentication
42
+
OpenId,
43
+
/// Profile scope - access to user profile information
44
+
Profile,
45
+
/// Email scope - access to user email address
46
+
Email,
47
+
}
48
+
49
+
/// Account scope attributes
50
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
51
+
pub struct AccountScope {
52
+
/// The account resource type
53
+
pub resource: AccountResource,
54
+
/// The action permission level
55
+
pub action: AccountAction,
56
+
}
57
+
58
+
/// Account resource types
59
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60
+
pub enum AccountResource {
61
+
/// Email access
62
+
Email,
63
+
/// Repository access
64
+
Repo,
65
+
/// Status access
66
+
Status,
67
+
}
68
+
69
+
/// Account action permissions
70
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71
+
pub enum AccountAction {
72
+
/// Read-only access
73
+
Read,
74
+
/// Management access (includes read)
75
+
Manage,
76
+
}
77
+
78
+
/// Identity scope attributes
79
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
80
+
pub enum IdentityScope {
81
+
/// Handle access
82
+
Handle,
83
+
/// All identity access (wildcard)
84
+
All,
85
+
}
86
+
87
+
/// Transition scope types
88
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
89
+
pub enum TransitionScope {
90
+
/// Generic transition operations
91
+
Generic,
92
+
/// Email transition operations
93
+
Email,
94
+
}
95
+
96
+
/// Blob scope with mime type constraints
97
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
98
+
pub struct BlobScope {
99
+
/// Accepted mime types
100
+
pub accept: BTreeSet<MimePattern>,
101
+
}
102
+
103
+
/// MIME type pattern for blob scope
104
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
105
+
pub enum MimePattern {
106
+
/// Match all types
107
+
All,
108
+
/// Match all subtypes of a type (e.g., "image/*")
109
+
TypeWildcard(String),
110
+
/// Exact mime type match
111
+
Exact(String),
112
+
}
113
+
114
+
/// Repository scope with collection and action constraints
115
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116
+
pub struct RepoScope {
117
+
/// Collection NSID or wildcard
118
+
pub collection: RepoCollection,
119
+
/// Allowed actions
120
+
pub actions: BTreeSet<RepoAction>,
121
+
}
122
+
123
+
/// Repository collection identifier
124
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
125
+
pub enum RepoCollection {
126
+
/// All collections (wildcard)
127
+
All,
128
+
/// Specific collection NSID
129
+
Nsid(String),
130
+
}
131
+
132
+
/// Repository actions
133
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
134
+
pub enum RepoAction {
135
+
/// Create records
136
+
Create,
137
+
/// Update records
138
+
Update,
139
+
/// Delete records
140
+
Delete,
141
+
}
142
+
143
+
/// RPC scope with lexicon method and audience constraints
144
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
145
+
pub struct RpcScope {
146
+
/// Lexicon methods (NSIDs or wildcard)
147
+
pub lxm: BTreeSet<RpcLexicon>,
148
+
/// Audiences (DIDs or wildcard)
149
+
pub aud: BTreeSet<RpcAudience>,
150
+
}
151
+
152
+
/// RPC lexicon identifier
153
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
154
+
pub enum RpcLexicon {
155
+
/// All lexicons (wildcard)
156
+
All,
157
+
/// Specific lexicon NSID
158
+
Nsid(String),
159
+
}
160
+
161
+
/// RPC audience identifier
162
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
163
+
pub enum RpcAudience {
164
+
/// All audiences (wildcard)
165
+
All,
166
+
/// Specific DID
167
+
Did(String),
168
+
}
169
+
170
+
impl Scope {
171
+
/// Parse multiple space-separated scopes from a string
172
+
///
173
+
/// # Examples
174
+
/// ```
175
+
/// # use atproto_oauth::scopes::Scope;
176
+
/// let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
177
+
/// assert_eq!(scopes.len(), 2);
178
+
/// ```
179
+
pub fn parse_multiple(s: &str) -> Result<Vec<Self>, ParseError> {
180
+
if s.trim().is_empty() {
181
+
return Ok(Vec::new());
182
+
}
183
+
184
+
let mut scopes = Vec::new();
185
+
for scope_str in s.trim().split_whitespace() {
186
+
scopes.push(Self::parse(scope_str)?);
187
+
}
188
+
189
+
Ok(scopes)
190
+
}
191
+
192
+
/// Parse multiple space-separated scopes and return the minimal set needed
193
+
///
194
+
/// This method removes duplicate scopes and scopes that are already granted
195
+
/// by other scopes in the list, returning only the minimal set of scopes needed.
196
+
///
197
+
/// # Examples
198
+
/// ```
199
+
/// # use atproto_oauth::scopes::Scope;
200
+
/// // repo:* grants repo:foo.bar, so only repo:* is kept
201
+
/// let scopes = Scope::parse_multiple_reduced("atproto repo:foo.bar repo:*").unwrap();
202
+
/// assert_eq!(scopes.len(), 2); // atproto and repo:*
203
+
/// ```
204
+
pub fn parse_multiple_reduced(s: &str) -> Result<Vec<Self>, ParseError> {
205
+
let all_scopes = Self::parse_multiple(s)?;
206
+
207
+
if all_scopes.is_empty() {
208
+
return Ok(Vec::new());
209
+
}
210
+
211
+
let mut result: Vec<Self> = Vec::new();
212
+
213
+
for scope in all_scopes {
214
+
// Check if this scope is already granted by something in the result
215
+
let mut is_granted = false;
216
+
for existing in &result {
217
+
if existing.grants(&scope) && existing != &scope {
218
+
is_granted = true;
219
+
break;
220
+
}
221
+
}
222
+
223
+
if is_granted {
224
+
continue; // Skip this scope, it's already covered
225
+
}
226
+
227
+
// Check if this scope grants any existing scopes in the result
228
+
let mut indices_to_remove = Vec::new();
229
+
for (i, existing) in result.iter().enumerate() {
230
+
if scope.grants(existing) && &scope != existing {
231
+
indices_to_remove.push(i);
232
+
}
233
+
}
234
+
235
+
// Remove scopes that are granted by the new scope (in reverse order to maintain indices)
236
+
for i in indices_to_remove.into_iter().rev() {
237
+
result.remove(i);
238
+
}
239
+
240
+
// Add the new scope if it's not a duplicate
241
+
if !result.contains(&scope) {
242
+
result.push(scope);
243
+
}
244
+
}
245
+
246
+
Ok(result)
247
+
}
248
+
249
+
/// Serialize a list of scopes into a space-separated OAuth scopes string
250
+
///
251
+
/// The scopes are sorted alphabetically by their string representation to ensure
252
+
/// consistent output regardless of input order.
253
+
///
254
+
/// # Examples
255
+
/// ```
256
+
/// # use atproto_oauth::scopes::Scope;
257
+
/// let scopes = vec![
258
+
/// Scope::parse("repo:*").unwrap(),
259
+
/// Scope::parse("atproto").unwrap(),
260
+
/// Scope::parse("account:email").unwrap(),
261
+
/// ];
262
+
/// let result = Scope::serialize_multiple(&scopes);
263
+
/// assert_eq!(result, "account:email atproto repo:*");
264
+
/// ```
265
+
pub fn serialize_multiple(scopes: &[Self]) -> String {
266
+
if scopes.is_empty() {
267
+
return String::new();
268
+
}
269
+
270
+
let mut serialized: Vec<String> = scopes.iter().map(|scope| scope.to_string()).collect();
271
+
272
+
serialized.sort();
273
+
serialized.join(" ")
274
+
}
275
+
276
+
/// Remove a scope from a list of scopes
277
+
///
278
+
/// Returns a new vector with all instances of the specified scope removed.
279
+
/// If the scope doesn't exist in the list, returns a copy of the original list.
280
+
///
281
+
/// # Examples
282
+
/// ```
283
+
/// # use atproto_oauth::scopes::Scope;
284
+
/// let scopes = vec![
285
+
/// Scope::parse("repo:*").unwrap(),
286
+
/// Scope::parse("atproto").unwrap(),
287
+
/// Scope::parse("account:email").unwrap(),
288
+
/// ];
289
+
/// let to_remove = Scope::parse("atproto").unwrap();
290
+
/// let result = Scope::remove_scope(&scopes, &to_remove);
291
+
/// assert_eq!(result.len(), 2);
292
+
/// assert!(!result.contains(&to_remove));
293
+
/// ```
294
+
pub fn remove_scope(scopes: &[Self], scope_to_remove: &Self) -> Vec<Self> {
295
+
scopes
296
+
.iter()
297
+
.filter(|s| *s != scope_to_remove)
298
+
.cloned()
299
+
.collect()
300
+
}
301
+
302
+
/// Parse a scope from a string
303
+
pub fn parse(s: &str) -> Result<Self, ParseError> {
304
+
// Determine the prefix first by checking for known prefixes
305
+
let prefixes = [
306
+
"account",
307
+
"identity",
308
+
"blob",
309
+
"repo",
310
+
"rpc",
311
+
"atproto",
312
+
"transition",
313
+
"openid",
314
+
"profile",
315
+
"email",
316
+
];
317
+
let mut found_prefix = None;
318
+
let mut suffix = None;
319
+
320
+
for prefix in &prefixes {
321
+
if s.starts_with(prefix) {
322
+
let remainder = &s[prefix.len()..];
323
+
if remainder.is_empty() || remainder.starts_with(':') || remainder.starts_with('?')
324
+
{
325
+
found_prefix = Some(*prefix);
326
+
if remainder.starts_with(':') {
327
+
suffix = Some(&remainder[1..]);
328
+
} else if remainder.starts_with('?') {
329
+
suffix = Some(remainder);
330
+
} else {
331
+
suffix = None;
332
+
}
333
+
break;
334
+
}
335
+
}
336
+
}
337
+
338
+
let prefix = found_prefix.ok_or_else(|| {
339
+
// If no known prefix found, extract what looks like a prefix for error reporting
340
+
let end = s.find(':').or_else(|| s.find('?')).unwrap_or(s.len());
341
+
ParseError::UnknownPrefix(s[..end].to_string())
342
+
})?;
343
+
344
+
match prefix {
345
+
"account" => Self::parse_account(suffix),
346
+
"identity" => Self::parse_identity(suffix),
347
+
"blob" => Self::parse_blob(suffix),
348
+
"repo" => Self::parse_repo(suffix),
349
+
"rpc" => Self::parse_rpc(suffix),
350
+
"atproto" => Self::parse_atproto(suffix),
351
+
"transition" => Self::parse_transition(suffix),
352
+
"openid" => Self::parse_openid(suffix),
353
+
"profile" => Self::parse_profile(suffix),
354
+
"email" => Self::parse_email(suffix),
355
+
_ => Err(ParseError::UnknownPrefix(prefix.to_string())),
356
+
}
357
+
}
358
+
359
+
fn parse_account(suffix: Option<&str>) -> Result<Self, ParseError> {
360
+
let (resource_str, params) = match suffix {
361
+
Some(s) => {
362
+
if let Some(pos) = s.find('?') {
363
+
(&s[..pos], Some(&s[pos + 1..]))
364
+
} else {
365
+
(s, None)
366
+
}
367
+
}
368
+
None => return Err(ParseError::MissingResource),
369
+
};
370
+
371
+
let resource = match resource_str {
372
+
"email" => AccountResource::Email,
373
+
"repo" => AccountResource::Repo,
374
+
"status" => AccountResource::Status,
375
+
_ => return Err(ParseError::InvalidResource(resource_str.to_string())),
376
+
};
377
+
378
+
let action = if let Some(params) = params {
379
+
let parsed_params = parse_query_string(params);
380
+
match parsed_params
381
+
.get("action")
382
+
.and_then(|v| v.first())
383
+
.map(|s| s.as_str())
384
+
{
385
+
Some("read") => AccountAction::Read,
386
+
Some("manage") => AccountAction::Manage,
387
+
Some(other) => return Err(ParseError::InvalidAction(other.to_string())),
388
+
None => AccountAction::Read,
389
+
}
390
+
} else {
391
+
AccountAction::Read
392
+
};
393
+
394
+
Ok(Scope::Account(AccountScope { resource, action }))
395
+
}
396
+
397
+
fn parse_identity(suffix: Option<&str>) -> Result<Self, ParseError> {
398
+
let scope = match suffix {
399
+
Some("handle") => IdentityScope::Handle,
400
+
Some("*") => IdentityScope::All,
401
+
Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
402
+
None => return Err(ParseError::MissingResource),
403
+
};
404
+
405
+
Ok(Scope::Identity(scope))
406
+
}
407
+
408
+
fn parse_blob(suffix: Option<&str>) -> Result<Self, ParseError> {
409
+
let mut accept = BTreeSet::new();
410
+
411
+
match suffix {
412
+
Some(s) if s.starts_with('?') => {
413
+
let params = parse_query_string(&s[1..]);
414
+
if let Some(values) = params.get("accept") {
415
+
for value in values {
416
+
accept.insert(MimePattern::from_str(value)?);
417
+
}
418
+
}
419
+
}
420
+
Some(s) => {
421
+
accept.insert(MimePattern::from_str(s)?);
422
+
}
423
+
None => {
424
+
accept.insert(MimePattern::All);
425
+
}
426
+
}
427
+
428
+
if accept.is_empty() {
429
+
accept.insert(MimePattern::All);
430
+
}
431
+
432
+
Ok(Scope::Blob(BlobScope { accept }))
433
+
}
434
+
435
+
fn parse_repo(suffix: Option<&str>) -> Result<Self, ParseError> {
436
+
let (collection_str, params) = match suffix {
437
+
Some(s) => {
438
+
if let Some(pos) = s.find('?') {
439
+
(Some(&s[..pos]), Some(&s[pos + 1..]))
440
+
} else {
441
+
(Some(s), None)
442
+
}
443
+
}
444
+
None => (None, None),
445
+
};
446
+
447
+
let collection = match collection_str {
448
+
Some("*") | None => RepoCollection::All,
449
+
Some(nsid) => RepoCollection::Nsid(nsid.to_string()),
450
+
};
451
+
452
+
let mut actions = BTreeSet::new();
453
+
if let Some(params) = params {
454
+
let parsed_params = parse_query_string(params);
455
+
if let Some(values) = parsed_params.get("action") {
456
+
for value in values {
457
+
match value.as_str() {
458
+
"create" => {
459
+
actions.insert(RepoAction::Create);
460
+
}
461
+
"update" => {
462
+
actions.insert(RepoAction::Update);
463
+
}
464
+
"delete" => {
465
+
actions.insert(RepoAction::Delete);
466
+
}
467
+
"*" => {
468
+
actions.insert(RepoAction::Create);
469
+
actions.insert(RepoAction::Update);
470
+
actions.insert(RepoAction::Delete);
471
+
}
472
+
other => return Err(ParseError::InvalidAction(other.to_string())),
473
+
}
474
+
}
475
+
}
476
+
}
477
+
478
+
if actions.is_empty() {
479
+
actions.insert(RepoAction::Create);
480
+
actions.insert(RepoAction::Update);
481
+
actions.insert(RepoAction::Delete);
482
+
}
483
+
484
+
Ok(Scope::Repo(RepoScope {
485
+
collection,
486
+
actions,
487
+
}))
488
+
}
489
+
490
+
fn parse_rpc(suffix: Option<&str>) -> Result<Self, ParseError> {
491
+
let mut lxm = BTreeSet::new();
492
+
let mut aud = BTreeSet::new();
493
+
494
+
match suffix {
495
+
Some("*") => {
496
+
lxm.insert(RpcLexicon::All);
497
+
aud.insert(RpcAudience::All);
498
+
}
499
+
Some(s) if s.starts_with('?') => {
500
+
let params = parse_query_string(&s[1..]);
501
+
502
+
if let Some(values) = params.get("lxm") {
503
+
for value in values {
504
+
if value == "*" {
505
+
lxm.insert(RpcLexicon::All);
506
+
} else {
507
+
lxm.insert(RpcLexicon::Nsid(value.to_string()));
508
+
}
509
+
}
510
+
}
511
+
512
+
if let Some(values) = params.get("aud") {
513
+
for value in values {
514
+
if value == "*" {
515
+
aud.insert(RpcAudience::All);
516
+
} else {
517
+
aud.insert(RpcAudience::Did(value.to_string()));
518
+
}
519
+
}
520
+
}
521
+
}
522
+
Some(s) => {
523
+
// Check if there's a query string in the suffix
524
+
if let Some(pos) = s.find('?') {
525
+
let nsid = &s[..pos];
526
+
let params = parse_query_string(&s[pos + 1..]);
527
+
528
+
lxm.insert(RpcLexicon::Nsid(nsid.to_string()));
529
+
530
+
if let Some(values) = params.get("aud") {
531
+
for value in values {
532
+
if value == "*" {
533
+
aud.insert(RpcAudience::All);
534
+
} else {
535
+
aud.insert(RpcAudience::Did(value.to_string()));
536
+
}
537
+
}
538
+
}
539
+
} else {
540
+
lxm.insert(RpcLexicon::Nsid(s.to_string()));
541
+
}
542
+
}
543
+
None => {}
544
+
}
545
+
546
+
if lxm.is_empty() {
547
+
lxm.insert(RpcLexicon::All);
548
+
}
549
+
if aud.is_empty() {
550
+
aud.insert(RpcAudience::All);
551
+
}
552
+
553
+
Ok(Scope::Rpc(RpcScope { lxm, aud }))
554
+
}
555
+
556
+
fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> {
557
+
if suffix.is_some() {
558
+
return Err(ParseError::InvalidResource(
559
+
"atproto scope does not accept suffixes".to_string(),
560
+
));
561
+
}
562
+
Ok(Scope::Atproto)
563
+
}
564
+
565
+
fn parse_transition(suffix: Option<&str>) -> Result<Self, ParseError> {
566
+
let scope = match suffix {
567
+
Some("generic") => TransitionScope::Generic,
568
+
Some("email") => TransitionScope::Email,
569
+
Some(other) => return Err(ParseError::InvalidResource(other.to_string())),
570
+
None => return Err(ParseError::MissingResource),
571
+
};
572
+
573
+
Ok(Scope::Transition(scope))
574
+
}
575
+
576
+
fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> {
577
+
if suffix.is_some() {
578
+
return Err(ParseError::InvalidResource(
579
+
"openid scope does not accept suffixes".to_string(),
580
+
));
581
+
}
582
+
Ok(Scope::OpenId)
583
+
}
584
+
585
+
fn parse_profile(suffix: Option<&str>) -> Result<Self, ParseError> {
586
+
if suffix.is_some() {
587
+
return Err(ParseError::InvalidResource(
588
+
"profile scope does not accept suffixes".to_string(),
589
+
));
590
+
}
591
+
Ok(Scope::Profile)
592
+
}
593
+
594
+
fn parse_email(suffix: Option<&str>) -> Result<Self, ParseError> {
595
+
if suffix.is_some() {
596
+
return Err(ParseError::InvalidResource(
597
+
"email scope does not accept suffixes".to_string(),
598
+
));
599
+
}
600
+
Ok(Scope::Email)
601
+
}
602
+
603
+
/// Convert the scope to its normalized string representation
604
+
pub fn to_string_normalized(&self) -> String {
605
+
match self {
606
+
Scope::Account(scope) => {
607
+
let resource = match scope.resource {
608
+
AccountResource::Email => "email",
609
+
AccountResource::Repo => "repo",
610
+
AccountResource::Status => "status",
611
+
};
612
+
613
+
match scope.action {
614
+
AccountAction::Read => format!("account:{}", resource),
615
+
AccountAction::Manage => format!("account:{}?action=manage", resource),
616
+
}
617
+
}
618
+
Scope::Identity(scope) => match scope {
619
+
IdentityScope::Handle => "identity:handle".to_string(),
620
+
IdentityScope::All => "identity:*".to_string(),
621
+
},
622
+
Scope::Blob(scope) => {
623
+
if scope.accept.len() == 1 {
624
+
if let Some(pattern) = scope.accept.iter().next() {
625
+
match pattern {
626
+
MimePattern::All => "blob:*/*".to_string(),
627
+
MimePattern::TypeWildcard(t) => format!("blob:{}/*", t),
628
+
MimePattern::Exact(mime) => format!("blob:{}", mime),
629
+
}
630
+
} else {
631
+
"blob:*/*".to_string()
632
+
}
633
+
} else {
634
+
let mut params = Vec::new();
635
+
for pattern in &scope.accept {
636
+
match pattern {
637
+
MimePattern::All => params.push("accept=*/*".to_string()),
638
+
MimePattern::TypeWildcard(t) => params.push(format!("accept={}/*", t)),
639
+
MimePattern::Exact(mime) => params.push(format!("accept={}", mime)),
640
+
}
641
+
}
642
+
params.sort();
643
+
format!("blob?{}", params.join("&"))
644
+
}
645
+
}
646
+
Scope::Repo(scope) => {
647
+
let collection = match &scope.collection {
648
+
RepoCollection::All => "*",
649
+
RepoCollection::Nsid(nsid) => nsid,
650
+
};
651
+
652
+
if scope.actions.len() == 3 {
653
+
format!("repo:{}", collection)
654
+
} else {
655
+
let mut params = Vec::new();
656
+
for action in &scope.actions {
657
+
match action {
658
+
RepoAction::Create => params.push("action=create"),
659
+
RepoAction::Update => params.push("action=update"),
660
+
RepoAction::Delete => params.push("action=delete"),
661
+
}
662
+
}
663
+
format!("repo:{}?{}", collection, params.join("&"))
664
+
}
665
+
}
666
+
Scope::Rpc(scope) => {
667
+
if scope.lxm.len() == 1
668
+
&& scope.lxm.contains(&RpcLexicon::All)
669
+
&& scope.aud.len() == 1
670
+
&& scope.aud.contains(&RpcAudience::All)
671
+
{
672
+
"rpc:*".to_string()
673
+
} else if scope.lxm.len() == 1
674
+
&& scope.aud.len() == 1
675
+
&& scope.aud.contains(&RpcAudience::All)
676
+
{
677
+
if let Some(lxm) = scope.lxm.iter().next() {
678
+
match lxm {
679
+
RpcLexicon::All => "rpc:*".to_string(),
680
+
RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid),
681
+
}
682
+
} else {
683
+
"rpc:*".to_string()
684
+
}
685
+
} else {
686
+
let mut params = Vec::new();
687
+
688
+
for lxm in &scope.lxm {
689
+
match lxm {
690
+
RpcLexicon::All => params.push("lxm=*".to_string()),
691
+
RpcLexicon::Nsid(nsid) => params.push(format!("lxm={}", nsid)),
692
+
}
693
+
}
694
+
695
+
for aud in &scope.aud {
696
+
match aud {
697
+
RpcAudience::All => params.push("aud=*".to_string()),
698
+
RpcAudience::Did(did) => params.push(format!("aud={}", did)),
699
+
}
700
+
}
701
+
702
+
params.sort();
703
+
704
+
if params.is_empty() {
705
+
"rpc:*".to_string()
706
+
} else {
707
+
format!("rpc?{}", params.join("&"))
708
+
}
709
+
}
710
+
}
711
+
Scope::Atproto => "atproto".to_string(),
712
+
Scope::Transition(scope) => match scope {
713
+
TransitionScope::Generic => "transition:generic".to_string(),
714
+
TransitionScope::Email => "transition:email".to_string(),
715
+
},
716
+
Scope::OpenId => "openid".to_string(),
717
+
Scope::Profile => "profile".to_string(),
718
+
Scope::Email => "email".to_string(),
719
+
}
720
+
}
721
+
722
+
/// Check if this scope grants the permissions of another scope
723
+
pub fn grants(&self, other: &Scope) -> bool {
724
+
match (self, other) {
725
+
// Atproto only grants itself (it's a required scope, not a permission grant)
726
+
(Scope::Atproto, Scope::Atproto) => true,
727
+
(Scope::Atproto, _) => false,
728
+
// Nothing else grants atproto
729
+
(_, Scope::Atproto) => false,
730
+
// Transition scopes only grant themselves
731
+
(Scope::Transition(a), Scope::Transition(b)) => a == b,
732
+
// Other scopes don't grant transition scopes
733
+
(_, Scope::Transition(_)) => false,
734
+
(Scope::Transition(_), _) => false,
735
+
// OpenID Connect scopes only grant themselves
736
+
(Scope::OpenId, Scope::OpenId) => true,
737
+
(Scope::OpenId, _) => false,
738
+
(_, Scope::OpenId) => false,
739
+
(Scope::Profile, Scope::Profile) => true,
740
+
(Scope::Profile, _) => false,
741
+
(_, Scope::Profile) => false,
742
+
(Scope::Email, Scope::Email) => true,
743
+
(Scope::Email, _) => false,
744
+
(_, Scope::Email) => false,
745
+
(Scope::Account(a), Scope::Account(b)) => {
746
+
a.resource == b.resource
747
+
&& match (a.action, b.action) {
748
+
(AccountAction::Manage, _) => true,
749
+
(AccountAction::Read, AccountAction::Read) => true,
750
+
_ => false,
751
+
}
752
+
}
753
+
(Scope::Identity(a), Scope::Identity(b)) => match (a, b) {
754
+
(IdentityScope::All, _) => true,
755
+
(IdentityScope::Handle, IdentityScope::Handle) => true,
756
+
_ => false,
757
+
},
758
+
(Scope::Blob(a), Scope::Blob(b)) => {
759
+
for b_pattern in &b.accept {
760
+
let mut granted = false;
761
+
for a_pattern in &a.accept {
762
+
if a_pattern.grants(b_pattern) {
763
+
granted = true;
764
+
break;
765
+
}
766
+
}
767
+
if !granted {
768
+
return false;
769
+
}
770
+
}
771
+
true
772
+
}
773
+
(Scope::Repo(a), Scope::Repo(b)) => {
774
+
let collection_match = match (&a.collection, &b.collection) {
775
+
(RepoCollection::All, _) => true,
776
+
(RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => {
777
+
a_nsid == b_nsid
778
+
}
779
+
_ => false,
780
+
};
781
+
782
+
if !collection_match {
783
+
return false;
784
+
}
785
+
786
+
b.actions.is_subset(&a.actions) || a.actions.len() == 3
787
+
}
788
+
(Scope::Rpc(a), Scope::Rpc(b)) => {
789
+
let lxm_match = if a.lxm.contains(&RpcLexicon::All) {
790
+
true
791
+
} else {
792
+
b.lxm.iter().all(|b_lxm| match b_lxm {
793
+
RpcLexicon::All => false,
794
+
RpcLexicon::Nsid(_) => a.lxm.contains(b_lxm),
795
+
})
796
+
};
797
+
798
+
let aud_match = if a.aud.contains(&RpcAudience::All) {
799
+
true
800
+
} else {
801
+
b.aud.iter().all(|b_aud| match b_aud {
802
+
RpcAudience::All => false,
803
+
RpcAudience::Did(_) => a.aud.contains(b_aud),
804
+
})
805
+
};
806
+
807
+
lxm_match && aud_match
808
+
}
809
+
_ => false,
810
+
}
811
+
}
812
+
}
813
+
814
+
impl MimePattern {
815
+
fn grants(&self, other: &MimePattern) -> bool {
816
+
match (self, other) {
817
+
(MimePattern::All, _) => true,
818
+
(MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => {
819
+
a_type == b_type
820
+
}
821
+
(MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => {
822
+
b_mime.starts_with(&format!("{}/", a_type))
823
+
}
824
+
(MimePattern::Exact(a), MimePattern::Exact(b)) => a == b,
825
+
_ => false,
826
+
}
827
+
}
828
+
}
829
+
830
+
impl FromStr for MimePattern {
831
+
type Err = ParseError;
832
+
833
+
fn from_str(s: &str) -> Result<Self, Self::Err> {
834
+
if s == "*/*" {
835
+
Ok(MimePattern::All)
836
+
} else if s.ends_with("/*") {
837
+
Ok(MimePattern::TypeWildcard(s[..s.len() - 2].to_string()))
838
+
} else if s.contains('/') {
839
+
Ok(MimePattern::Exact(s.to_string()))
840
+
} else {
841
+
Err(ParseError::InvalidMimeType(s.to_string()))
842
+
}
843
+
}
844
+
}
845
+
846
+
impl FromStr for Scope {
847
+
type Err = ParseError;
848
+
849
+
fn from_str(s: &str) -> Result<Self, Self::Err> {
850
+
Self::parse(s)
851
+
}
852
+
}
853
+
854
+
impl fmt::Display for Scope {
855
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
856
+
write!(f, "{}", self.to_string_normalized())
857
+
}
858
+
}
859
+
860
+
/// Parse a query string into a map of keys to lists of values
861
+
fn parse_query_string(query: &str) -> BTreeMap<String, Vec<String>> {
862
+
let mut params = BTreeMap::new();
863
+
864
+
for pair in query.split('&') {
865
+
if let Some(pos) = pair.find('=') {
866
+
let key = &pair[..pos];
867
+
let value = &pair[pos + 1..];
868
+
params
869
+
.entry(key.to_string())
870
+
.or_insert_with(Vec::new)
871
+
.push(value.to_string());
872
+
}
873
+
}
874
+
875
+
params
876
+
}
877
+
878
+
/// Error type for scope parsing
879
+
#[derive(Debug, Clone, PartialEq, Eq)]
880
+
pub enum ParseError {
881
+
/// Unknown scope prefix
882
+
UnknownPrefix(String),
883
+
/// Missing required resource
884
+
MissingResource,
885
+
/// Invalid resource type
886
+
InvalidResource(String),
887
+
/// Invalid action type
888
+
InvalidAction(String),
889
+
/// Invalid MIME type
890
+
InvalidMimeType(String),
891
+
}
892
+
893
+
impl fmt::Display for ParseError {
894
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
895
+
match self {
896
+
ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix),
897
+
ParseError::MissingResource => write!(f, "Missing required resource"),
898
+
ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource),
899
+
ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action),
900
+
ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime),
901
+
}
902
+
}
903
+
}
904
+
905
+
impl std::error::Error for ParseError {}
906
+
907
+
#[cfg(test)]
908
+
mod tests {
909
+
use super::*;
910
+
911
+
#[test]
912
+
fn test_account_scope_parsing() {
913
+
let scope = Scope::parse("account:email").unwrap();
914
+
assert_eq!(
915
+
scope,
916
+
Scope::Account(AccountScope {
917
+
resource: AccountResource::Email,
918
+
action: AccountAction::Read,
919
+
})
920
+
);
921
+
922
+
let scope = Scope::parse("account:repo?action=manage").unwrap();
923
+
assert_eq!(
924
+
scope,
925
+
Scope::Account(AccountScope {
926
+
resource: AccountResource::Repo,
927
+
action: AccountAction::Manage,
928
+
})
929
+
);
930
+
931
+
let scope = Scope::parse("account:status?action=read").unwrap();
932
+
assert_eq!(
933
+
scope,
934
+
Scope::Account(AccountScope {
935
+
resource: AccountResource::Status,
936
+
action: AccountAction::Read,
937
+
})
938
+
);
939
+
}
940
+
941
+
#[test]
942
+
fn test_identity_scope_parsing() {
943
+
let scope = Scope::parse("identity:handle").unwrap();
944
+
assert_eq!(scope, Scope::Identity(IdentityScope::Handle));
945
+
946
+
let scope = Scope::parse("identity:*").unwrap();
947
+
assert_eq!(scope, Scope::Identity(IdentityScope::All));
948
+
}
949
+
950
+
#[test]
951
+
fn test_blob_scope_parsing() {
952
+
let scope = Scope::parse("blob:*/*").unwrap();
953
+
let mut accept = BTreeSet::new();
954
+
accept.insert(MimePattern::All);
955
+
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
956
+
957
+
let scope = Scope::parse("blob:image/png").unwrap();
958
+
let mut accept = BTreeSet::new();
959
+
accept.insert(MimePattern::Exact("image/png".to_string()));
960
+
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
961
+
962
+
let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap();
963
+
let mut accept = BTreeSet::new();
964
+
accept.insert(MimePattern::Exact("image/png".to_string()));
965
+
accept.insert(MimePattern::Exact("image/jpeg".to_string()));
966
+
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
967
+
968
+
let scope = Scope::parse("blob:image/*").unwrap();
969
+
let mut accept = BTreeSet::new();
970
+
accept.insert(MimePattern::TypeWildcard("image".to_string()));
971
+
assert_eq!(scope, Scope::Blob(BlobScope { accept }));
972
+
}
973
+
974
+
#[test]
975
+
fn test_repo_scope_parsing() {
976
+
let scope = Scope::parse("repo:*?action=create").unwrap();
977
+
let mut actions = BTreeSet::new();
978
+
actions.insert(RepoAction::Create);
979
+
assert_eq!(
980
+
scope,
981
+
Scope::Repo(RepoScope {
982
+
collection: RepoCollection::All,
983
+
actions,
984
+
})
985
+
);
986
+
987
+
let scope = Scope::parse("repo:foo.bar?action=create&action=update").unwrap();
988
+
let mut actions = BTreeSet::new();
989
+
actions.insert(RepoAction::Create);
990
+
actions.insert(RepoAction::Update);
991
+
assert_eq!(
992
+
scope,
993
+
Scope::Repo(RepoScope {
994
+
collection: RepoCollection::Nsid("foo.bar".to_string()),
995
+
actions,
996
+
})
997
+
);
998
+
999
+
let scope = Scope::parse("repo:foo.bar").unwrap();
1000
+
let mut actions = BTreeSet::new();
1001
+
actions.insert(RepoAction::Create);
1002
+
actions.insert(RepoAction::Update);
1003
+
actions.insert(RepoAction::Delete);
1004
+
assert_eq!(
1005
+
scope,
1006
+
Scope::Repo(RepoScope {
1007
+
collection: RepoCollection::Nsid("foo.bar".to_string()),
1008
+
actions,
1009
+
})
1010
+
);
1011
+
}
1012
+
1013
+
#[test]
1014
+
fn test_rpc_scope_parsing() {
1015
+
let scope = Scope::parse("rpc:*").unwrap();
1016
+
let mut lxm = BTreeSet::new();
1017
+
let mut aud = BTreeSet::new();
1018
+
lxm.insert(RpcLexicon::All);
1019
+
aud.insert(RpcAudience::All);
1020
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1021
+
1022
+
let scope = Scope::parse("rpc:com.example.service").unwrap();
1023
+
let mut lxm = BTreeSet::new();
1024
+
let mut aud = BTreeSet::new();
1025
+
lxm.insert(RpcLexicon::Nsid("com.example.service".to_string()));
1026
+
aud.insert(RpcAudience::All);
1027
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1028
+
1029
+
let scope = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap();
1030
+
let mut lxm = BTreeSet::new();
1031
+
let mut aud = BTreeSet::new();
1032
+
lxm.insert(RpcLexicon::Nsid("com.example.service".to_string()));
1033
+
aud.insert(RpcAudience::Did("did:example:123".to_string()));
1034
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1035
+
1036
+
let scope =
1037
+
Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:example:123")
1038
+
.unwrap();
1039
+
let mut lxm = BTreeSet::new();
1040
+
let mut aud = BTreeSet::new();
1041
+
lxm.insert(RpcLexicon::Nsid("com.example.method1".to_string()));
1042
+
lxm.insert(RpcLexicon::Nsid("com.example.method2".to_string()));
1043
+
aud.insert(RpcAudience::Did("did:example:123".to_string()));
1044
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1045
+
}
1046
+
1047
+
#[test]
1048
+
fn test_scope_normalization() {
1049
+
let tests = vec![
1050
+
("account:email", "account:email"),
1051
+
("account:email?action=read", "account:email"),
1052
+
("account:email?action=manage", "account:email?action=manage"),
1053
+
("blob:image/png", "blob:image/png"),
1054
+
(
1055
+
"blob?accept=image/jpeg&accept=image/png",
1056
+
"blob?accept=image/jpeg&accept=image/png",
1057
+
),
1058
+
("repo:foo.bar", "repo:foo.bar"),
1059
+
("repo:foo.bar?action=create", "repo:foo.bar?action=create"),
1060
+
("rpc:*", "rpc:*"),
1061
+
];
1062
+
1063
+
for (input, expected) in tests {
1064
+
let scope = Scope::parse(input).unwrap();
1065
+
assert_eq!(scope.to_string_normalized(), expected);
1066
+
}
1067
+
}
1068
+
1069
+
#[test]
1070
+
fn test_account_scope_grants() {
1071
+
let manage = Scope::parse("account:email?action=manage").unwrap();
1072
+
let read = Scope::parse("account:email?action=read").unwrap();
1073
+
let other_read = Scope::parse("account:repo?action=read").unwrap();
1074
+
1075
+
assert!(manage.grants(&read));
1076
+
assert!(manage.grants(&manage));
1077
+
assert!(!read.grants(&manage));
1078
+
assert!(read.grants(&read));
1079
+
assert!(!read.grants(&other_read));
1080
+
}
1081
+
1082
+
#[test]
1083
+
fn test_identity_scope_grants() {
1084
+
let all = Scope::parse("identity:*").unwrap();
1085
+
let handle = Scope::parse("identity:handle").unwrap();
1086
+
1087
+
assert!(all.grants(&handle));
1088
+
assert!(all.grants(&all));
1089
+
assert!(!handle.grants(&all));
1090
+
assert!(handle.grants(&handle));
1091
+
}
1092
+
1093
+
#[test]
1094
+
fn test_blob_scope_grants() {
1095
+
let all = Scope::parse("blob:*/*").unwrap();
1096
+
let image_all = Scope::parse("blob:image/*").unwrap();
1097
+
let image_png = Scope::parse("blob:image/png").unwrap();
1098
+
let text_plain = Scope::parse("blob:text/plain").unwrap();
1099
+
1100
+
assert!(all.grants(&image_all));
1101
+
assert!(all.grants(&image_png));
1102
+
assert!(all.grants(&text_plain));
1103
+
assert!(image_all.grants(&image_png));
1104
+
assert!(!image_all.grants(&text_plain));
1105
+
assert!(!image_png.grants(&image_all));
1106
+
}
1107
+
1108
+
#[test]
1109
+
fn test_repo_scope_grants() {
1110
+
let all_all = Scope::parse("repo:*").unwrap();
1111
+
let all_create = Scope::parse("repo:*?action=create").unwrap();
1112
+
let specific_all = Scope::parse("repo:foo.bar").unwrap();
1113
+
let specific_create = Scope::parse("repo:foo.bar?action=create").unwrap();
1114
+
let other_create = Scope::parse("repo:baz.qux?action=create").unwrap();
1115
+
1116
+
assert!(all_all.grants(&all_create));
1117
+
assert!(all_all.grants(&specific_all));
1118
+
assert!(all_all.grants(&specific_create));
1119
+
assert!(all_create.grants(&all_create));
1120
+
assert!(!all_create.grants(&specific_all));
1121
+
assert!(specific_all.grants(&specific_create));
1122
+
assert!(!specific_create.grants(&specific_all));
1123
+
assert!(!specific_create.grants(&other_create));
1124
+
}
1125
+
1126
+
#[test]
1127
+
fn test_rpc_scope_grants() {
1128
+
let all = Scope::parse("rpc:*").unwrap();
1129
+
let specific_lxm = Scope::parse("rpc:com.example.service").unwrap();
1130
+
let specific_both = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap();
1131
+
1132
+
assert!(all.grants(&specific_lxm));
1133
+
assert!(all.grants(&specific_both));
1134
+
assert!(specific_lxm.grants(&specific_both));
1135
+
assert!(!specific_both.grants(&specific_lxm));
1136
+
assert!(!specific_both.grants(&all));
1137
+
}
1138
+
1139
+
#[test]
1140
+
fn test_cross_scope_grants() {
1141
+
let account = Scope::parse("account:email").unwrap();
1142
+
let identity = Scope::parse("identity:handle").unwrap();
1143
+
1144
+
assert!(!account.grants(&identity));
1145
+
assert!(!identity.grants(&account));
1146
+
}
1147
+
1148
+
#[test]
1149
+
fn test_parse_errors() {
1150
+
assert!(matches!(
1151
+
Scope::parse("unknown:test"),
1152
+
Err(ParseError::UnknownPrefix(_))
1153
+
));
1154
+
1155
+
assert!(matches!(
1156
+
Scope::parse("account"),
1157
+
Err(ParseError::MissingResource)
1158
+
));
1159
+
1160
+
assert!(matches!(
1161
+
Scope::parse("account:invalid"),
1162
+
Err(ParseError::InvalidResource(_))
1163
+
));
1164
+
1165
+
assert!(matches!(
1166
+
Scope::parse("account:email?action=invalid"),
1167
+
Err(ParseError::InvalidAction(_))
1168
+
));
1169
+
}
1170
+
1171
+
#[test]
1172
+
fn test_query_parameter_sorting() {
1173
+
let scope =
1174
+
Scope::parse("blob?accept=image/png&accept=application/pdf&accept=image/jpeg").unwrap();
1175
+
let normalized = scope.to_string_normalized();
1176
+
assert!(normalized.contains("accept=application/pdf"));
1177
+
assert!(normalized.contains("accept=image/jpeg"));
1178
+
assert!(normalized.contains("accept=image/png"));
1179
+
let pdf_pos = normalized.find("accept=application/pdf").unwrap();
1180
+
let jpeg_pos = normalized.find("accept=image/jpeg").unwrap();
1181
+
let png_pos = normalized.find("accept=image/png").unwrap();
1182
+
assert!(pdf_pos < jpeg_pos);
1183
+
assert!(jpeg_pos < png_pos);
1184
+
}
1185
+
1186
+
#[test]
1187
+
fn test_repo_action_wildcard() {
1188
+
let scope = Scope::parse("repo:foo.bar?action=*").unwrap();
1189
+
let mut actions = BTreeSet::new();
1190
+
actions.insert(RepoAction::Create);
1191
+
actions.insert(RepoAction::Update);
1192
+
actions.insert(RepoAction::Delete);
1193
+
assert_eq!(
1194
+
scope,
1195
+
Scope::Repo(RepoScope {
1196
+
collection: RepoCollection::Nsid("foo.bar".to_string()),
1197
+
actions,
1198
+
})
1199
+
);
1200
+
}
1201
+
1202
+
#[test]
1203
+
fn test_multiple_blob_accepts() {
1204
+
let scope = Scope::parse("blob?accept=image/*&accept=text/plain").unwrap();
1205
+
assert!(scope.grants(&Scope::parse("blob:image/png").unwrap()));
1206
+
assert!(scope.grants(&Scope::parse("blob:text/plain").unwrap()));
1207
+
assert!(!scope.grants(&Scope::parse("blob:application/json").unwrap()));
1208
+
}
1209
+
1210
+
#[test]
1211
+
fn test_rpc_default_wildcards() {
1212
+
let scope = Scope::parse("rpc").unwrap();
1213
+
let mut lxm = BTreeSet::new();
1214
+
let mut aud = BTreeSet::new();
1215
+
lxm.insert(RpcLexicon::All);
1216
+
aud.insert(RpcAudience::All);
1217
+
assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud }));
1218
+
}
1219
+
1220
+
#[test]
1221
+
fn test_atproto_scope_parsing() {
1222
+
let scope = Scope::parse("atproto").unwrap();
1223
+
assert_eq!(scope, Scope::Atproto);
1224
+
1225
+
// Atproto should not accept suffixes
1226
+
assert!(Scope::parse("atproto:something").is_err());
1227
+
assert!(Scope::parse("atproto?param=value").is_err());
1228
+
}
1229
+
1230
+
#[test]
1231
+
fn test_transition_scope_parsing() {
1232
+
let scope = Scope::parse("transition:generic").unwrap();
1233
+
assert_eq!(scope, Scope::Transition(TransitionScope::Generic));
1234
+
1235
+
let scope = Scope::parse("transition:email").unwrap();
1236
+
assert_eq!(scope, Scope::Transition(TransitionScope::Email));
1237
+
1238
+
// Test invalid transition types
1239
+
assert!(matches!(
1240
+
Scope::parse("transition:invalid"),
1241
+
Err(ParseError::InvalidResource(_))
1242
+
));
1243
+
1244
+
// Test missing suffix
1245
+
assert!(matches!(
1246
+
Scope::parse("transition"),
1247
+
Err(ParseError::MissingResource)
1248
+
));
1249
+
1250
+
// Test transition doesn't accept query parameters
1251
+
assert!(matches!(
1252
+
Scope::parse("transition:generic?param=value"),
1253
+
Err(ParseError::InvalidResource(_))
1254
+
));
1255
+
}
1256
+
1257
+
#[test]
1258
+
fn test_atproto_scope_normalization() {
1259
+
let scope = Scope::parse("atproto").unwrap();
1260
+
assert_eq!(scope.to_string_normalized(), "atproto");
1261
+
}
1262
+
1263
+
#[test]
1264
+
fn test_transition_scope_normalization() {
1265
+
let tests = vec![
1266
+
("transition:generic", "transition:generic"),
1267
+
("transition:email", "transition:email"),
1268
+
];
1269
+
1270
+
for (input, expected) in tests {
1271
+
let scope = Scope::parse(input).unwrap();
1272
+
assert_eq!(scope.to_string_normalized(), expected);
1273
+
}
1274
+
}
1275
+
1276
+
#[test]
1277
+
fn test_atproto_scope_grants() {
1278
+
let atproto = Scope::parse("atproto").unwrap();
1279
+
let account = Scope::parse("account:email").unwrap();
1280
+
let identity = Scope::parse("identity:handle").unwrap();
1281
+
let blob = Scope::parse("blob:image/png").unwrap();
1282
+
let repo = Scope::parse("repo:foo.bar").unwrap();
1283
+
let rpc = Scope::parse("rpc:com.example.service").unwrap();
1284
+
let transition_generic = Scope::parse("transition:generic").unwrap();
1285
+
let transition_email = Scope::parse("transition:email").unwrap();
1286
+
1287
+
// Atproto only grants itself (it's a required scope, not a permission grant)
1288
+
assert!(atproto.grants(&atproto));
1289
+
assert!(!atproto.grants(&account));
1290
+
assert!(!atproto.grants(&identity));
1291
+
assert!(!atproto.grants(&blob));
1292
+
assert!(!atproto.grants(&repo));
1293
+
assert!(!atproto.grants(&rpc));
1294
+
assert!(!atproto.grants(&transition_generic));
1295
+
assert!(!atproto.grants(&transition_email));
1296
+
1297
+
// Nothing else grants atproto
1298
+
assert!(!account.grants(&atproto));
1299
+
assert!(!identity.grants(&atproto));
1300
+
assert!(!blob.grants(&atproto));
1301
+
assert!(!repo.grants(&atproto));
1302
+
assert!(!rpc.grants(&atproto));
1303
+
assert!(!transition_generic.grants(&atproto));
1304
+
assert!(!transition_email.grants(&atproto));
1305
+
}
1306
+
1307
+
#[test]
1308
+
fn test_transition_scope_grants() {
1309
+
let transition_generic = Scope::parse("transition:generic").unwrap();
1310
+
let transition_email = Scope::parse("transition:email").unwrap();
1311
+
let account = Scope::parse("account:email").unwrap();
1312
+
1313
+
// Transition scopes only grant themselves
1314
+
assert!(transition_generic.grants(&transition_generic));
1315
+
assert!(transition_email.grants(&transition_email));
1316
+
assert!(!transition_generic.grants(&transition_email));
1317
+
assert!(!transition_email.grants(&transition_generic));
1318
+
1319
+
// Transition scopes don't grant other scope types
1320
+
assert!(!transition_generic.grants(&account));
1321
+
assert!(!transition_email.grants(&account));
1322
+
1323
+
// Other scopes don't grant transition scopes
1324
+
assert!(!account.grants(&transition_generic));
1325
+
assert!(!account.grants(&transition_email));
1326
+
}
1327
+
1328
+
#[test]
1329
+
fn test_parse_multiple() {
1330
+
// Test parsing multiple scopes
1331
+
let scopes = Scope::parse_multiple("atproto repo:*").unwrap();
1332
+
assert_eq!(scopes.len(), 2);
1333
+
assert_eq!(scopes[0], Scope::Atproto);
1334
+
assert_eq!(
1335
+
scopes[1],
1336
+
Scope::Repo(RepoScope {
1337
+
collection: RepoCollection::All,
1338
+
actions: {
1339
+
let mut actions = BTreeSet::new();
1340
+
actions.insert(RepoAction::Create);
1341
+
actions.insert(RepoAction::Update);
1342
+
actions.insert(RepoAction::Delete);
1343
+
actions
1344
+
}
1345
+
})
1346
+
);
1347
+
1348
+
// Test with more scopes
1349
+
let scopes = Scope::parse_multiple("account:email identity:handle blob:image/png").unwrap();
1350
+
assert_eq!(scopes.len(), 3);
1351
+
assert!(matches!(scopes[0], Scope::Account(_)));
1352
+
assert!(matches!(scopes[1], Scope::Identity(_)));
1353
+
assert!(matches!(scopes[2], Scope::Blob(_)));
1354
+
1355
+
// Test with complex scopes
1356
+
let scopes = Scope::parse_multiple(
1357
+
"account:email?action=manage repo:foo.bar?action=create transition:email",
1358
+
)
1359
+
.unwrap();
1360
+
assert_eq!(scopes.len(), 3);
1361
+
1362
+
// Test empty string
1363
+
let scopes = Scope::parse_multiple("").unwrap();
1364
+
assert_eq!(scopes.len(), 0);
1365
+
1366
+
// Test whitespace only
1367
+
let scopes = Scope::parse_multiple(" ").unwrap();
1368
+
assert_eq!(scopes.len(), 0);
1369
+
1370
+
// Test with extra whitespace
1371
+
let scopes = Scope::parse_multiple(" atproto repo:* ").unwrap();
1372
+
assert_eq!(scopes.len(), 2);
1373
+
1374
+
// Test single scope
1375
+
let scopes = Scope::parse_multiple("atproto").unwrap();
1376
+
assert_eq!(scopes.len(), 1);
1377
+
assert_eq!(scopes[0], Scope::Atproto);
1378
+
1379
+
// Test error propagation
1380
+
assert!(Scope::parse_multiple("atproto invalid:scope").is_err());
1381
+
assert!(Scope::parse_multiple("account:invalid repo:*").is_err());
1382
+
}
1383
+
1384
+
#[test]
1385
+
fn test_parse_multiple_reduced() {
1386
+
// Test repo scope reduction - wildcard grants specific
1387
+
let scopes = Scope::parse_multiple_reduced("atproto repo:foo.bar repo:*").unwrap();
1388
+
assert_eq!(scopes.len(), 2);
1389
+
assert!(scopes.contains(&Scope::Atproto));
1390
+
assert!(scopes.contains(&Scope::Repo(RepoScope {
1391
+
collection: RepoCollection::All,
1392
+
actions: {
1393
+
let mut actions = BTreeSet::new();
1394
+
actions.insert(RepoAction::Create);
1395
+
actions.insert(RepoAction::Update);
1396
+
actions.insert(RepoAction::Delete);
1397
+
actions
1398
+
}
1399
+
})));
1400
+
1401
+
// Test reverse order - should get same result
1402
+
let scopes = Scope::parse_multiple_reduced("atproto repo:* repo:foo.bar").unwrap();
1403
+
assert_eq!(scopes.len(), 2);
1404
+
assert!(scopes.contains(&Scope::Atproto));
1405
+
assert!(scopes.contains(&Scope::Repo(RepoScope {
1406
+
collection: RepoCollection::All,
1407
+
actions: {
1408
+
let mut actions = BTreeSet::new();
1409
+
actions.insert(RepoAction::Create);
1410
+
actions.insert(RepoAction::Update);
1411
+
actions.insert(RepoAction::Delete);
1412
+
actions
1413
+
}
1414
+
})));
1415
+
1416
+
// Test account scope reduction - manage grants read
1417
+
let scopes =
1418
+
Scope::parse_multiple_reduced("account:email account:email?action=manage").unwrap();
1419
+
assert_eq!(scopes.len(), 1);
1420
+
assert_eq!(
1421
+
scopes[0],
1422
+
Scope::Account(AccountScope {
1423
+
resource: AccountResource::Email,
1424
+
action: AccountAction::Manage,
1425
+
})
1426
+
);
1427
+
1428
+
// Test identity scope reduction - wildcard grants specific
1429
+
let scopes = Scope::parse_multiple_reduced("identity:handle identity:*").unwrap();
1430
+
assert_eq!(scopes.len(), 1);
1431
+
assert_eq!(scopes[0], Scope::Identity(IdentityScope::All));
1432
+
1433
+
// Test blob scope reduction - wildcard grants specific
1434
+
let scopes = Scope::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*").unwrap();
1435
+
assert_eq!(scopes.len(), 1);
1436
+
let mut accept = BTreeSet::new();
1437
+
accept.insert(MimePattern::All);
1438
+
assert_eq!(scopes[0], Scope::Blob(BlobScope { accept }));
1439
+
1440
+
// Test no reduction needed - different scope types
1441
+
let scopes =
1442
+
Scope::parse_multiple_reduced("account:email identity:handle blob:image/png").unwrap();
1443
+
assert_eq!(scopes.len(), 3);
1444
+
1445
+
// Test repo action reduction
1446
+
let scopes =
1447
+
Scope::parse_multiple_reduced("repo:foo.bar?action=create repo:foo.bar").unwrap();
1448
+
assert_eq!(scopes.len(), 1);
1449
+
assert_eq!(
1450
+
scopes[0],
1451
+
Scope::Repo(RepoScope {
1452
+
collection: RepoCollection::Nsid("foo.bar".to_string()),
1453
+
actions: {
1454
+
let mut actions = BTreeSet::new();
1455
+
actions.insert(RepoAction::Create);
1456
+
actions.insert(RepoAction::Update);
1457
+
actions.insert(RepoAction::Delete);
1458
+
actions
1459
+
}
1460
+
})
1461
+
);
1462
+
1463
+
// Test RPC scope reduction
1464
+
let scopes = Scope::parse_multiple_reduced(
1465
+
"rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*",
1466
+
)
1467
+
.unwrap();
1468
+
assert_eq!(scopes.len(), 1);
1469
+
assert_eq!(
1470
+
scopes[0],
1471
+
Scope::Rpc(RpcScope {
1472
+
lxm: {
1473
+
let mut lxm = BTreeSet::new();
1474
+
lxm.insert(RpcLexicon::All);
1475
+
lxm
1476
+
},
1477
+
aud: {
1478
+
let mut aud = BTreeSet::new();
1479
+
aud.insert(RpcAudience::All);
1480
+
aud
1481
+
}
1482
+
})
1483
+
);
1484
+
1485
+
// Test duplicate removal
1486
+
let scopes = Scope::parse_multiple_reduced("atproto atproto atproto").unwrap();
1487
+
assert_eq!(scopes.len(), 1);
1488
+
assert_eq!(scopes[0], Scope::Atproto);
1489
+
1490
+
// Test transition scopes - only grant themselves
1491
+
let scopes = Scope::parse_multiple_reduced("transition:generic transition:email").unwrap();
1492
+
assert_eq!(scopes.len(), 2);
1493
+
assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic)));
1494
+
assert!(scopes.contains(&Scope::Transition(TransitionScope::Email)));
1495
+
1496
+
// Test empty input
1497
+
let scopes = Scope::parse_multiple_reduced("").unwrap();
1498
+
assert_eq!(scopes.len(), 0);
1499
+
1500
+
// Test complex scenario with multiple reductions
1501
+
let scopes = Scope::parse_multiple_reduced(
1502
+
"account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle"
1503
+
).unwrap();
1504
+
assert_eq!(scopes.len(), 3);
1505
+
// Should have: account:email?action=manage, account:repo, identity:*
1506
+
assert!(scopes.contains(&Scope::Account(AccountScope {
1507
+
resource: AccountResource::Email,
1508
+
action: AccountAction::Manage,
1509
+
})));
1510
+
assert!(scopes.contains(&Scope::Account(AccountScope {
1511
+
resource: AccountResource::Repo,
1512
+
action: AccountAction::Read,
1513
+
})));
1514
+
assert!(scopes.contains(&Scope::Identity(IdentityScope::All)));
1515
+
1516
+
// Test that atproto doesn't grant other scopes (per recent change)
1517
+
let scopes = Scope::parse_multiple_reduced("atproto account:email repo:*").unwrap();
1518
+
assert_eq!(scopes.len(), 3);
1519
+
assert!(scopes.contains(&Scope::Atproto));
1520
+
assert!(scopes.contains(&Scope::Account(AccountScope {
1521
+
resource: AccountResource::Email,
1522
+
action: AccountAction::Read,
1523
+
})));
1524
+
assert!(scopes.contains(&Scope::Repo(RepoScope {
1525
+
collection: RepoCollection::All,
1526
+
actions: {
1527
+
let mut actions = BTreeSet::new();
1528
+
actions.insert(RepoAction::Create);
1529
+
actions.insert(RepoAction::Update);
1530
+
actions.insert(RepoAction::Delete);
1531
+
actions
1532
+
}
1533
+
})));
1534
+
}
1535
+
1536
+
#[test]
1537
+
fn test_openid_connect_scope_parsing() {
1538
+
// Test OpenID scope
1539
+
let scope = Scope::parse("openid").unwrap();
1540
+
assert_eq!(scope, Scope::OpenId);
1541
+
1542
+
// Test Profile scope
1543
+
let scope = Scope::parse("profile").unwrap();
1544
+
assert_eq!(scope, Scope::Profile);
1545
+
1546
+
// Test Email scope
1547
+
let scope = Scope::parse("email").unwrap();
1548
+
assert_eq!(scope, Scope::Email);
1549
+
1550
+
// Test that they don't accept suffixes
1551
+
assert!(Scope::parse("openid:something").is_err());
1552
+
assert!(Scope::parse("profile:something").is_err());
1553
+
assert!(Scope::parse("email:something").is_err());
1554
+
1555
+
// Test that they don't accept query parameters
1556
+
assert!(Scope::parse("openid?param=value").is_err());
1557
+
assert!(Scope::parse("profile?param=value").is_err());
1558
+
assert!(Scope::parse("email?param=value").is_err());
1559
+
}
1560
+
1561
+
#[test]
1562
+
fn test_openid_connect_scope_normalization() {
1563
+
let scope = Scope::parse("openid").unwrap();
1564
+
assert_eq!(scope.to_string_normalized(), "openid");
1565
+
1566
+
let scope = Scope::parse("profile").unwrap();
1567
+
assert_eq!(scope.to_string_normalized(), "profile");
1568
+
1569
+
let scope = Scope::parse("email").unwrap();
1570
+
assert_eq!(scope.to_string_normalized(), "email");
1571
+
}
1572
+
1573
+
#[test]
1574
+
fn test_openid_connect_scope_grants() {
1575
+
let openid = Scope::parse("openid").unwrap();
1576
+
let profile = Scope::parse("profile").unwrap();
1577
+
let email = Scope::parse("email").unwrap();
1578
+
let account = Scope::parse("account:email").unwrap();
1579
+
1580
+
// OpenID Connect scopes only grant themselves
1581
+
assert!(openid.grants(&openid));
1582
+
assert!(!openid.grants(&profile));
1583
+
assert!(!openid.grants(&email));
1584
+
assert!(!openid.grants(&account));
1585
+
1586
+
assert!(profile.grants(&profile));
1587
+
assert!(!profile.grants(&openid));
1588
+
assert!(!profile.grants(&email));
1589
+
assert!(!profile.grants(&account));
1590
+
1591
+
assert!(email.grants(&email));
1592
+
assert!(!email.grants(&openid));
1593
+
assert!(!email.grants(&profile));
1594
+
assert!(!email.grants(&account));
1595
+
1596
+
// Other scopes don't grant OpenID Connect scopes
1597
+
assert!(!account.grants(&openid));
1598
+
assert!(!account.grants(&profile));
1599
+
assert!(!account.grants(&email));
1600
+
}
1601
+
1602
+
#[test]
1603
+
fn test_parse_multiple_with_openid_connect() {
1604
+
let scopes = Scope::parse_multiple("openid profile email atproto").unwrap();
1605
+
assert_eq!(scopes.len(), 4);
1606
+
assert_eq!(scopes[0], Scope::OpenId);
1607
+
assert_eq!(scopes[1], Scope::Profile);
1608
+
assert_eq!(scopes[2], Scope::Email);
1609
+
assert_eq!(scopes[3], Scope::Atproto);
1610
+
1611
+
// Test with mixed scopes
1612
+
let scopes = Scope::parse_multiple("openid account:email profile repo:*").unwrap();
1613
+
assert_eq!(scopes.len(), 4);
1614
+
assert!(scopes.contains(&Scope::OpenId));
1615
+
assert!(scopes.contains(&Scope::Profile));
1616
+
}
1617
+
1618
+
#[test]
1619
+
fn test_parse_multiple_reduced_with_openid_connect() {
1620
+
// OpenID Connect scopes don't grant each other, so no reduction
1621
+
let scopes = Scope::parse_multiple_reduced("openid profile email openid").unwrap();
1622
+
assert_eq!(scopes.len(), 3);
1623
+
assert!(scopes.contains(&Scope::OpenId));
1624
+
assert!(scopes.contains(&Scope::Profile));
1625
+
assert!(scopes.contains(&Scope::Email));
1626
+
1627
+
// Mixed with other scopes
1628
+
let scopes = Scope::parse_multiple_reduced(
1629
+
"openid account:email account:email?action=manage profile",
1630
+
)
1631
+
.unwrap();
1632
+
assert_eq!(scopes.len(), 3);
1633
+
assert!(scopes.contains(&Scope::OpenId));
1634
+
assert!(scopes.contains(&Scope::Profile));
1635
+
assert!(scopes.contains(&Scope::Account(AccountScope {
1636
+
resource: AccountResource::Email,
1637
+
action: AccountAction::Manage,
1638
+
})));
1639
+
}
1640
+
1641
+
#[test]
1642
+
fn test_serialize_multiple() {
1643
+
// Test empty list
1644
+
let scopes: Vec<Scope> = vec![];
1645
+
assert_eq!(Scope::serialize_multiple(&scopes), "");
1646
+
1647
+
// Test single scope
1648
+
let scopes = vec![Scope::Atproto];
1649
+
assert_eq!(Scope::serialize_multiple(&scopes), "atproto");
1650
+
1651
+
// Test multiple scopes - should be sorted alphabetically
1652
+
let scopes = vec![
1653
+
Scope::parse("repo:*").unwrap(),
1654
+
Scope::Atproto,
1655
+
Scope::parse("account:email").unwrap(),
1656
+
];
1657
+
assert_eq!(
1658
+
Scope::serialize_multiple(&scopes),
1659
+
"account:email atproto repo:*"
1660
+
);
1661
+
1662
+
// Test that sorting is consistent regardless of input order
1663
+
let scopes = vec![
1664
+
Scope::parse("identity:handle").unwrap(),
1665
+
Scope::parse("blob:image/png").unwrap(),
1666
+
Scope::parse("account:repo?action=manage").unwrap(),
1667
+
];
1668
+
assert_eq!(
1669
+
Scope::serialize_multiple(&scopes),
1670
+
"account:repo?action=manage blob:image/png identity:handle"
1671
+
);
1672
+
1673
+
// Test with OpenID Connect scopes
1674
+
let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto];
1675
+
assert_eq!(
1676
+
Scope::serialize_multiple(&scopes),
1677
+
"atproto email openid profile"
1678
+
);
1679
+
1680
+
// Test with complex scopes including query parameters
1681
+
let scopes = vec![
1682
+
Scope::parse("rpc:com.example.service?aud=did:example:123&lxm=com.example.method")
1683
+
.unwrap(),
1684
+
Scope::parse("repo:foo.bar?action=create&action=update").unwrap(),
1685
+
Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(),
1686
+
];
1687
+
let result = Scope::serialize_multiple(&scopes);
1688
+
// The result should be sorted alphabetically
1689
+
// Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..."
1690
+
assert!(result.starts_with("blob:"));
1691
+
assert!(result.contains(" repo:"));
1692
+
assert!(result.contains("rpc?aud=did:example:123&lxm=com.example.service"));
1693
+
1694
+
// Test with transition scopes
1695
+
let scopes = vec![
1696
+
Scope::Transition(TransitionScope::Email),
1697
+
Scope::Transition(TransitionScope::Generic),
1698
+
Scope::Atproto,
1699
+
];
1700
+
assert_eq!(
1701
+
Scope::serialize_multiple(&scopes),
1702
+
"atproto transition:email transition:generic"
1703
+
);
1704
+
1705
+
// Test duplicates - they remain in the output (caller's responsibility to dedupe if needed)
1706
+
let scopes = vec![
1707
+
Scope::Atproto,
1708
+
Scope::Atproto,
1709
+
Scope::parse("account:email").unwrap(),
1710
+
];
1711
+
assert_eq!(
1712
+
Scope::serialize_multiple(&scopes),
1713
+
"account:email atproto atproto"
1714
+
);
1715
+
1716
+
// Test normalization is preserved in serialization
1717
+
let scopes = vec![Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap()];
1718
+
// Should normalize query parameters alphabetically
1719
+
assert_eq!(
1720
+
Scope::serialize_multiple(&scopes),
1721
+
"blob?accept=image/jpeg&accept=image/png"
1722
+
);
1723
+
}
1724
+
1725
+
#[test]
1726
+
fn test_serialize_multiple_roundtrip() {
1727
+
// Test that parse_multiple and serialize_multiple are inverses (when sorted)
1728
+
let original = "account:email atproto blob:image/png identity:handle repo:*";
1729
+
let scopes = Scope::parse_multiple(original).unwrap();
1730
+
let serialized = Scope::serialize_multiple(&scopes);
1731
+
assert_eq!(serialized, original);
1732
+
1733
+
// Test with complex scopes
1734
+
let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*";
1735
+
let scopes = Scope::parse_multiple(original).unwrap();
1736
+
let serialized = Scope::serialize_multiple(&scopes);
1737
+
// Parse again to verify it's valid
1738
+
let reparsed = Scope::parse_multiple(&serialized).unwrap();
1739
+
assert_eq!(scopes, reparsed);
1740
+
1741
+
// Test with OpenID Connect scopes
1742
+
let original = "email openid profile";
1743
+
let scopes = Scope::parse_multiple(original).unwrap();
1744
+
let serialized = Scope::serialize_multiple(&scopes);
1745
+
assert_eq!(serialized, original);
1746
+
}
1747
+
1748
+
#[test]
1749
+
fn test_remove_scope() {
1750
+
// Test removing a scope that exists
1751
+
let scopes = vec![
1752
+
Scope::parse("repo:*").unwrap(),
1753
+
Scope::Atproto,
1754
+
Scope::parse("account:email").unwrap(),
1755
+
];
1756
+
let to_remove = Scope::Atproto;
1757
+
let result = Scope::remove_scope(&scopes, &to_remove);
1758
+
assert_eq!(result.len(), 2);
1759
+
assert!(!result.contains(&to_remove));
1760
+
assert!(result.contains(&Scope::parse("repo:*").unwrap()));
1761
+
assert!(result.contains(&Scope::parse("account:email").unwrap()));
1762
+
1763
+
// Test removing a scope that doesn't exist
1764
+
let scopes = vec![
1765
+
Scope::parse("repo:*").unwrap(),
1766
+
Scope::parse("account:email").unwrap(),
1767
+
];
1768
+
let to_remove = Scope::parse("identity:handle").unwrap();
1769
+
let result = Scope::remove_scope(&scopes, &to_remove);
1770
+
assert_eq!(result.len(), 2);
1771
+
assert_eq!(result, scopes);
1772
+
1773
+
// Test removing from empty list
1774
+
let scopes: Vec<Scope> = vec![];
1775
+
let to_remove = Scope::Atproto;
1776
+
let result = Scope::remove_scope(&scopes, &to_remove);
1777
+
assert_eq!(result.len(), 0);
1778
+
1779
+
// Test removing all instances of a duplicate scope
1780
+
let scopes = vec![
1781
+
Scope::Atproto,
1782
+
Scope::parse("account:email").unwrap(),
1783
+
Scope::Atproto,
1784
+
Scope::parse("repo:*").unwrap(),
1785
+
Scope::Atproto,
1786
+
];
1787
+
let to_remove = Scope::Atproto;
1788
+
let result = Scope::remove_scope(&scopes, &to_remove);
1789
+
assert_eq!(result.len(), 2);
1790
+
assert!(!result.contains(&to_remove));
1791
+
assert!(result.contains(&Scope::parse("account:email").unwrap()));
1792
+
assert!(result.contains(&Scope::parse("repo:*").unwrap()));
1793
+
1794
+
// Test removing complex scopes with query parameters
1795
+
let scopes = vec![
1796
+
Scope::parse("account:email?action=manage").unwrap(),
1797
+
Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(),
1798
+
Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(),
1799
+
];
1800
+
let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); // Note: normalized order
1801
+
let result = Scope::remove_scope(&scopes, &to_remove);
1802
+
assert_eq!(result.len(), 2);
1803
+
assert!(!result.contains(&to_remove));
1804
+
1805
+
// Test with OpenID Connect scopes
1806
+
let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto];
1807
+
let to_remove = Scope::Profile;
1808
+
let result = Scope::remove_scope(&scopes, &to_remove);
1809
+
assert_eq!(result.len(), 3);
1810
+
assert!(!result.contains(&to_remove));
1811
+
assert!(result.contains(&Scope::OpenId));
1812
+
assert!(result.contains(&Scope::Email));
1813
+
assert!(result.contains(&Scope::Atproto));
1814
+
1815
+
// Test with transition scopes
1816
+
let scopes = vec![
1817
+
Scope::Transition(TransitionScope::Generic),
1818
+
Scope::Transition(TransitionScope::Email),
1819
+
Scope::Atproto,
1820
+
];
1821
+
let to_remove = Scope::Transition(TransitionScope::Email);
1822
+
let result = Scope::remove_scope(&scopes, &to_remove);
1823
+
assert_eq!(result.len(), 2);
1824
+
assert!(!result.contains(&to_remove));
1825
+
assert!(result.contains(&Scope::Transition(TransitionScope::Generic)));
1826
+
assert!(result.contains(&Scope::Atproto));
1827
+
1828
+
// Test that only exact matches are removed
1829
+
let scopes = vec![
1830
+
Scope::parse("account:email").unwrap(),
1831
+
Scope::parse("account:email?action=manage").unwrap(),
1832
+
Scope::parse("account:repo").unwrap(),
1833
+
];
1834
+
let to_remove = Scope::parse("account:email").unwrap();
1835
+
let result = Scope::remove_scope(&scopes, &to_remove);
1836
+
assert_eq!(result.len(), 2);
1837
+
assert!(!result.contains(&Scope::parse("account:email").unwrap()));
1838
+
assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap()));
1839
+
assert!(result.contains(&Scope::parse("account:repo").unwrap()));
1840
+
}
1841
+
}
+5
-2
crates/atproto-record/src/bytes.rs
+5
-2
crates/atproto-record/src/bytes.rs
···
35
35
use serde::{Deserialize, Serialize};
36
36
use serde::{Deserializer, Serializer};
37
37
38
-
use base64::{Engine, engine::general_purpose::{STANDARD, STANDARD_NO_PAD}};
38
+
use base64::{
39
+
Engine,
40
+
engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
41
+
};
39
42
40
43
/// Serializes a byte vector to a base64 encoded string.
41
44
pub fn serialize<S: Serializer>(value: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error> {
···
47
50
/// Handles both padded and unpadded base64 strings for compatibility.
48
51
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
49
52
let encoded_value = String::deserialize(d)?;
50
-
53
+
51
54
// Try standard base64 with padding first
52
55
STANDARD
53
56
.decode(&encoded_value)
+9
-7
crates/atproto-record/src/lexicon/community_lexicon_attestation.rs
+9
-7
crates/atproto-record/src/lexicon/community_lexicon_attestation.rs
···
167
167
eprintln!("TypedSignature deserialization error: {}", e);
168
168
}
169
169
}
170
-
170
+
171
171
// Then try as SignatureOrRef
172
172
let sig_or_ref_result: Result<SignatureOrRef, _> = serde_json::from_str(json_str);
173
173
match &sig_or_ref_result {
···
182
182
eprintln!("SignatureOrRef deserialization error: {}", e);
183
183
}
184
184
}
185
-
185
+
186
186
// Try without $type field
187
187
let json_no_type = r#"{
188
188
"issuedAt": "2025-08-19T20:17:17.133Z",
···
191
191
"$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA"
192
192
}
193
193
}"#;
194
-
194
+
195
195
let no_type_result: Result<Signature, _> = serde_json::from_str(json_no_type);
196
196
match &no_type_result {
197
197
Ok(sig) => {
198
198
println!("Signature (no type) OK: issuer={}", sig.issuer);
199
199
assert_eq!(sig.issuer, "did:web:acudo-dev.smokesignal.tools");
200
200
assert_eq!(sig.signature.bytes.len(), 64);
201
-
201
+
202
202
// Now wrap it in TypedLexicon and try as SignatureOrRef
203
203
let typed = TypedLexicon::new(sig.clone());
204
204
let _as_sig_or_ref = SignatureOrRef::Inline(typed);
···
208
208
eprintln!("Signature (no type) deserialization error: {}", e);
209
209
}
210
210
}
211
-
211
+
212
212
// Check that at least one worked
213
-
assert!(typed_sig_result.is_ok() || sig_or_ref_result.is_ok() || no_type_result.is_ok(),
214
-
"Failed to deserialize signature in any form");
213
+
assert!(
214
+
typed_sig_result.is_ok() || sig_or_ref_result.is_ok() || no_type_result.is_ok(),
215
+
"Failed to deserialize signature in any form"
216
+
);
215
217
}
216
218
217
219
#[test]
+8
-4
crates/atproto-record/src/lexicon/community_lexicon_calendar_rsvp.rs
+8
-4
crates/atproto-record/src/lexicon/community_lexicon_calendar_rsvp.rs
···
333
333
// First parse as generic JSON to verify structure
334
334
let json_value: Value = serde_json::from_str(json_str)?;
335
335
assert_eq!(json_value["$type"], "community.lexicon.calendar.rsvp");
336
-
assert_eq!(json_value["status"], "community.lexicon.calendar.rsvp#going");
337
-
336
+
assert_eq!(
337
+
json_value["status"],
338
+
"community.lexicon.calendar.rsvp#going"
339
+
);
340
+
338
341
// Deserialize the JSON
339
342
let typed_rsvp: TypedRsvp = serde_json::from_str(json_str)?;
340
343
···
350
353
);
351
354
352
355
// Verify the timestamp
353
-
let expected_time = Utc.with_ymd_and_hms(2025, 8, 19, 20, 17, 17)
356
+
let expected_time = Utc
357
+
.with_ymd_and_hms(2025, 8, 19, 20, 17, 17)
354
358
.unwrap()
355
359
.with_nanosecond(133_000_000)
356
360
.unwrap();
···
361
365
match &typed_rsvp.inner.signatures[0] {
362
366
SignatureOrRef::Inline(sig) => {
363
367
assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools");
364
-
368
+
365
369
// Verify the issuedAt field if present
366
370
if let Some(issued_at_value) = sig.inner.extra.get("issuedAt") {
367
371
assert_eq!(issued_at_value, "2025-08-19T20:17:17.133Z");