A better Rust ATProto crate
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

bluesky pref bs

+482 -38
+1
Cargo.lock
··· 2458 2458 "serde", 2459 2459 "serde_html_form", 2460 2460 "serde_json", 2461 + "smol_str", 2461 2462 "thiserror 2.0.18", 2462 2463 "tokio", 2463 2464 "tower",
+1
crates/jacquard-axum/Cargo.toml
··· 31 31 serde.workspace = true 32 32 serde_html_form.workspace = true 33 33 serde_json.workspace = true 34 + smol_str.workspace = true 34 35 thiserror.workspace = true 35 36 tokio.workspace = true 36 37 tower-http = { version = "0.6.6", features = ["trace", "tracing"] }
+42 -13
crates/jacquard-axum/src/service_auth.rs
··· 23 23 //! ); 24 24 //! let config = ServiceAuthConfig::new( 25 25 //! Did::new_static("did:web:feedgen.example.com").unwrap(), 26 + //! &[], 26 27 //! resolver, 27 28 //! ); 28 29 //! ··· 49 50 service_auth::{self, PublicKey}, 50 51 types::{ 51 52 did_doc::VerificationMethod, 52 - string::{Did, Nsid}, 53 + string::{Did, DidService, Nsid}, 53 54 }, 54 55 }; 55 56 use jacquard_identity::resolver::IdentityResolver; 56 57 use serde_json::json; 57 - use std::sync::Arc; 58 + use smol_str::SmolStr; 59 + use std::{borrow::Cow, sync::Arc}; 58 60 use thiserror::Error; 59 61 60 62 /// Trait for providing service authentication configuration. ··· 73 75 74 76 /// Whether to require the `lxm` (method binding) field 75 77 fn require_lxm(&self) -> bool; 78 + 79 + /// Allow-list of service ids accepted in the audience `#service_id` 80 + /// fragment. An empty slice disables the check and accepts any fragment 81 + /// (including none). 82 + fn allowed_services(&self) -> &[SmolStr]; 76 83 } 77 84 78 85 /// Configuration for service auth verification. ··· 82 89 pub struct ServiceAuthConfig<R> { 83 90 /// The DID of your service (the expected audience) 84 91 service_did: Did<'static>, 92 + /// Allow-list of service ids accepted in the audience's `#service_id` 93 + /// fragment. Empty means "no service-id check". 94 + services: Vec<SmolStr>, 85 95 /// Identity resolver for fetching DID documents 86 96 resolver: Arc<R>, 87 97 /// Whether to require the `lxm` (method binding) field ··· 92 102 fn clone(&self) -> Self { 93 103 Self { 94 104 service_did: self.service_did.clone(), 105 + services: self.services.clone(), 95 106 resolver: Arc::clone(&self.resolver), 96 107 require_lxm: self.require_lxm, 97 108 } ··· 101 112 impl<R: IdentityResolver> ServiceAuthConfig<R> { 102 113 /// Create a new service auth config. 103 114 /// 104 - /// This enables `lxm` (method binding). If you need backward compatibility, 105 - /// use `ServiceAuthConfig::new_legacy()` 106 - pub fn new(service_did: Did<'static>, resolver: R) -> Self { 115 + /// `services` is an allow-list of acceptable values for the audience's 116 + /// `#service_id` fragment (e.g. `["bsky_appview"]`). Pass an empty slice 117 + /// to accept any fragment (including none). This enables `lxm` (method 118 + /// binding). If you need backward compatibility, use 119 + /// [`ServiceAuthConfig::new_legacy`]. 120 + pub fn new(service_did: Did<'static>, services: &[&str], resolver: R) -> Self { 107 121 Self { 108 122 service_did, 123 + services: services.iter().map(|s| SmolStr::new(*s)).collect(), 109 124 resolver: Arc::new(resolver), 110 125 require_lxm: true, 111 126 } ··· 113 128 114 129 /// Create a new service auth config. 115 130 /// 116 - /// `lxm` (method binding) is disabled for backwards compatibility 117 - pub fn new_legacy(service_did: Did<'static>, resolver: R) -> Self { 131 + /// `lxm` (method binding) is disabled for backwards compatibility. See 132 + /// [`Self::new`] for the meaning of `services`. 133 + pub fn new_legacy(service_did: Did<'static>, services: &[&str], resolver: R) -> Self { 118 134 Self { 119 135 service_did, 136 + services: services.iter().map(|s| SmolStr::new(*s)).collect(), 120 137 resolver: Arc::new(resolver), 121 138 require_lxm: false, 122 139 } ··· 136 153 &self.service_did 137 154 } 138 155 156 + /// Get the allowed service-id fragments (empty if disabled). 157 + pub fn services(&self) -> &[SmolStr] { 158 + &self.services 159 + } 160 + 139 161 /// Get a reference to the identity resolver. 140 162 pub fn resolver(&self) -> &R { 141 163 &self.resolver ··· 156 178 fn require_lxm(&self) -> bool { 157 179 self.require_lxm 158 180 } 181 + 182 + fn allowed_services(&self) -> &[SmolStr] { 183 + &self.services 184 + } 159 185 } 160 186 161 187 /// Verified service authentication information. ··· 166 192 pub struct VerifiedServiceAuth<'a> { 167 193 /// The authenticated user's DID (from `iss` claim) 168 194 did: Did<'a>, 169 - /// The audience (should match your service DID) 170 - aud: Did<'a>, 195 + /// The audience (your service DID, optionally with a `#service_id` fragment) 196 + aud: DidService<'a>, 171 197 /// The lexicon method NSID, if present 172 198 lxm: Option<Nsid<'a>>, 173 199 /// JWT ID (nonce), if present ··· 180 206 &self.did 181 207 } 182 208 183 - /// Get the audience (your service DID). 184 - pub fn aud(&self) -> &Did<'a> { 209 + /// Get the audience (your service DID, optionally with a `#service_id` fragment). 210 + pub fn aud(&self) -> &DidService<'a> { 185 211 &self.aud 186 212 } 187 213 ··· 227 253 /// ); 228 254 /// let config = ServiceAuthConfig::new( 229 255 /// Did::new_static("did:web:feedgen.example.com").unwrap(), 256 + /// &[], 230 257 /// resolver, 231 258 /// ); 232 259 /// ··· 276 303 /// ); 277 304 /// let config = ServiceAuthConfig::new( 278 305 /// Did::new_static("did:web:example.com").unwrap(), 306 + /// &[], 279 307 /// resolver, 280 308 /// ); 281 309 /// ··· 445 473 service_auth::verify_signature(&parsed, &signing_key)?; 446 474 447 475 // Now validate claims (audience, expiration, etc.) 448 - claims.validate(state.service_did())?; 476 + claims.validate(state.service_did(), state.allowed_services())?; 449 477 450 478 // Check method binding if required 451 479 if state.require_lxm() && claims.lxm.is_none() { ··· 527 555 service_auth::verify_signature(&parsed, &signing_key)?; 528 556 529 557 // Now validate claims (audience, expiration, etc.) 530 - claims.validate(state.service_did())?; 558 + claims.validate(state.service_did(), state.allowed_services())?; 531 559 532 560 // Check method binding if required 533 561 if state.require_lxm() && claims.lxm.is_none() { ··· 622 650 /// ); 623 651 /// let config = ServiceAuthConfig::new( 624 652 /// Did::new_static("did:web:feedgen.example.com").unwrap(), 653 + /// &[], 625 654 /// resolver, 626 655 /// ); 627 656 ///
+9 -8
crates/jacquard-axum/tests/service_auth_tests.rs
··· 165 165 let resolver = MockResolver::new(did_doc); 166 166 167 167 // Create config (default: require_lxm = true) 168 - let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver); 168 + let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver); 169 169 170 170 // Create handler 171 171 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { ··· 210 210 let did_doc = create_test_did_doc(user_did, verifying_key); 211 211 let resolver = MockResolver::new(did_doc); 212 212 213 - let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver); 213 + let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver); 214 214 215 215 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 216 216 format!("Authenticated as {}", auth.did()) ··· 246 246 let did_doc = create_test_did_doc(user_did, verifying_key); 247 247 let resolver = MockResolver::new(did_doc); 248 248 249 - let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver); 249 + let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver); 250 250 251 251 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 252 252 format!("Authenticated as {}", auth.did()) ··· 278 278 let did_doc = create_test_did_doc(user_did, verifying_key); 279 279 let resolver = MockResolver::new(did_doc); 280 280 281 - let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver); 281 + let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver); 282 282 283 283 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 284 284 format!("Authenticated as {}", auth.did()) ··· 317 317 let resolver = MockResolver::new(did_doc); 318 318 319 319 // Create config (default: require_lxm = true) 320 - let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver); 320 + let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver); 321 321 322 322 async fn handler(Extension(auth): Extension<VerifiedServiceAuth<'static>>) -> String { 323 323 format!("Authenticated as {}", auth.did()) ··· 365 365 let resolver = MockResolver::new(did_doc); 366 366 367 367 let config = 368 - ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver).require_lxm(true); 368 + ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver).require_lxm(true); 369 369 370 370 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 371 371 format!("Authenticated as {}", auth.did()) ··· 409 409 let resolver = MockResolver::new(did_doc); 410 410 411 411 let config = 412 - ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver).require_lxm(true); 412 + ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver).require_lxm(true); 413 413 414 414 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 415 415 format!( ··· 464 464 465 465 // Legacy config: lxm not required 466 466 let config = 467 - ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver).require_lxm(false); 467 + ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), &[], resolver).require_lxm(false); 468 468 469 469 async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { 470 470 format!("Authenticated as {}", auth.did()) ··· 520 520 521 521 let config = ServiceAuthConfig::new( 522 522 Did::new_static("did:web:dev.pdsmoover.com").unwrap(), 523 + &[], 523 524 resolver, 524 525 ); 525 526
+101 -17
crates/jacquard-common/src/service_auth.rs
··· 18 18 19 19 use crate::CowStr; 20 20 use crate::IntoStatic; 21 - use crate::types::string::{Did, Nsid}; 21 + use crate::types::string::{Did, DidService, Nsid}; 22 22 use alloc::string::String; 23 23 use alloc::string::ToString; 24 24 use alloc::vec::Vec; ··· 78 78 AudienceMismatch { 79 79 /// Expected audience DID 80 80 expected: Did<'static>, 81 - /// Actual audience DID in token 82 - actual: Did<'static>, 81 + /// Actual audience in token (DID with optional service id) 82 + actual: DidService<'static>, 83 + }, 84 + 85 + /// Service id in audience did not match any of the allowed service ids 86 + #[error("service id mismatch: expected one of {allowed:?}, got {actual:?}")] 87 + ServiceIdMismatch { 88 + /// Allowed service ids (caller-supplied) 89 + allowed: alloc::vec::Vec<SmolStr>, 90 + /// Service id present in the token's audience, if any 91 + actual: Option<SmolStr>, 83 92 }, 84 93 85 94 /// Method mismatch (lxm field) ··· 131 140 #[serde(borrow)] 132 141 pub iss: Did<'a>, 133 142 134 - /// Audience (target service DID) 143 + /// Audience (target service DID, optionally with `#service_id` fragment) 135 144 #[serde(borrow)] 136 - pub aud: Did<'a>, 145 + pub aud: DidService<'a>, 137 146 138 147 /// Expiration time (unix timestamp) 139 148 pub exp: i64, ··· 168 177 impl<'a> ServiceAuthClaims<'a> { 169 178 /// Validate the claims against expected values. 170 179 /// 171 - /// Checks: 172 - /// - Audience matches expected DID 173 - /// - Token is not expired 174 - pub fn validate(&self, expected_aud: &Did) -> Result<(), ServiceAuthError> { 175 - // Check audience 176 - if self.aud.as_str() != expected_aud.as_str() { 180 + /// Checks, in order: 181 + /// - Audience DID matches `expected_aud` (the service-id fragment is 182 + /// compared separately below, not as part of the DID match). 183 + /// - If `allowed_services` is non-empty, the audience's service id 184 + /// (`#service_id` fragment) must be present and must equal one of the 185 + /// provided values. An empty slice disables the service-id check and 186 + /// accepts any fragment — including none. 187 + /// - Token is not expired. 188 + pub fn validate<S>( 189 + &self, 190 + expected_aud: &Did, 191 + allowed_services: &[S], 192 + ) -> Result<(), ServiceAuthError> 193 + where 194 + S: AsRef<str>, 195 + { 196 + // Check audience (DID portion only; service id is checked next). 197 + if self.aud.audience().as_str() != expected_aud.as_str() { 177 198 return Err(ServiceAuthError::AudienceMismatch { 178 199 expected: expected_aud.clone().into_static(), 179 200 actual: self.aud.clone().into_static(), 180 201 }); 181 202 } 182 203 204 + // If the caller provided an allow-list of service ids, enforce it. 205 + if !allowed_services.is_empty() && self.aud.service().is_some() { 206 + let actual = self.aud.service(); 207 + let matched = actual 208 + .map(|svc| allowed_services.iter().any(|s| s.as_ref() == svc)) 209 + .unwrap_or(false); 210 + if !matched { 211 + return Err(ServiceAuthError::ServiceIdMismatch { 212 + allowed: allowed_services 213 + .iter() 214 + .map(|s| SmolStr::new(s.as_ref())) 215 + .collect(), 216 + actual: actual.map(SmolStr::new), 217 + }); 218 + } 219 + } 220 + 183 221 // Check expiration 184 222 if self.is_expired() { 185 223 let now = chrono::Utc::now().timestamp(); ··· 421 459 let now = chrono::Utc::now().timestamp(); 422 460 let expired_claims = ServiceAuthClaims { 423 461 iss: Did::new("did:plc:test").unwrap(), 424 - aud: Did::new("did:web:example.com").unwrap(), 462 + aud: DidService::new("did:web:example.com").unwrap(), 425 463 exp: now - 100, 426 464 iat: now - 200, 427 465 jti: None, ··· 432 470 433 471 let valid_claims = ServiceAuthClaims { 434 472 iss: Did::new("did:plc:test").unwrap(), 435 - aud: Did::new("did:web:example.com").unwrap(), 473 + aud: DidService::new("did:web:example.com").unwrap(), 436 474 exp: now + 100, 437 475 iat: now, 438 476 jti: None, ··· 447 485 let now = chrono::Utc::now().timestamp(); 448 486 let claims = ServiceAuthClaims { 449 487 iss: Did::new("did:plc:test").unwrap(), 450 - aud: Did::new("did:web:example.com").unwrap(), 488 + aud: DidService::new("did:web:example.com").unwrap(), 451 489 exp: now + 100, 452 490 iat: now, 453 491 jti: None, ··· 455 493 }; 456 494 457 495 let expected_aud = Did::new("did:web:example.com").unwrap(); 458 - assert!(claims.validate(&expected_aud).is_ok()); 496 + let no_services: &[&str] = &[]; 497 + assert!(claims.validate(&expected_aud, no_services).is_ok()); 459 498 460 499 let wrong_aud = Did::new("did:web:wrong.com").unwrap(); 461 500 assert!(matches!( 462 - claims.validate(&wrong_aud), 501 + claims.validate(&wrong_aud, no_services), 463 502 Err(ServiceAuthError::AudienceMismatch { .. }) 464 503 )); 465 504 } 466 505 467 506 #[test] 507 + fn test_service_id_validation() { 508 + let now = chrono::Utc::now().timestamp(); 509 + let with_service = ServiceAuthClaims { 510 + iss: Did::new("did:plc:test").unwrap(), 511 + aud: DidService::new("did:web:example.com#bsky_appview").unwrap(), 512 + exp: now + 100, 513 + iat: now, 514 + jti: None, 515 + lxm: None, 516 + }; 517 + let expected_aud = Did::new("did:web:example.com").unwrap(); 518 + 519 + // Empty allow-list: service id is not enforced. 520 + assert!(with_service.validate(&expected_aud, &[] as &[&str]).is_ok()); 521 + 522 + // Matching allow-list accepts the token. 523 + assert!( 524 + with_service 525 + .validate(&expected_aud, &["bsky_appview", "atproto_labeler"]) 526 + .is_ok() 527 + ); 528 + 529 + // Non-matching allow-list rejects the token. 530 + assert!(matches!( 531 + with_service.validate(&expected_aud, &["atproto_labeler"]), 532 + Err(ServiceAuthError::ServiceIdMismatch { .. }) 533 + )); 534 + 535 + // Bare-DID audience against a non-empty allow-list is rejected: 536 + // the token carries no service id, so nothing can match. 537 + let no_service = ServiceAuthClaims { 538 + iss: Did::new("did:plc:test").unwrap(), 539 + aud: DidService::new("did:web:example.com").unwrap(), 540 + exp: now + 100, 541 + iat: now, 542 + jti: None, 543 + lxm: None, 544 + }; 545 + assert!(matches!( 546 + no_service.validate(&expected_aud, &["bsky_appview"]), 547 + Err(ServiceAuthError::ServiceIdMismatch { actual: None, .. }) 548 + )); 549 + } 550 + 551 + #[test] 468 552 fn test_method_check() { 469 553 let claims = ServiceAuthClaims { 470 554 iss: Did::new("did:plc:test").unwrap(), 471 - aud: Did::new("did:web:example.com").unwrap(), 555 + aud: DidService::new("did:web:example.com").unwrap(), 472 556 exp: chrono::Utc::now().timestamp() + 100, 473 557 iat: chrono::Utc::now().timestamp(), 474 558 jti: None,
+2
crates/jacquard-common/src/types.rs
··· 18 18 pub mod did; 19 19 /// DID Document types and helpers 20 20 pub mod did_doc; 21 + /// DID with optional service identifier fragment 22 + pub mod did_service; 21 23 /// AT Protocol handle types and validation 22 24 pub mod handle; 23 25 /// AT Protocol identifier types (handle or DID)
+325
crates/jacquard-common/src/types/did_service.rs
··· 1 + use crate::types::did::Did; 2 + use crate::types::string::AtStrError; 3 + use crate::{CowStr, IntoStatic}; 4 + use alloc::string::{String, ToString}; 5 + use core::fmt; 6 + use core::ops::Deref; 7 + use core::str::FromStr; 8 + #[cfg(all(not(target_arch = "wasm32"), feature = "std"))] 9 + use regex::Regex; 10 + #[cfg(all(not(target_arch = "wasm32"), not(feature = "std")))] 11 + use regex_automata::meta::Regex; 12 + #[cfg(target_arch = "wasm32")] 13 + use regex_lite::Regex; 14 + use serde::{Deserialize, Deserializer, Serialize, de::Error}; 15 + use smol_str::{SmolStr, ToSmolStr}; 16 + 17 + use super::Lazy; 18 + 19 + /// DID with an optional service identifier fragment. 20 + /// 21 + /// Represents values like `did:web:app.modelo.social#bsky_appview`, used in 22 + /// atproto service auth audience (`aud`) claims to scope a token to a specific 23 + /// application hosted on a given DID. The fragment is optional; a bare DID 24 + /// (e.g. `did:web:example.com`) is also valid and represents "any service on 25 + /// this DID". 26 + /// 27 + /// The DID portion uses the same grammar as [`Did`]. The service identifier, 28 + /// when present, must begin with an ASCII letter and contain only ASCII 29 + /// letters, digits, `_`, or `-`, which matches the convention used in the wild 30 + /// (`bsky_appview`, `atproto_labeler`, `atproto_pds`). 31 + /// 32 + /// Maximum total length is 2048, matching [`Did`]. 33 + #[derive(Clone, PartialEq, Eq, Serialize, Hash, PartialOrd, Ord)] 34 + #[serde(transparent)] 35 + #[repr(transparent)] 36 + pub struct DidService<'d>(pub(crate) CowStr<'d>); 37 + 38 + /// Regex for `DidService` validation. 39 + /// 40 + /// The DID body mirrors [`crate::types::did::DID_REGEX`]; the optional 41 + /// `#service` fragment requires a leading ASCII letter and disallows dots, 42 + /// colons, and percent signs to avoid ambiguity with DID-method and 43 + /// percent-encoded bytes. 44 + pub static DID_SERVICE_REGEX: Lazy<Regex> = Lazy::new(|| { 45 + Regex::new(r"^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-](#[a-zA-Z][a-zA-Z0-9_-]*)?$").unwrap() 46 + }); 47 + 48 + impl<'d> DidService<'d> { 49 + /// Fallible constructor, validates, borrows from input. 50 + pub fn new(value: &'d str) -> Result<Self, AtStrError> { 51 + Self::validate(value)?; 52 + Ok(Self(CowStr::Borrowed(value))) 53 + } 54 + 55 + /// Fallible constructor, validates, borrows from input if possible. 56 + pub fn new_cow(value: CowStr<'d>) -> Result<Self, AtStrError> { 57 + Self::validate(&value)?; 58 + Ok(Self(value)) 59 + } 60 + 61 + /// Fallible constructor, validates, takes ownership. 62 + pub fn new_owned(value: impl AsRef<str>) -> Result<Self, AtStrError> { 63 + let value = value.as_ref(); 64 + Self::validate(value)?; 65 + Ok(Self(CowStr::Owned(value.to_smolstr()))) 66 + } 67 + 68 + /// Fallible constructor, validates, doesn't allocate. 69 + pub fn new_static(value: &'static str) -> Result<Self, AtStrError> { 70 + Self::validate(value)?; 71 + Ok(Self(CowStr::new_static(value))) 72 + } 73 + 74 + /// Infallible constructor for when you *know* the string is valid. 75 + /// Panics on invalid input. Prefer for trusted data decoded outside of serde. 76 + pub fn raw(value: &'d str) -> Self { 77 + match Self::validate(value) { 78 + Ok(()) => Self(CowStr::Borrowed(value)), 79 + Err(_) => panic!("Invalid DidService"), 80 + } 81 + } 82 + 83 + /// Infallible constructor. Marked unsafe because responsibility for 84 + /// upholding the invariant is on the caller. 85 + pub unsafe fn unchecked(value: &'d str) -> Self { 86 + Self(CowStr::Borrowed(value)) 87 + } 88 + 89 + fn validate(value: &str) -> Result<(), AtStrError> { 90 + if value.len() > 2048 { 91 + Err(AtStrError::too_long( 92 + "did-service", 93 + value, 94 + 2048, 95 + value.len(), 96 + )) 97 + } else if !DID_SERVICE_REGEX.is_match(value) { 98 + Err(AtStrError::regex( 99 + "did-service", 100 + value, 101 + SmolStr::new_static("invalid"), 102 + )) 103 + } else { 104 + Ok(()) 105 + } 106 + } 107 + 108 + /// Get the full compound string (DID plus optional `#service`). 109 + pub fn as_str(&self) -> &str { 110 + &self.0 111 + } 112 + 113 + /// The audience DID, i.e. the portion before any `#` fragment. 114 + /// 115 + /// The returned `Did` borrows from this value's storage. Validation at 116 + /// construction time guarantees the DID portion is well-formed, so this 117 + /// does not re-run the regex. 118 + pub fn audience(&self) -> Did<'_> { 119 + let full = self.as_str(); 120 + let did_part = match full.find('#') { 121 + Some(i) => &full[..i], 122 + None => full, 123 + }; 124 + // SAFETY: `DID_SERVICE_REGEX` guarantees the portion before any `#` is 125 + // a valid DID matching `DID_REGEX`. 126 + unsafe { Did::unchecked(did_part) } 127 + } 128 + 129 + /// The service identifier portion (after `#`), or `None` if absent. 130 + /// 131 + /// Returns `None` for a bare DID like `did:web:example.com`; returns 132 + /// `Some("bsky_appview")` for `did:web:example.com#bsky_appview`. The 133 + /// returned slice never contains the leading `#`. 134 + pub fn service(&self) -> Option<&str> { 135 + let full = self.as_str(); 136 + full.find('#').map(|i| &full[i + 1..]) 137 + } 138 + } 139 + 140 + impl FromStr for DidService<'_> { 141 + type Err = AtStrError; 142 + 143 + /// Has to take ownership due to `FromStr` lifetime constraints. Prefer 144 + /// [`DidService::new`] or [`DidService::raw`] if you want to borrow. 145 + fn from_str(s: &str) -> Result<Self, Self::Err> { 146 + Self::new_owned(s) 147 + } 148 + } 149 + 150 + impl IntoStatic for DidService<'_> { 151 + type Output = DidService<'static>; 152 + 153 + fn into_static(self) -> Self::Output { 154 + DidService(self.0.into_static()) 155 + } 156 + } 157 + 158 + impl<'de, 'a> Deserialize<'de> for DidService<'a> 159 + where 160 + 'de: 'a, 161 + { 162 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 163 + where 164 + D: Deserializer<'de>, 165 + { 166 + let value = Deserialize::deserialize(deserializer)?; 167 + Self::new_cow(value).map_err(D::Error::custom) 168 + } 169 + } 170 + 171 + impl fmt::Display for DidService<'_> { 172 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 173 + f.write_str(&self.0) 174 + } 175 + } 176 + 177 + impl fmt::Debug for DidService<'_> { 178 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 179 + f.write_str(&self.0) 180 + } 181 + } 182 + 183 + impl<'d> From<DidService<'d>> for String { 184 + fn from(value: DidService<'d>) -> Self { 185 + value.0.to_string() 186 + } 187 + } 188 + 189 + impl<'d> From<DidService<'d>> for CowStr<'d> { 190 + fn from(value: DidService<'d>) -> Self { 191 + value.0 192 + } 193 + } 194 + 195 + impl From<String> for DidService<'static> { 196 + fn from(value: String) -> Self { 197 + match Self::validate(&value) { 198 + Ok(()) => Self(CowStr::Owned(value.into())), 199 + Err(_) => panic!("Invalid DidService"), 200 + } 201 + } 202 + } 203 + 204 + impl<'d> From<CowStr<'d>> for DidService<'d> { 205 + fn from(value: CowStr<'d>) -> Self { 206 + match Self::validate(&value) { 207 + Ok(()) => Self(value), 208 + Err(_) => panic!("Invalid DidService"), 209 + } 210 + } 211 + } 212 + 213 + impl AsRef<str> for DidService<'_> { 214 + fn as_ref(&self) -> &str { 215 + self.as_str() 216 + } 217 + } 218 + 219 + impl Deref for DidService<'_> { 220 + type Target = str; 221 + 222 + fn deref(&self) -> &Self::Target { 223 + self.as_str() 224 + } 225 + } 226 + 227 + #[cfg(test)] 228 + mod tests { 229 + use super::*; 230 + 231 + #[test] 232 + fn valid_with_service() { 233 + assert!(DidService::new("did:web:app.modelo.social#bsky_appview").is_ok()); 234 + assert!(DidService::new("did:plc:abc123#atproto_labeler").is_ok()); 235 + assert!(DidService::new("did:web:example.com#a").is_ok()); 236 + } 237 + 238 + #[test] 239 + fn valid_without_service() { 240 + let v = DidService::new("did:web:example.com").unwrap(); 241 + assert_eq!(v.as_str(), "did:web:example.com"); 242 + assert_eq!(v.service(), None); 243 + assert_eq!(v.audience().as_str(), "did:web:example.com"); 244 + } 245 + 246 + #[test] 247 + fn audience_and_service_split() { 248 + let v = DidService::new("did:web:app.modelo.social#bsky_appview").unwrap(); 249 + assert_eq!(v.audience().as_str(), "did:web:app.modelo.social"); 250 + assert_eq!(v.service(), Some("bsky_appview")); 251 + assert_eq!(v.as_str(), "did:web:app.modelo.social#bsky_appview"); 252 + } 253 + 254 + #[test] 255 + fn empty_fragment_rejected() { 256 + assert!(DidService::new("did:web:foo#").is_err()); 257 + } 258 + 259 + #[test] 260 + fn invalid_service_chars() { 261 + assert!(DidService::new("did:web:foo#with space").is_err()); 262 + assert!(DidService::new("did:web:foo#with.dot").is_err()); 263 + assert!(DidService::new("did:web:foo#-leading-dash").is_err()); 264 + assert!(DidService::new("did:web:foo#123leading").is_err()); 265 + assert!(DidService::new("did:web:foo#svc#again").is_err()); 266 + assert!(DidService::new("did:web:foo#svc:nope").is_err()); 267 + } 268 + 269 + #[test] 270 + fn valid_service_chars() { 271 + assert!(DidService::new("did:web:foo#bsky_appview").is_ok()); 272 + assert!(DidService::new("did:web:foo#a").is_ok()); 273 + assert!(DidService::new("did:web:foo#atproto-labeler").is_ok()); 274 + assert!(DidService::new("did:web:foo#abc_123").is_ok()); 275 + } 276 + 277 + #[test] 278 + fn invalid_did_body() { 279 + assert!(DidService::new("foo#bsky_appview").is_err()); 280 + assert!(DidService::new("DID:web:foo#svc").is_err()); 281 + assert!(DidService::new("did:WEB:foo#svc").is_err()); 282 + assert!(DidService::new("did:web:foo:#svc").is_err()); 283 + } 284 + 285 + #[test] 286 + fn max_length_enforced() { 287 + // 2048 valid; pad the DID body. 288 + let valid_2048 = format!("did:web:{}", "a".repeat(2048 - 8)); 289 + assert_eq!(valid_2048.len(), 2048); 290 + assert!(DidService::new(&valid_2048).is_ok()); 291 + 292 + let too_long = format!("did:web:{}", "a".repeat(2049 - 8)); 293 + assert_eq!(too_long.len(), 2049); 294 + assert!(DidService::new(&too_long).is_err()); 295 + } 296 + 297 + #[test] 298 + fn deserialize_roundtrip() { 299 + let json = "\"did:web:a.b#svc\""; 300 + let parsed: DidService<'_> = serde_json::from_str(json).unwrap(); 301 + assert_eq!(parsed.as_str(), "did:web:a.b#svc"); 302 + assert_eq!(parsed.service(), Some("svc")); 303 + let reserialized = serde_json::to_string(&parsed).unwrap(); 304 + assert_eq!(reserialized, json); 305 + } 306 + 307 + #[test] 308 + fn into_static_preserves_value() { 309 + let owned: DidService<'static> = { 310 + let s = String::from("did:web:a.b#svc"); 311 + let borrowed = DidService::new(s.as_str()).unwrap(); 312 + borrowed.into_static() 313 + }; 314 + assert_eq!(owned.as_str(), "did:web:a.b#svc"); 315 + assert_eq!(owned.audience().as_str(), "did:web:a.b"); 316 + assert_eq!(owned.service(), Some("svc")); 317 + } 318 + 319 + #[test] 320 + fn from_str_ok() { 321 + let v: DidService<'static> = "did:plc:abc#svc".parse().unwrap(); 322 + assert_eq!(v.audience().as_str(), "did:plc:abc"); 323 + assert_eq!(v.service(), Some("svc")); 324 + } 325 + }
+1
crates/jacquard-common/src/types/string.rs
··· 33 33 cid::{Cid, CidLink}, 34 34 datetime::Datetime, 35 35 did::Did, 36 + did_service::DidService, 36 37 handle::Handle, 37 38 ident::AtIdentifier, 38 39 language::Language,