A better Rust ATProto crate

fixed non-dns lexicon resolution (using cf DoH)

Orual 849876d7 5c79bb76

Changed files
+245 -61
crates
jacquard-identity
jacquard-lexicon
+170 -46
crates/jacquard-identity/src/lexicon_resolver.rs
··· 130 ) 131 } 132 133 pub fn invalid_collection() -> Self { 134 Self::new(LexiconResolutionErrorKind::InvalidCollection, None) 135 } ··· 180 #[error("failed to parse lexicon schema for {nsid}")] 181 #[diagnostic(code(jacquard::lexicon::parse_failed))] 182 ParseFailed { nsid: SmolStr }, 183 184 #[error("invalid collection NSID")] 185 #[diagnostic(code(jacquard::lexicon::invalid_collection))] ··· 248 &self, 249 nsid: &Nsid<'_>, 250 ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> { 251 - if let Ok(mut url) = Url::parse("https://public.api.bsky.app") { 252 - url.set_path("/xrpc/com.atproto.lexicon.resolveLexicon"); 253 - if let Ok(qs) = 254 - serde_html_form::to_string(&ResolveLexicon::new().nsid(nsid.clone()).build()) 255 - { 256 - url.set_query(Some(&qs)); 257 - } else { 258 - return Err(LexiconResolutionError::invalid_collection()); 259 - } 260 - if let Ok((buf, status)) = self.get_json_bytes(url).await { 261 - if status.is_success() { 262 - if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&buf) { 263 - if let Some(obj) = val.as_object() { 264 - if let Some(schema) = obj.get("schema") { 265 - if let Ok(schema) = from_json_value::<LexiconDoc>(schema.clone()) { 266 - let uri = 267 - obj.get("uri").expect("uri should be present").to_string(); 268 - let cid = 269 - obj.get("cid").expect("cid should be present").to_string(); 270 - let uri = AtUri::new_owned(uri).map_err(|e| { 271 - LexiconResolutionError::parse_failed("uri", e) 272 - })?; 273 - let cid = Cid::str(&cid).into_static(); 274 - let repo = Did::raw(uri.authority().as_str()).into_static(); 275 - return Ok(ResolvedLexiconSchema { 276 - repo, 277 - cid, 278 - nsid: nsid.clone().into_static(), 279 - doc: schema.into_static(), 280 - }); 281 - } 282 - } 283 - } 284 - } 285 - } 286 - } 287 } 288 289 - Err(LexiconResolutionError::invalid_collection()) 290 } 291 } 292 ··· 334 impl LexiconAuthorityResolver for crate::JacquardResolver { 335 async fn resolve_lexicon_authority( 336 &self, 337 - _nsid: &Nsid<'_>, 338 ) -> std::result::Result<Did<'static>, LexiconResolutionError> { 339 - Err(LexiconResolutionError::dns_not_configured()) 340 } 341 } 342 ··· 358 } 359 360 // Perform resolution 361 - #[cfg(feature = "dns")] 362 let result = async { 363 // 1. Resolve authority DID via DNS 364 let authority_did = self.resolve_lexicon_authority(nsid).await?; 365 366 #[cfg(feature = "tracing")] 367 - tracing::debug!( 368 "resolved lexicon authority {} -> {}", 369 nsid.domain_authority(), 370 authority_did ··· 378 .ok_or_else(|| IdentityError::missing_pds_endpoint())?; 379 380 #[cfg(feature = "tracing")] 381 - tracing::debug!("fetching lexicon {} from PDS {}", nsid, pds); 382 383 // 3. Fetch lexicon record via XRPC getRecord 384 let collection = Nsid::new("com.atproto.lexicon.schema") ··· 408 .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; 409 410 #[cfg(feature = "tracing")] 411 - tracing::debug!("successfully parsed lexicon schema {}", nsid); 412 413 let cid = output 414 .cid ··· 423 }) 424 } 425 .await; 426 - 427 - #[cfg(not(feature = "dns"))] 428 - let result = self.resolve_lexicon_xrpc(nsid).await; 429 430 // Handle result 431 match result {
··· 130 ) 131 } 132 133 + pub fn resolution_failed(nsid: impl Into<SmolStr>, message: impl Into<SmolStr>) -> Self { 134 + Self::new( 135 + LexiconResolutionErrorKind::ResolutionFailed { 136 + nsid: nsid.into(), 137 + message: message.into(), 138 + }, 139 + None, 140 + ) 141 + } 142 + 143 pub fn invalid_collection() -> Self { 144 Self::new(LexiconResolutionErrorKind::InvalidCollection, None) 145 } ··· 190 #[error("failed to parse lexicon schema for {nsid}")] 191 #[diagnostic(code(jacquard::lexicon::parse_failed))] 192 ParseFailed { nsid: SmolStr }, 193 + 194 + #[error("failed to parse lexicon schema for {nsid}")] 195 + #[diagnostic(code(jacquard::lexicon::resolution_failed))] 196 + ResolutionFailed { nsid: SmolStr, message: SmolStr }, 197 198 #[error("invalid collection NSID")] 199 #[diagnostic(code(jacquard::lexicon::invalid_collection))] ··· 262 &self, 263 nsid: &Nsid<'_>, 264 ) -> std::result::Result<ResolvedLexiconSchema<'static>, LexiconResolutionError> { 265 + #[cfg(feature = "tracing")] 266 + tracing::debug!("resolving lexicon via XRPC: {}", nsid); 267 + 268 + let mut url = 269 + Url::parse("https://public.api.bsky.app").expect("hardcoded URL should be valid"); 270 + 271 + url.set_path("/xrpc/com.atproto.lexicon.resolveLexicon"); 272 + 273 + let qs = serde_html_form::to_string(&ResolveLexicon::new().nsid(nsid.clone()).build()) 274 + .map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?; 275 + url.set_query(Some(&qs)); 276 + 277 + #[cfg(feature = "tracing")] 278 + tracing::debug!("fetching from URL: {}", url); 279 + 280 + let (buf, status) = self 281 + .get_json_bytes(url) 282 + .await 283 + .map_err(|e| LexiconResolutionError::fetch_failed(nsid.as_str(), e))?; 284 + 285 + #[cfg(feature = "tracing")] 286 + tracing::debug!("got response with status: {}", status); 287 + 288 + if !status.is_success() { 289 + return Err(LexiconResolutionError::resolution_failed( 290 + nsid.as_str(), 291 + format!("HTTP {}", status.as_u16()), 292 + )); 293 } 294 295 + let val = serde_json::from_slice::<serde_json::Value>(&buf) 296 + .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; 297 + 298 + #[cfg(feature = "tracing")] 299 + tracing::debug!("parsed JSON response"); 300 + 301 + let obj = val.as_object().ok_or_else(|| { 302 + LexiconResolutionError::resolution_failed(nsid.as_str(), "response not an object") 303 + })?; 304 + 305 + let schema_val = obj.get("schema").ok_or_else(|| { 306 + #[cfg(feature = "tracing")] 307 + tracing::error!( 308 + "response missing 'schema' field, got keys: {:?}", 309 + obj.keys().collect::<Vec<_>>() 310 + ); 311 + 312 + LexiconResolutionError::resolution_failed(nsid.as_str(), "missing 'schema' field") 313 + })?; 314 + 315 + #[cfg(feature = "tracing")] 316 + tracing::debug!("found schema field in response"); 317 + 318 + let schema = from_json_value::<LexiconDoc>(schema_val.clone()) 319 + .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; 320 + 321 + let uri_str = obj.get("uri").and_then(|v| v.as_str()).ok_or_else(|| { 322 + LexiconResolutionError::resolution_failed( 323 + nsid.as_str(), 324 + "missing or invalid 'uri' field", 325 + ) 326 + })?; 327 + 328 + let cid_str = obj.get("cid").and_then(|v| v.as_str()).ok_or_else(|| { 329 + LexiconResolutionError::resolution_failed( 330 + nsid.as_str(), 331 + "missing or invalid 'cid' field", 332 + ) 333 + })?; 334 + 335 + let uri = AtUri::new_owned(uri_str) 336 + .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; 337 + 338 + let cid = Cid::str(cid_str).into_static(); 339 + let repo = Did::raw(uri.authority().as_str()).into_static(); 340 + 341 + #[cfg(feature = "tracing")] 342 + tracing::debug!("successfully resolved lexicon schema for {}", nsid); 343 + 344 + Ok(ResolvedLexiconSchema { 345 + repo, 346 + cid, 347 + nsid: nsid.clone().into_static(), 348 + doc: schema.into_static(), 349 + }) 350 } 351 } 352 ··· 394 impl LexiconAuthorityResolver for crate::JacquardResolver { 395 async fn resolve_lexicon_authority( 396 &self, 397 + nsid: &Nsid<'_>, 398 ) -> std::result::Result<Did<'static>, LexiconResolutionError> { 399 + // Use DNS-over-HTTPS fallback for WASM/non-DNS builds 400 + self.resolve_lexicon_authority_doh(nsid).await 401 + } 402 + } 403 + 404 + impl crate::JacquardResolver { 405 + /// Resolve lexicon authority via DNS-over-HTTPS (for WASM compatibility) 406 + #[allow(dead_code)] 407 + async fn resolve_lexicon_authority_doh( 408 + &self, 409 + nsid: &Nsid<'_>, 410 + ) -> std::result::Result<Did<'static>, LexiconResolutionError> { 411 + // Try cache first 412 + #[cfg(feature = "cache")] 413 + if let Some(caches) = &self.caches { 414 + let authority = jacquard_common::smol_str::SmolStr::from(nsid.domain_authority()); 415 + if let Some(did) = crate::cache_impl::get(&caches.authority_to_did, &authority) { 416 + return Ok(did); 417 + } 418 + } 419 + 420 + let authority = nsid.domain_authority(); 421 + let reversed_authority = authority.split('.').rev().collect::<Vec<_>>().join("."); 422 + let fqdn = format!("_lexicon.{}.", reversed_authority); 423 + 424 + #[cfg(feature = "tracing")] 425 + tracing::trace!("resolving lexicon authority via DoH: {}", fqdn); 426 + 427 + let response = self 428 + .query_dns_doh(&fqdn, "TXT") 429 + .await 430 + .map_err(|e| LexiconResolutionError::dns_lookup_failed(authority, e))?; 431 + 432 + // Parse DoH JSON response 433 + let answers = response 434 + .get("Answer") 435 + .and_then(|a| a.as_array()) 436 + .ok_or_else(|| LexiconResolutionError::no_did_found(authority))?; 437 + 438 + for answer in answers { 439 + if let Some(data) = answer.get("data").and_then(|d| d.as_str()) { 440 + // TXT records are quoted in DNS responses, strip quotes 441 + let txt_data = data.trim_matches('"'); 442 + 443 + if let Some(did_str) = txt_data.strip_prefix("did=") { 444 + let result = Did::new_owned(did_str) 445 + .map(|d| d.into_static()) 446 + .map_err(|_| LexiconResolutionError::invalid_did(authority, did_str)); 447 + 448 + // Cache on success 449 + if let Ok(ref did) = result { 450 + #[cfg(feature = "cache")] 451 + if let Some(caches) = &self.caches { 452 + let authority_key = jacquard_common::smol_str::SmolStr::from(authority); 453 + crate::cache_impl::insert( 454 + &caches.authority_to_did, 455 + authority_key, 456 + did.clone(), 457 + ); 458 + } 459 + } 460 + 461 + return result; 462 + } 463 + } 464 + } 465 + 466 + Err(LexiconResolutionError::no_did_found(authority)) 467 } 468 } 469 ··· 485 } 486 487 // Perform resolution 488 + //#[cfg(feature = "dns")] 489 let result = async { 490 // 1. Resolve authority DID via DNS 491 let authority_did = self.resolve_lexicon_authority(nsid).await?; 492 493 #[cfg(feature = "tracing")] 494 + tracing::trace!( 495 "resolved lexicon authority {} -> {}", 496 nsid.domain_authority(), 497 authority_did ··· 505 .ok_or_else(|| IdentityError::missing_pds_endpoint())?; 506 507 #[cfg(feature = "tracing")] 508 + tracing::trace!("fetching lexicon {} from PDS {}", nsid, pds); 509 510 // 3. Fetch lexicon record via XRPC getRecord 511 let collection = Nsid::new("com.atproto.lexicon.schema") ··· 535 .map_err(|e| LexiconResolutionError::parse_failed(nsid.as_str(), e))?; 536 537 #[cfg(feature = "tracing")] 538 + tracing::trace!("successfully parsed lexicon schema {}", nsid); 539 540 let cid = output 541 .cid ··· 550 }) 551 } 552 .await; 553 554 // Handle result 555 match result {
+66 -9
crates/jacquard-identity/src/lib.rs
··· 477 Ok(out) 478 } 479 480 fn parse_atproto_did_body(body: &str) -> resolver::Result<Did<'static>> { 481 let line = body 482 .lines() ··· 592 'outer: for step in &self.opts.handle_order { 593 match step { 594 HandleStep::DnsTxt => { 595 - #[cfg(feature = "dns")] 596 - { 597 - if let Ok(txts) = self.dns_txt(host).await { 598 - for txt in txts { 599 - if let Some(did_str) = txt.strip_prefix("did=") { 600 - if let Ok(did) = Did::new(did_str) { 601 - resolved_did = Some(did.into_static()); 602 - break 'outer; 603 - } 604 } 605 } 606 }
··· 477 Ok(out) 478 } 479 480 + /// Query DNS via DNS-over-HTTPS using Cloudflare 481 + pub async fn query_dns_doh( 482 + &self, 483 + name: &str, 484 + record_type: &str, 485 + ) -> resolver::Result<serde_json::Value> { 486 + #[cfg(feature = "tracing")] 487 + tracing::trace!("querying DNS via DoH: {} ({})", name, record_type); 488 + 489 + let mut url = Url::parse("https://cloudflare-dns.com/dns-query") 490 + .expect("hardcoded URL should be valid"); 491 + 492 + url.query_pairs_mut() 493 + .append_pair("name", name) 494 + .append_pair("type", record_type); 495 + 496 + let response = self 497 + .http 498 + .get(url) 499 + .header("Accept", "application/dns-json") 500 + .send() 501 + .await?; 502 + 503 + let status = response.status(); 504 + if !status.is_success() { 505 + return Err(IdentityError::http_status(status)); 506 + } 507 + 508 + let json: serde_json::Value = response.json().await?; 509 + Ok(json) 510 + } 511 + 512 + #[cfg(not(feature = "dns"))] 513 + async fn dns_txt(&self, name: &str) -> resolver::Result<Vec<String>> { 514 + let fqdn = format!("_atproto.{name}."); 515 + let response = self 516 + .query_dns_doh(&fqdn, "TXT") 517 + .await 518 + .map_err(|e| IdentityError::dns(e))?; 519 + 520 + // Parse DoH JSON response 521 + let answers = response 522 + .get("Answer") 523 + .and_then(|a| a.as_array()) 524 + .ok_or_else(|| { 525 + IdentityError::invalid_well_known().with_context(format!( 526 + "couldn't parse cloudflare DoH answers looking for {name}" 527 + )) 528 + })?; 529 + 530 + let mut results: Vec<String> = Vec::new(); 531 + for answer in answers { 532 + if let Some(data) = answer.get("data").and_then(|d| d.as_str()) { 533 + // TXT records are quoted in DNS responses, strip quotes 534 + results.push(data.trim_matches('"').to_string()) 535 + } 536 + } 537 + Ok(results) 538 + } 539 + 540 fn parse_atproto_did_body(body: &str) -> resolver::Result<Did<'static>> { 541 let line = body 542 .lines() ··· 652 'outer: for step in &self.opts.handle_order { 653 match step { 654 HandleStep::DnsTxt => { 655 + if let Ok(txts) = self.dns_txt(host).await { 656 + for txt in txts { 657 + if let Some(did_str) = txt.strip_prefix("did=") { 658 + if let Ok(did) = Did::new(did_str) { 659 + resolved_did = Some(did.into_static()); 660 + break 'outer; 661 } 662 } 663 }
+4 -2
crates/jacquard-identity/src/resolver.rs
··· 233 handle_order.push(HandleStep::PdsResolveHandle); 234 #[cfg(target_family = "wasm")] 235 handle_order.push(HandleStep::HttpsWellKnown); 236 237 let mut did_order = vec![]; 238 #[cfg(not(target_family = "wasm"))] ··· 558 Url, 559 560 /// DNS resolution error 561 - #[cfg(all(feature = "dns", not(target_family = "wasm")))] 562 #[error("DNS resolution error")] 563 #[diagnostic( 564 code(jacquard::identity::dns), ··· 667 } 668 669 /// Create a DNS error 670 - #[cfg(all(feature = "dns", not(target_family = "wasm")))] 671 pub fn dns(source: impl std::error::Error + Send + Sync + 'static) -> Self { 672 Self::new(IdentityErrorKind::Dns, Some(Box::new(source))) 673 }
··· 233 handle_order.push(HandleStep::PdsResolveHandle); 234 #[cfg(target_family = "wasm")] 235 handle_order.push(HandleStep::HttpsWellKnown); 236 + #[cfg(target_family = "wasm")] 237 + handle_order.push(HandleStep::DnsTxt); 238 239 let mut did_order = vec![]; 240 #[cfg(not(target_family = "wasm"))] ··· 560 Url, 561 562 /// DNS resolution error 563 + //#[cfg(all(feature = "dns", not(target_family = "wasm")))] 564 #[error("DNS resolution error")] 565 #[diagnostic( 566 code(jacquard::identity::dns), ··· 669 } 670 671 /// Create a DNS error 672 + //#[cfg(all(feature = "dns", not(target_family = "wasm")))] 673 pub fn dns(source: impl std::error::Error + Send + Sync + 'static) -> Self { 674 Self::new(IdentityErrorKind::Dns, Some(Box::new(source))) 675 }
+5 -4
crates/jacquard-lexicon/src/validation.rs
··· 365 cache: DashMap<ValidationCacheKey, Arc<ValidationResult>>, 366 } 367 368 impl SchemaValidator { 369 /// Get the global validator instance 370 pub fn global() -> &'static Self { 371 - static VALIDATOR: LazyLock<SchemaValidator> = LazyLock::new(|| SchemaValidator { 372 - registry: SchemaRegistry::from_inventory(), 373 - cache: DashMap::new(), 374 - }); 375 &VALIDATOR 376 } 377
··· 365 cache: DashMap<ValidationCacheKey, Arc<ValidationResult>>, 366 } 367 368 + static VALIDATOR: LazyLock<SchemaValidator> = LazyLock::new(|| SchemaValidator { 369 + registry: SchemaRegistry::from_inventory(), 370 + cache: DashMap::new(), 371 + }); 372 + 373 impl SchemaValidator { 374 /// Get the global validator instance 375 pub fn global() -> &'static Self { 376 &VALIDATOR 377 } 378