forked from
atscan.net/plcbundle-rs
High-performance implementation of plcbundle written in Rust
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}