High-performance implementation of plcbundle written in Rust
at main 255 lines 8.5 kB view raw
1//! AT Protocol handle resolver client using XRPC; includes validation and normalization helpers 2// Handle resolver - resolves AT Protocol handles to DIDs via XRPC 3 4use crate::constants; 5use anyhow::Result; 6use regex::Regex; 7use serde::Deserialize; 8use std::time::Duration; 9 10/// Client for resolving AT Protocol handles to DIDs via XRPC 11pub struct HandleResolver { 12 base_url: String, 13 client: reqwest::Client, 14} 15 16impl HandleResolver { 17 /// Create a new handle resolver client 18 pub fn new(base_url: impl Into<String>) -> Self { 19 let base_url = base_url.into().trim_end_matches('/').to_string(); 20 let client = reqwest::Client::builder() 21 .timeout(Duration::from_secs(10)) 22 .pool_max_idle_per_host(10) 23 .pool_idle_timeout(Duration::from_secs(90)) 24 .http2_keep_alive_interval(Some(Duration::from_secs(30))) 25 .http2_keep_alive_timeout(Duration::from_secs(10)) 26 .tcp_keepalive(Some(Duration::from_secs(60))) 27 .build() 28 .expect("Failed to create HTTP client"); 29 30 Self { base_url, client } 31 } 32 33 /// Get the base URL of the resolver 34 pub fn get_base_url(&self) -> &str { 35 &self.base_url 36 } 37 38 /// Resolve a handle to a DID using com.atproto.identity.resolveHandle 39 pub async fn resolve_handle(&self, handle: &str) -> Result<String> { 40 // Validate handle format 41 validate_handle_format(handle)?; 42 43 // Build XRPC URL 44 let endpoint = format!("{}/xrpc/com.atproto.identity.resolveHandle", self.base_url); 45 let url = reqwest::Url::parse_with_params(&endpoint, &[("handle", handle)])?; 46 47 // Execute request 48 let response = self 49 .client 50 .get(url) 51 .header("User-Agent", constants::user_agent()) 52 .send() 53 .await?; 54 55 if !response.status().is_success() { 56 let status = response.status(); 57 let body = response.text().await.unwrap_or_default(); 58 anyhow::bail!("Resolver returned status {}: {}", status, body); 59 } 60 61 // Parse response 62 #[derive(Deserialize)] 63 struct ResolveResponse { 64 did: String, 65 } 66 67 let result: ResolveResponse = response.json().await?; 68 69 if result.did.is_empty() { 70 anyhow::bail!("Resolver returned empty DID"); 71 } 72 73 // Validate returned DID 74 if !result.did.starts_with("did:plc:") && !result.did.starts_with("did:web:") { 75 anyhow::bail!("Invalid DID format returned: {}", result.did); 76 } 77 78 Ok(result.did) 79 } 80 81 /// Ping the resolver to keep the connection alive 82 /// 83 /// This performs a lightweight health check on the resolver service. 84 /// It resolves a well-known handle (bsky.app) to keep HTTP/2 connections alive. 85 pub async fn ping(&self) -> Result<()> { 86 // Use a well-known handle that should always resolve 87 let test_handle = "bsky.app"; 88 89 // Build XRPC URL 90 let endpoint = format!("{}/xrpc/com.atproto.identity.resolveHandle", self.base_url); 91 let url = reqwest::Url::parse_with_params(&endpoint, &[("handle", test_handle)])?; 92 93 log::trace!("[HandleResolver] Sending ping to {}", url); 94 95 // Execute request with shorter timeout for pings 96 let response = self 97 .client 98 .get(url) 99 .header("User-Agent", constants::user_agent()) 100 .timeout(Duration::from_secs(5)) 101 .send() 102 .await 103 .map_err(|e| { 104 log::trace!("[HandleResolver] Ping request failed: {}", e); 105 e 106 })?; 107 108 let status = response.status(); 109 log::trace!("[HandleResolver] Ping response status: {}", status); 110 111 if !status.is_success() { 112 anyhow::bail!("Resolver ping failed with status {}", status); 113 } 114 115 // Consume the response to complete the request 116 let bytes = response.bytes().await?; 117 log::trace!("[HandleResolver] Ping response: {} bytes", bytes.len()); 118 119 Ok(()) 120 } 121} 122 123/// Validate AT Protocol handle format 124pub fn validate_handle_format(handle: &str) -> Result<()> { 125 if handle.is_empty() { 126 anyhow::bail!("Handle cannot be empty"); 127 } 128 129 // Handle can't be a DID 130 if handle.starts_with("did:") { 131 anyhow::bail!("Input is already a DID, not a handle"); 132 } 133 134 // Basic length check 135 if handle.len() > 253 { 136 anyhow::bail!("Handle too long (max 253 chars)"); 137 } 138 139 // Must have at least one dot (domain.tld) 140 if !handle.contains('.') { 141 anyhow::bail!("Handle must be a domain (e.g., user.bsky.social)"); 142 } 143 144 // Valid handle pattern (simplified - matches AT Protocol spec) 145 let valid_pattern = Regex::new(r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$") 146 .expect("Invalid regex pattern"); 147 148 if !valid_pattern.is_match(handle) { 149 anyhow::bail!("Invalid handle format"); 150 } 151 152 Ok(()) 153} 154 155/// Check if a string looks like a handle (not a DID) 156pub fn is_handle(input: &str) -> bool { 157 !input.starts_with("did:") 158} 159 160/// Normalize handle format (removes at:// prefix if present) 161pub fn normalize_handle(handle: &str) -> String { 162 handle 163 .trim_start_matches("at://") 164 .trim_start_matches("@") 165 .to_string() 166} 167 168#[cfg(test)] 169mod tests { 170 use super::*; 171 172 #[test] 173 fn test_validate_handle_format_valid() { 174 // Valid handles 175 assert!(validate_handle_format("user.bsky.social").is_ok()); 176 assert!(validate_handle_format("example.com").is_ok()); 177 assert!(validate_handle_format("test.example.org").is_ok()); 178 assert!(validate_handle_format("a.b").is_ok()); 179 assert!(validate_handle_format("sub.domain.example.com").is_ok()); 180 } 181 182 #[test] 183 fn test_validate_handle_format_empty() { 184 let result = validate_handle_format(""); 185 assert!(result.is_err()); 186 assert!(result.unwrap_err().to_string().contains("cannot be empty")); 187 } 188 189 #[test] 190 fn test_validate_handle_format_did() { 191 let result = validate_handle_format("did:plc:test"); 192 assert!(result.is_err()); 193 assert!(result.unwrap_err().to_string().contains("already a DID")); 194 } 195 196 #[test] 197 fn test_validate_handle_format_no_dot() { 198 let result = validate_handle_format("nodomain"); 199 assert!(result.is_err()); 200 assert!(result.unwrap_err().to_string().contains("must be a domain")); 201 } 202 203 #[test] 204 fn test_validate_handle_format_too_long() { 205 let long_handle = "a".repeat(254) + ".com"; 206 let result = validate_handle_format(&long_handle); 207 assert!(result.is_err()); 208 assert!(result.unwrap_err().to_string().contains("too long")); 209 } 210 211 #[test] 212 fn test_validate_handle_format_invalid_chars() { 213 // Invalid characters 214 assert!(validate_handle_format("user@bsky.social").is_err()); 215 assert!(validate_handle_format("user bsky.social").is_err()); 216 assert!(validate_handle_format("user_bsky.social").is_err()); 217 } 218 219 #[test] 220 fn test_is_handle() { 221 assert!(is_handle("user.bsky.social")); 222 assert!(is_handle("example.com")); 223 assert!(!is_handle("did:plc:test")); 224 assert!(!is_handle("did:web:example.com")); 225 assert!(!is_handle("did:key:z6Mk")); 226 } 227 228 #[test] 229 fn test_normalize_handle() { 230 assert_eq!(normalize_handle("user.bsky.social"), "user.bsky.social"); 231 assert_eq!( 232 normalize_handle("at://user.bsky.social"), 233 "user.bsky.social" 234 ); 235 assert_eq!(normalize_handle("@user.bsky.social"), "user.bsky.social"); 236 // Note: trim_start_matches removes all matches, so "at://@user" becomes "user" (both prefixes removed) 237 assert_eq!( 238 normalize_handle("at://@user.bsky.social"), 239 "user.bsky.social" 240 ); 241 assert_eq!(normalize_handle("example.com"), "example.com"); 242 } 243 244 #[test] 245 fn test_handle_resolver_new() { 246 let resolver = HandleResolver::new("https://example.com"); 247 assert_eq!(resolver.get_base_url(), "https://example.com"); 248 } 249 250 #[test] 251 fn test_handle_resolver_new_trim_slash() { 252 let resolver = HandleResolver::new("https://example.com/"); 253 assert_eq!(resolver.get_base_url(), "https://example.com"); 254 } 255}