A better Rust ATProto crate
1

Configure Feed

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

at main 21 kB View raw
1//! Lexicon schema resolution via DNS and XRPC 2//! 3//! This module provides traits and implementations for resolving lexicon schemas at runtime: 4//! 1. Resolve NSID authority to DID via DNS TXT records (`_lexicon.{reversed-authority}`) 5//! 2. Fetch lexicon schema from `com.atproto.lexicon.schema` collection via XRPC 6 7use crate::resolver::{IdentityError, IdentityResolver}; 8 9use jacquard_common::{ 10 BosStr, 11 deps::smol_str, 12 http_client::HttpClient, 13 types::{cid::Cid, did::Did, ident::AtIdentifier, string::Nsid, string::RecordKey}, 14}; 15use smol_str::SmolStr; 16 17/// Resolve lexicon authority (NSID → authoritative DID) 18#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 19pub trait LexiconAuthorityResolver { 20 /// Resolve an NSID to the authoritative DID via DNS 21 /// 22 /// Uses DNS TXT records at `_lexicon.{reversed-authority}`, following the 23 /// AT Protocol lexicon authority spec. Authority segments are reversed 24 /// (e.g., `app.bsky.feed` → query `_lexicon.feed.bsky.app`). 25 /// 26 /// Note: No hierarchical fallback - per the spec, only exact authority match is checked. 27 fn resolve_lexicon_authority<S: BosStr + Sync>( 28 &self, 29 nsid: &Nsid<S>, 30 ) -> impl Future<Output = std::result::Result<Did, LexiconResolutionError>>; 31} 32 33/// Resolve lexicon schemas (NSID → schema document) 34#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 35pub trait LexiconSchemaResolver { 36 /// Resolve a complete lexicon schema for an NSID 37 fn resolve_lexicon_schema<S: BosStr + Sync>( 38 &self, 39 nsid: &Nsid<S>, 40 ) -> impl Future<Output = std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError>>; 41} 42 43/// A resolved lexicon schema with metadata 44#[derive(Debug, Clone)] 45pub struct ResolvedLexiconSchema<'s> { 46 /// The NSID of the schema 47 pub nsid: Nsid, 48 /// DID of the repository this schema was fetched from 49 pub repo: Did, 50 /// Content ID of the record (for cache invalidation) 51 pub cid: Cid, 52 /// Parsed lexicon document 53 pub doc: jacquard_lexicon::lexicon::LexiconDoc<'s>, 54} 55 56/// Error type for lexicon resolution operations 57#[derive(Debug, thiserror::Error, miette::Diagnostic)] 58#[error("{kind}")] 59pub struct LexiconResolutionError { 60 #[diagnostic_source] 61 kind: LexiconResolutionErrorKind, 62 #[source] 63 source: Option<Box<dyn std::error::Error + Send + Sync>>, 64 context: Option<SmolStr>, 65} 66 67impl LexiconResolutionError { 68 /// Create a new error with the given kind and optional source. 69 pub fn new( 70 kind: LexiconResolutionErrorKind, 71 source: Option<Box<dyn std::error::Error + Send + Sync>>, 72 ) -> Self { 73 Self { 74 kind, 75 source, 76 context: None, 77 } 78 } 79 80 /// Return the error kind. 81 pub fn kind(&self) -> &LexiconResolutionErrorKind { 82 &self.kind 83 } 84 85 /// Add context to this error 86 pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 87 self.context = Some(context.into()); 88 self 89 } 90 91 /// Get the context if present 92 pub fn context(&self) -> Option<&str> { 93 self.context.as_deref() 94 } 95 96 /// Create an error for a failed DNS TXT lookup while resolving a lexicon authority. 97 pub fn dns_lookup_failed( 98 authority: impl Into<SmolStr>, 99 source: impl std::error::Error + Send + Sync + 'static, 100 ) -> Self { 101 Self::new( 102 LexiconResolutionErrorKind::DnsLookupFailed { 103 authority: authority.into(), 104 }, 105 Some(Box::new(source)), 106 ) 107 } 108 109 /// Create an error for when DNS records exist but contain no `did=...` entry. 110 pub fn no_did_found(authority: impl Into<SmolStr>) -> Self { 111 Self::new( 112 LexiconResolutionErrorKind::NoDIDFound { 113 authority: authority.into(), 114 }, 115 None, 116 ) 117 } 118 119 /// Create an error for a syntactically invalid DID found in DNS for the given authority. 120 pub fn invalid_did(authority: impl Into<SmolStr>, value: impl Into<SmolStr>) -> Self { 121 Self::new( 122 LexiconResolutionErrorKind::InvalidDID { 123 authority: authority.into(), 124 value: value.into(), 125 }, 126 None, 127 ) 128 } 129 130 /// Create an error for when DNS is not available (feature disabled or WASM target). 131 pub fn dns_not_configured() -> Self { 132 Self::new(LexiconResolutionErrorKind::DnsNotConfigured, None) 133 } 134 135 /// Create an error for a failure to fetch the lexicon record for an NSID. 136 pub fn fetch_failed( 137 nsid: impl Into<SmolStr>, 138 source: impl std::error::Error + Send + Sync + 'static, 139 ) -> Self { 140 Self::new( 141 LexiconResolutionErrorKind::FetchFailed { nsid: nsid.into() }, 142 Some(Box::new(source)), 143 ) 144 } 145 146 /// Create an error for a failure to parse a fetched lexicon schema document. 147 pub fn parse_failed( 148 nsid: impl Into<SmolStr>, 149 source: impl std::error::Error + Send + Sync + 'static, 150 ) -> Self { 151 Self::new( 152 LexiconResolutionErrorKind::ParseFailed { nsid: nsid.into() }, 153 Some(Box::new(source)), 154 ) 155 } 156 157 /// Create a generic resolution failure error with a descriptive message. 158 pub fn resolution_failed(nsid: impl Into<SmolStr>, message: impl Into<SmolStr>) -> Self { 159 Self::new( 160 LexiconResolutionErrorKind::ResolutionFailed { 161 nsid: nsid.into(), 162 message: message.into(), 163 }, 164 None, 165 ) 166 } 167 168 /// Create an error for a non-success HTTP status received while fetching a lexicon. 169 pub fn http_error(nsid: impl Into<SmolStr>, status: u16) -> Self { 170 Self::new( 171 LexiconResolutionErrorKind::HttpError { 172 nsid: nsid.into(), 173 status, 174 }, 175 None, 176 ) 177 } 178 179 /// Create an error for a required field missing from the XRPC response. 180 pub fn missing_response_field(nsid: impl Into<SmolStr>, field: &'static str) -> Self { 181 Self::new( 182 LexiconResolutionErrorKind::MissingResponseField { 183 nsid: nsid.into(), 184 field, 185 }, 186 None, 187 ) 188 } 189 190 /// Create an error for an invalid lexicon collection NSID. 191 pub fn invalid_collection() -> Self { 192 Self::new(LexiconResolutionErrorKind::InvalidCollection, None) 193 } 194 195 /// Create an error for a lexicon record response that is missing its CID. 196 pub fn missing_cid(nsid: impl Into<SmolStr>) -> Self { 197 Self::new( 198 LexiconResolutionErrorKind::MissingCID { nsid: nsid.into() }, 199 None, 200 ) 201 } 202} 203 204impl From<IdentityError> for LexiconResolutionError { 205 fn from(err: IdentityError) -> Self { 206 Self::new(LexiconResolutionErrorKind::IdentityResolution(err), None) 207 } 208} 209 210/// Error categories for lexicon resolution 211#[derive(Debug, thiserror::Error, miette::Diagnostic)] 212#[non_exhaustive] 213pub enum LexiconResolutionErrorKind { 214 /// DNS TXT lookup for the lexicon authority failed. 215 #[error("DNS lookup failed for authority {authority}")] 216 #[diagnostic(code(jacquard::lexicon::dns_lookup_failed))] 217 DnsLookupFailed { 218 /// The NSID authority segment that was being looked up. 219 authority: SmolStr, 220 }, 221 222 /// DNS records were reachable but contained no `did=...` entry. 223 #[error("no DID found in DNS for authority {authority}")] 224 #[diagnostic( 225 code(jacquard::lexicon::no_did_found), 226 help("ensure _lexicon.{{reversed-authority}} TXT record exists with did=...") 227 )] 228 NoDIDFound { 229 /// The NSID authority segment that was being looked up. 230 authority: SmolStr, 231 }, 232 233 /// DNS returned a `did=...` entry but its value is not a valid DID. 234 #[error("invalid DID in DNS for authority {authority}: {value}")] 235 #[diagnostic(code(jacquard::lexicon::invalid_did))] 236 InvalidDID { 237 /// The NSID authority segment. 238 authority: SmolStr, 239 /// The raw invalid DID string found in DNS. 240 value: SmolStr, 241 }, 242 243 /// DNS is not available on this build (the `dns` feature is disabled or target is WASM). 244 #[error("DNS not configured (dns feature disabled or WASM target)")] 245 #[diagnostic( 246 code(jacquard::lexicon::dns_not_configured), 247 help("enable the 'dns' feature or use a non-WASM target") 248 )] 249 DnsNotConfigured, 250 251 /// XRPC or HTTP request to fetch the lexicon record failed. 252 #[error("failed to fetch lexicon record for {nsid}")] 253 #[diagnostic(code(jacquard::lexicon::fetch_failed))] 254 FetchFailed { 255 /// The NSID of the lexicon that could not be fetched. 256 nsid: SmolStr, 257 }, 258 259 /// The fetched lexicon record could not be deserialized as a `LexiconDoc`. 260 #[error("failed to parse lexicon schema for {nsid}")] 261 #[diagnostic(code(jacquard::lexicon::parse_failed))] 262 ParseFailed { 263 /// The NSID of the lexicon that could not be parsed. 264 nsid: SmolStr, 265 }, 266 267 /// Generic resolution failure with a descriptive message. 268 #[error("failed to parse lexicon schema for {nsid}")] 269 #[diagnostic(code(jacquard::lexicon::resolution_failed))] 270 ResolutionFailed { 271 /// The NSID of the lexicon being resolved. 272 nsid: SmolStr, 273 /// Human-readable description of what went wrong. 274 message: SmolStr, 275 }, 276 277 /// HTTP non-success status from lexicon fetch. 278 #[error("HTTP {status} fetching lexicon {nsid}")] 279 #[diagnostic(code(jacquard::lexicon::http_error))] 280 HttpError { 281 /// The NSID of the lexicon being fetched. 282 nsid: SmolStr, 283 /// The HTTP status code received. 284 status: u16, 285 }, 286 287 /// Required field missing in XRPC response. 288 #[error("missing '{field}' field in response for {nsid}")] 289 #[diagnostic( 290 code(jacquard::lexicon::missing_response_field), 291 help("the XRPC response is missing a required field") 292 )] 293 MissingResponseField { 294 /// The NSID of the lexicon being fetched. 295 nsid: SmolStr, 296 /// Name of the missing field. 297 field: &'static str, 298 }, 299 300 /// The lexicon collection NSID was not valid. 301 #[error("invalid collection NSID")] 302 #[diagnostic(code(jacquard::lexicon::invalid_collection))] 303 InvalidCollection, 304 305 /// The `getRecord` response did not include a CID for the lexicon record. 306 #[error("record missing CID for {nsid}")] 307 #[diagnostic(code(jacquard::lexicon::missing_cid))] 308 MissingCID { 309 /// The NSID of the lexicon whose record was missing a CID. 310 nsid: SmolStr, 311 }, 312 313 /// Identity resolution failed while locating the PDS that hosts the lexicon. 314 #[error(transparent)] 315 #[diagnostic(code(jacquard::lexicon::identity_resolution_failed))] 316 IdentityResolution(#[from] crate::resolver::IdentityError), 317} 318 319// Implementation on JacquardResolver 320impl<C: HttpClient> crate::JacquardResolver<C> { 321 /// Resolve lexicon authority via DNS 322 /// 323 /// Queries `_lexicon.{reversed-authority}` for a TXT record containing `did=...` 324 #[cfg(all(feature = "dns", not(target_family = "wasm")))] 325 async fn resolve_lexicon_authority_dns<S: BosStr + Sync>( 326 &self, 327 nsid: &Nsid<S>, 328 ) -> std::result::Result<Did, LexiconResolutionError> { 329 let Some(dns) = &self.dns else { 330 return Err(LexiconResolutionError::dns_not_configured()); 331 }; 332 333 // Extract and reverse authority segments 334 let authority = nsid.domain_authority(); 335 let reversed_authority = authority.split('.').rev().collect::<Vec<_>>().join("."); 336 let fqdn = format!("_lexicon.{}.", reversed_authority); 337 338 #[cfg(feature = "tracing")] 339 tracing::debug!("resolving lexicon authority via DNS: {}", fqdn); 340 341 let response = dns 342 .txt_lookup(fqdn) 343 .await 344 .map_err(|e| LexiconResolutionError::dns_lookup_failed(authority, e))?; 345 346 // Parse TXT records looking for "did=..." 347 for txt in response.iter() { 348 for data in txt.txt_data().iter() { 349 let text = std::str::from_utf8(data).unwrap_or(""); 350 if let Some(did_str) = text.strip_prefix("did=") { 351 return Did::new_owned(did_str).map_err(|_| { 352 LexiconResolutionError::invalid_did(authority, did_str) 353 .with_context(format!("resolving NSID {}", nsid)) 354 }); 355 } 356 } 357 } 358 359 Err(LexiconResolutionError::no_did_found(authority)) 360 } 361} 362 363#[cfg(all(feature = "dns", not(target_family = "wasm")))] 364impl<C: HttpClient + Sync> LexiconAuthorityResolver for crate::JacquardResolver<C> { 365 async fn resolve_lexicon_authority<S: BosStr + Sync>( 366 &self, 367 nsid: &Nsid<S>, 368 ) -> std::result::Result<Did, LexiconResolutionError> { 369 // Try cache first 370 #[cfg(feature = "cache")] 371 if let Some(caches) = &self.caches { 372 let authority = jacquard_common::deps::smol_str::SmolStr::from(nsid.domain_authority()); 373 if let Some(did) = crate::cache_impl::get(&caches.authority_to_did, &authority) { 374 return Ok(did); 375 } 376 } 377 378 // Resolve via DNS 379 let result = self.resolve_lexicon_authority_dns(nsid).await; 380 381 // Cache on success, invalidate on error 382 #[cfg(feature = "cache")] 383 match &result { 384 Ok(did) => { 385 if let Some(caches) = &self.caches { 386 let authority = 387 jacquard_common::deps::smol_str::SmolStr::from(nsid.domain_authority()); 388 crate::cache_impl::insert(&caches.authority_to_did, authority, did.clone()); 389 } 390 } 391 Err(_) => { 392 self.invalidate_authority_chain(nsid.domain_authority()) 393 .await; 394 } 395 } 396 397 result 398 } 399} 400 401#[cfg(not(all(feature = "dns", not(target_family = "wasm"))))] 402impl<C: HttpClient + Sync> LexiconAuthorityResolver for crate::JacquardResolver<C> { 403 async fn resolve_lexicon_authority<S: BosStr + Sync>( 404 &self, 405 nsid: &Nsid<S>, 406 ) -> std::result::Result<Did, LexiconResolutionError> { 407 // Use DNS-over-HTTPS fallback for WASM/non-DNS builds 408 self.resolve_lexicon_authority_doh(nsid).await 409 } 410} 411 412impl<C: HttpClient> crate::JacquardResolver<C> { 413 /// Resolve lexicon authority via DNS-over-HTTPS (for WASM compatibility) 414 #[allow(dead_code)] 415 async fn resolve_lexicon_authority_doh<S: BosStr + Sync>( 416 &self, 417 nsid: &Nsid<S>, 418 ) -> std::result::Result<Did, LexiconResolutionError> { 419 // Try cache first 420 #[cfg(feature = "cache")] 421 if let Some(caches) = &self.caches { 422 let authority = jacquard_common::deps::smol_str::SmolStr::from(nsid.domain_authority()); 423 if let Some(did) = crate::cache_impl::get(&caches.authority_to_did, &authority) { 424 return Ok(did); 425 } 426 } 427 428 let authority = nsid.domain_authority(); 429 let reversed_authority = authority.split('.').rev().collect::<Vec<_>>().join("."); 430 let fqdn = format!("_lexicon.{}.", reversed_authority); 431 432 #[cfg(feature = "tracing")] 433 tracing::trace!("resolving lexicon authority via DoH: {}", fqdn); 434 435 let response = self 436 .query_dns_doh(&fqdn, "TXT") 437 .await 438 .map_err(|e| LexiconResolutionError::dns_lookup_failed(authority, e))?; 439 440 // Parse DoH JSON response 441 let answers = response 442 .get("Answer") 443 .and_then(|a| a.as_array()) 444 .ok_or_else(|| LexiconResolutionError::no_did_found(authority))?; 445 446 for answer in answers { 447 if let Some(data) = answer.get("data").and_then(|d| d.as_str()) { 448 // TXT records are quoted in DNS responses, strip quotes 449 let txt_data = data.trim_matches('"'); 450 451 if let Some(did_str) = txt_data.strip_prefix("did=") { 452 let result = Did::new_owned(did_str).map_err(|_| { 453 LexiconResolutionError::invalid_did(authority, did_str) 454 .with_context(format!("resolving NSID {}", nsid)) 455 }); 456 457 // Cache on success 458 #[cfg(feature = "cache")] 459 if let Ok(ref did) = result { 460 if let Some(caches) = &self.caches { 461 let authority_key = 462 jacquard_common::deps::smol_str::SmolStr::from(authority); 463 crate::cache_impl::insert( 464 &caches.authority_to_did, 465 authority_key, 466 did.clone(), 467 ); 468 } 469 } 470 471 return result; 472 } 473 } 474 } 475 476 Err(LexiconResolutionError::no_did_found(authority)) 477 } 478} 479 480impl<C: HttpClient + Sync> LexiconSchemaResolver for crate::JacquardResolver<C> { 481 async fn resolve_lexicon_schema<S: BosStr + Sync>( 482 &self, 483 nsid: &Nsid<S>, 484 ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> { 485 use jacquard_common::xrpc::atproto::GetRecord; 486 use jacquard_common::{IntoStatic, xrpc::XrpcExt}; 487 488 let nsid_str = nsid.as_str(); 489 let owned_nsid: Nsid = Nsid::new_owned(nsid_str).expect("already validated NSID"); 490 491 // Try cache first 492 #[cfg(feature = "cache")] 493 if let Some(caches) = &self.caches { 494 if let Some(schema) = crate::cache_impl::get(&caches.nsid_to_schema, &owned_nsid) { 495 return Ok((*schema).clone()); 496 } 497 } 498 499 // Perform resolution 500 let result = async { 501 // 1. Resolve authority DID via DNS 502 let authority_did = self.resolve_lexicon_authority(nsid).await?; 503 504 #[cfg(feature = "tracing")] 505 tracing::trace!( 506 "resolved lexicon authority {} -> {}", 507 nsid.domain_authority(), 508 authority_did 509 ); 510 511 // 2. Resolve DID document to get PDS endpoint 512 let did_doc_resp = self.resolve_did_doc(&authority_did).await?; 513 let did_doc = did_doc_resp.parse()?; 514 let pds = did_doc 515 .pds_endpoint() 516 .ok_or_else(|| IdentityError::missing_pds_endpoint(authority_did.as_str()))?; 517 518 #[cfg(feature = "tracing")] 519 tracing::trace!("fetching lexicon {} from PDS {}", nsid, pds); 520 521 // 3. Fetch lexicon record via XRPC getRecord 522 let collection = Nsid::new_owned("com.atproto.lexicon.schema") 523 .map_err(|_| LexiconResolutionError::invalid_collection())?; 524 525 let request = GetRecord { 526 repo: AtIdentifier::Did(authority_did.clone()), 527 collection, 528 rkey: RecordKey::any_owned(nsid_str).unwrap(), 529 cid: None, 530 }; 531 532 let response = self 533 .xrpc(pds) 534 .send(&request) 535 .await 536 .map_err(|e| LexiconResolutionError::fetch_failed(nsid_str, e))?; 537 538 let output = response 539 .into_output() 540 .map_err(|e| LexiconResolutionError::fetch_failed(nsid_str, e))?; 541 542 // 4. Parse lexicon document from value 543 let json_str = serde_json::to_string(&output.value) 544 .map_err(|e| LexiconResolutionError::parse_failed(nsid_str, e))?; 545 546 let doc: jacquard_lexicon::lexicon::LexiconDoc = serde_json::from_str(&json_str) 547 .map_err(|e| LexiconResolutionError::parse_failed(nsid_str, e))?; 548 549 #[cfg(feature = "tracing")] 550 tracing::trace!("successfully parsed lexicon schema {}", nsid); 551 552 let cid = output 553 .cid 554 .ok_or_else(|| LexiconResolutionError::missing_cid(nsid_str))?; 555 556 Ok(ResolvedLexiconSchema { 557 nsid: owned_nsid.clone(), 558 repo: authority_did, 559 cid, 560 doc: doc.into_static(), 561 }) 562 } 563 .await; 564 565 // Handle result 566 match result { 567 Ok(schema) => { 568 // Cache successful resolution 569 #[cfg(feature = "cache")] 570 if let Some(caches) = &self.caches { 571 crate::cache_impl::insert( 572 &caches.nsid_to_schema, 573 owned_nsid, 574 std::sync::Arc::new(schema.clone()), 575 ); 576 } 577 Ok(schema) 578 } 579 Err(e) => { 580 // Invalidate on error 581 #[cfg(feature = "cache")] 582 self.invalidate_lexicon_chain(nsid).await; 583 Err(e) 584 } 585 } 586 } 587}