1use crate::Document;
4use async_trait::async_trait;
5use std::collections::HashMap;
6use std::sync::Mutex;
7use web_time::{Duration, Instant};
8
9#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
14#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
15pub trait DidDocumentResolver: Send + Sync {
16 async fn resolve(&self, did: &str) -> crate::error::Result<Document>;
17
18 fn set_cache_ttls(&self, _positive_ttl: Duration, _negative_ttl: Duration) {}
22
23 fn cache_ttls(&self) -> Option<(Duration, Duration)> {
25 None
26 }
27}
28
29pub struct IpfsGatewayResolver {
33 gateways: Vec<String>,
34 client: reqwest::Client,
35 positive_ttl: Mutex<Duration>,
36 negative_ttl: Mutex<Duration>,
37 localhost_cooldown: Duration,
38 cache: Mutex<HashMap<String, CacheEntry>>,
39 localhost_blocked_until: Mutex<Option<Instant>>,
40 wasm_request_timeout: Mutex<Option<Duration>>,
43}
44
45#[derive(Clone)]
46struct CacheEntry {
47 expires_at: Instant,
48 value: CacheValue,
49}
50
51#[derive(Clone)]
52enum CacheValue {
53 Hit(Vec<u8>),
54 Miss(String),
55}
56
57impl Default for IpfsGatewayResolver {
58 fn default() -> Self {
62 Self::new(Self::LOCALHOST_GATEWAY)
63 }
64}
65
66impl IpfsGatewayResolver {
67 const LOCALHOST_GATEWAY: &'static str = "http://127.0.0.1:8080/";
68 const DEFAULT_PUBLIC_GATEWAYS: [&'static str; 2] = ["https://dweb.link/", "https://w3s.link/"];
69
70 pub fn new(gateway_url: impl Into<String>) -> Self {
71 let primary = normalize_gateway_url(&gateway_url.into());
72
73 let mut gateways = Vec::new();
74 push_gateway(&mut gateways, Self::LOCALHOST_GATEWAY);
75 push_gateway(&mut gateways, &primary);
76 for fallback in Self::DEFAULT_PUBLIC_GATEWAYS {
77 push_gateway(&mut gateways, fallback);
78 }
79
80 #[cfg(not(target_arch = "wasm32"))]
81 let client = reqwest::Client::builder()
82 .timeout(Duration::from_secs(4))
83 .build()
84 .unwrap_or_else(|_| reqwest::Client::new());
85
86 #[cfg(target_arch = "wasm32")]
87 let client = reqwest::Client::builder()
88 .build()
89 .unwrap_or_else(|_| reqwest::Client::new());
90
91 Self {
92 gateways,
93 client,
94 positive_ttl: Mutex::new(Duration::from_mins(1)),
95 negative_ttl: Mutex::new(Duration::from_secs(10)),
96 localhost_cooldown: Duration::from_secs(20),
97 cache: Mutex::new(HashMap::new()),
98 localhost_blocked_until: Mutex::new(None),
99 wasm_request_timeout: Mutex::new(None),
100 }
101 }
102
103 #[must_use]
104 pub fn with_cache_ttls(self, positive_ttl: Duration, negative_ttl: Duration) -> Self {
105 self.set_cache_ttls_inner(positive_ttl, negative_ttl);
106 self
107 }
108
109 fn set_cache_ttls_inner(&self, positive_ttl: Duration, negative_ttl: Duration) {
110 if let Ok(mut ttl) = self.positive_ttl.lock() {
111 *ttl = positive_ttl;
112 }
113 if let Ok(mut ttl) = self.negative_ttl.lock() {
114 *ttl = negative_ttl;
115 }
116 }
117
118 fn positive_ttl(&self) -> Duration {
119 self.positive_ttl
120 .lock()
121 .map_or(Duration::from_secs(0), |ttl| *ttl)
122 }
123
124 fn negative_ttl(&self) -> Duration {
125 self.negative_ttl
126 .lock()
127 .map_or(Duration::from_secs(0), |ttl| *ttl)
128 }
129
130 #[must_use]
131 pub fn with_localhost_cooldown(mut self, cooldown: Duration) -> Self {
132 self.localhost_cooldown = cooldown;
133 self
134 }
135
136 #[must_use]
140 pub fn with_request_timeout(self, timeout: Duration) -> Self {
141 if let Ok(mut t) = self.wasm_request_timeout.lock() {
142 *t = Some(timeout);
143 }
144 self
145 }
146
147 pub fn set_request_timeout(&self, timeout: Option<Duration>) {
150 if let Ok(mut t) = self.wasm_request_timeout.lock() {
151 *t = timeout;
152 }
153 }
154}
155
156#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
157#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
158impl DidDocumentResolver for IpfsGatewayResolver {
159 async fn resolve(&self, did: &str) -> crate::error::Result<Document> {
160 let parsed = crate::Did::try_from(did).map_err(crate::error::Error::Validation)?;
161 let did_key = did.to_string();
162 let positive_ttl = self.positive_ttl();
163 let negative_ttl = self.negative_ttl();
164 let cache_hit_enabled = !positive_ttl.is_zero();
165 let cache_miss_enabled = !negative_ttl.is_zero();
166
167 if let Some(cached) = self.read_cache(&did_key, cache_hit_enabled, cache_miss_enabled) {
168 return match cached {
169 CacheValue::Hit(body) => {
170 parse_document_bytes(&body).map_err(|detail| crate::error::Error::Resolution {
171 did: did_key,
172 detail: format!("cached document parse failed: {detail}"),
173 })
174 }
175 CacheValue::Miss(detail) => Err(crate::error::Error::Resolution {
176 did: did_key,
177 detail,
178 }),
179 };
180 }
181
182 let mut errors = Vec::new();
183 let now = Instant::now();
184
185 for gateway in &self.gateways {
186 if is_localhost_gateway(gateway) && self.localhost_is_blocked(now) {
187 errors.push(format!("{} -> skipped (cooldown)", gateway));
188 continue;
189 }
190
191 let url = format!("{}ipns/{}", gateway, parsed.ipns);
192
193 let req = self
194 .client
195 .get(&url)
196 .header(reqwest::header::ACCEPT, "application/vnd.ipld.dag-cbor");
197 #[cfg(target_arch = "wasm32")]
198 let req = {
199 let timeout = self
200 .wasm_request_timeout
201 .lock()
202 .ok()
203 .and_then(|guard| *guard)
204 .unwrap_or_else(|| Duration::from_secs(10));
205 req.timeout(timeout)
206 };
207 let response = match req.send().await {
208 Ok(response) => response,
209 Err(err) => {
210 if is_localhost_gateway(gateway) {
211 self.block_localhost_until(Some(now + self.localhost_cooldown));
212 }
213 errors.push(format!("{url} -> {err}"));
214 continue;
215 }
216 };
217
218 if !response.status().is_success() {
219 if is_localhost_gateway(gateway) {
220 self.block_localhost_until(Some(now + self.localhost_cooldown));
221 }
222 errors.push(format!("{url} -> HTTP {}", response.status()));
223 continue;
224 }
225
226 let body = match response.bytes().await {
227 Ok(body) => body,
228 Err(err) => {
229 if is_localhost_gateway(gateway) {
230 self.block_localhost_until(Some(now + self.localhost_cooldown));
231 }
232 errors.push(format!("{url} -> {err}"));
233 continue;
234 }
235 };
236
237 let doc = match parse_document_bytes(body.as_ref()) {
238 Ok(doc) => doc,
239 Err(detail) => {
240 errors.push(format!("{url} -> invalid DID document: {detail}"));
241 continue;
242 }
243 };
244
245 if is_localhost_gateway(gateway) {
246 self.block_localhost_until(None);
247 }
248
249 if cache_hit_enabled {
250 self.write_cache(
251 did_key.clone(),
252 CacheValue::Hit(body.to_vec()),
253 now + positive_ttl,
254 );
255 }
256 return Ok(doc);
257 }
258
259 let detail = format!("all gateways failed: {}", errors.join(" | "));
260 if cache_miss_enabled {
261 self.write_cache(
262 did_key.clone(),
263 CacheValue::Miss(detail.clone()),
264 now + negative_ttl,
265 );
266 }
267
268 Err(crate::error::Error::Resolution {
269 did: did_key,
270 detail,
271 })
272 }
273
274 fn set_cache_ttls(&self, positive_ttl: Duration, negative_ttl: Duration) {
275 self.set_cache_ttls_inner(positive_ttl, negative_ttl);
276 }
277
278 fn cache_ttls(&self) -> Option<(Duration, Duration)> {
279 Some((self.positive_ttl(), self.negative_ttl()))
280 }
281}
282
283impl IpfsGatewayResolver {
284 fn read_cache(
285 &self,
286 did: &str,
287 cache_hit_enabled: bool,
288 cache_miss_enabled: bool,
289 ) -> Option<CacheValue> {
290 if !cache_hit_enabled && !cache_miss_enabled {
291 return None;
292 }
293
294 let mut cache = self.cache.lock().ok()?;
295 let entry = cache.get(did).cloned()?;
296 if entry.expires_at <= Instant::now() {
297 cache.remove(did);
298 return None;
299 }
300
301 match entry.value {
302 CacheValue::Hit(value) if cache_hit_enabled => Some(CacheValue::Hit(value)),
303 CacheValue::Miss(value) if cache_miss_enabled => Some(CacheValue::Miss(value)),
304 _ => None,
305 }
306 }
307
308 fn write_cache(&self, did: String, value: CacheValue, expires_at: Instant) {
309 if let Ok(mut cache) = self.cache.lock() {
310 cache.insert(did, CacheEntry { expires_at, value });
311 }
312 }
313
314 fn localhost_is_blocked(&self, now: Instant) -> bool {
315 let guard = match self.localhost_blocked_until.lock() {
316 Ok(guard) => guard,
317 Err(_) => return false,
318 };
319 guard.as_ref().is_some_and(|blocked| *blocked > now)
320 }
321
322 fn block_localhost_until(&self, until: Option<Instant>) {
323 if let Ok(mut guard) = self.localhost_blocked_until.lock() {
324 *guard = until;
325 }
326 }
327}
328
329fn normalize_gateway_url(input: &str) -> String {
330 let mut url = input.trim().to_string();
331 if !url.ends_with('/') {
332 url.push('/');
333 }
334 url
335}
336
337fn push_gateway(gateways: &mut Vec<String>, candidate: &str) {
338 let normalized = normalize_gateway_url(candidate);
339 if !gateways.iter().any(|g| g.eq_ignore_ascii_case(&normalized)) {
340 gateways.push(normalized);
341 }
342}
343
344fn is_localhost_gateway(gateway: &str) -> bool {
345 gateway.starts_with("http://127.0.0.1:") || gateway.starts_with("http://localhost:")
346}
347
348fn parse_document_bytes(bytes: &[u8]) -> std::result::Result<Document, String> {
349 if let Ok(doc) = Document::decode(bytes) {
352 return Ok(doc);
353 }
354 serde_json::from_slice::<Document>(bytes)
357 .map_err(|json_err| format!("CBOR decode failed and JSON fallback also failed: {json_err}"))
358}
359
360#[cfg(test)]
361mod tests {
362 use super::parse_document_bytes;
363 use crate::generate_identity_from_secret;
364
365 #[test]
366 fn parses_dag_cbor_documents() {
367 let identity = generate_identity_from_secret([7u8; 32]).expect("identity");
368 let cbor = identity.document.encode().expect("cbor");
369 let parsed = parse_document_bytes(&cbor).expect("parsed cbor");
370 assert_eq!(parsed, identity.document);
371 }
372
373 #[test]
374 fn rejects_non_document_payloads() {
375 let err = parse_document_bytes(b"<html>nope</html>").expect_err("invalid payload");
376 assert!(err.contains("CBOR decode failed"));
377 }
378
379 #[test]
380 fn parses_json_fallback_when_cbor_fails() {
381 let identity = generate_identity_from_secret([5u8; 32]).expect("identity");
382 let json = serde_json::to_vec(&identity.document).expect("json serialize");
383 let parsed = parse_document_bytes(&json).expect("JSON fallback should succeed");
384 assert_eq!(parsed, identity.document);
385 }
386
387 #[test]
388 fn is_localhost_gateway_matches_local_addresses() {
389 use super::is_localhost_gateway;
390 assert!(is_localhost_gateway("http://127.0.0.1:8080/"));
391 assert!(is_localhost_gateway("http://127.0.0.1:5001/"));
392 assert!(is_localhost_gateway("http://localhost:8080/"));
393 assert!(!is_localhost_gateway("https://dweb.link/"));
394 assert!(!is_localhost_gateway("https://w3s.link/"));
395 }
396
397 #[test]
398 fn normalize_gateway_url_adds_missing_trailing_slash() {
399 use super::normalize_gateway_url;
400 assert_eq!(
401 normalize_gateway_url("https://dweb.link"),
402 "https://dweb.link/"
403 );
404 assert_eq!(
405 normalize_gateway_url("https://dweb.link/"),
406 "https://dweb.link/"
407 );
408 assert_eq!(
409 normalize_gateway_url(" https://dweb.link "),
410 "https://dweb.link/"
411 );
412 }
413
414 #[test]
415 fn push_gateway_deduplicates_case_insensitively() {
416 use super::push_gateway;
417 let mut gateways = Vec::new();
418 push_gateway(&mut gateways, "https://dweb.link/");
419 push_gateway(&mut gateways, "https://dweb.link/"); push_gateway(&mut gateways, "https://dweb.link"); assert_eq!(gateways.len(), 1, "duplicates must not be added");
422 }
423
424 #[test]
425 fn block_localhost_until_and_unblock() {
426 use super::IpfsGatewayResolver;
427 use web_time::{Duration, Instant};
428 let resolver = IpfsGatewayResolver::default();
429 let now = Instant::now();
430
431 assert!(
432 !resolver.localhost_is_blocked(now),
433 "should start unblocked"
434 );
435 resolver.block_localhost_until(Some(now + Duration::from_mins(1)));
436 assert!(
437 resolver.localhost_is_blocked(now),
438 "should be blocked after setting future deadline"
439 );
440 resolver.block_localhost_until(None);
441 assert!(
442 !resolver.localhost_is_blocked(now),
443 "should be unblocked after clearing deadline"
444 );
445 }
446
447 #[test]
448 fn cache_write_and_read_hit() {
449 use super::{CacheValue, IpfsGatewayResolver};
450 use web_time::{Duration, Instant};
451 let resolver = IpfsGatewayResolver::default();
452 let identity = generate_identity_from_secret([9u8; 32]).expect("identity");
453 let cbor = identity.document.encode().expect("cbor");
454 let did = identity.document.id.clone();
455 let expires_at = Instant::now() + Duration::from_mins(1);
456
457 resolver.write_cache(did.clone(), CacheValue::Hit(cbor.clone()), expires_at);
458 let cached = resolver.read_cache(&did, true, true);
459 assert!(matches!(cached, Some(CacheValue::Hit(ref b)) if *b == cbor));
460 }
461
462 #[test]
463 fn cache_miss_not_returned_when_miss_disabled() {
464 use super::{CacheValue, IpfsGatewayResolver};
465 use web_time::{Duration, Instant};
466 let resolver = IpfsGatewayResolver::default();
467 let expires_at = Instant::now() + Duration::from_mins(1);
468
469 resolver.write_cache(
470 "did:ma:test".to_string(),
471 CacheValue::Miss("some error".to_string()),
472 expires_at,
473 );
474 let cached = resolver.read_cache("did:ma:test", true, false);
476 assert!(
477 cached.is_none(),
478 "miss should not be returned when miss-cache is disabled"
479 );
480 }
481
482 #[test]
483 fn expired_cache_entry_is_evicted() {
484 use super::{CacheValue, IpfsGatewayResolver};
485 use web_time::Instant;
486 let resolver = IpfsGatewayResolver::default();
487 let identity = generate_identity_from_secret([11u8; 32]).expect("identity");
488 let cbor = identity.document.encode().expect("cbor");
489 let did = identity.document.id.clone();
490 let already_expired = Instant::now()
492 .checked_sub(web_time::Duration::from_secs(1))
493 .unwrap();
494
495 resolver.write_cache(did.clone(), CacheValue::Hit(cbor), already_expired);
496 let cached = resolver.read_cache(&did, true, true);
497 assert!(cached.is_none(), "expired entry must not be returned");
498 }
499}