Skip to main content

ma_core/ipfs/
gateway_resolver.rs

1//! IPFS gateway DID document resolver traits and implementations.
2
3use crate::Document;
4use async_trait::async_trait;
5use std::collections::HashMap;
6use std::sync::Mutex;
7use web_time::{Duration, Instant};
8
9/// Trait for resolving a DID to its DID document.
10///
11/// Ship with `IpfsGatewayResolver` for HTTP gateway resolution.
12/// Implement this trait for custom resolution strategies.
13#[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    /// Update resolver cache TTLs at runtime.
19    ///
20    /// Default implementation is a no-op for resolvers without mutable cache policy.
21    fn set_cache_ttls(&self, _positive_ttl: Duration, _negative_ttl: Duration) {}
22
23    /// Return current resolver cache TTLs when supported.
24    fn cache_ttls(&self) -> Option<(Duration, Duration)> {
25        None
26    }
27}
28
29/// Resolves DID documents via an IPFS/IPNS HTTP gateway.
30///
31/// The gateway must serve DID documents at `/ipns/<key-id>`.
32pub 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    /// Per-request timeout for WASM fetches.  `None` → use the built-in
41    /// 10-second fallback.  Ignored on native (client-level 4 s applies).
42    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    /// Build a resolver using only the built-in gateways (localhost:8080 +
59    /// two public fallbacks). Use [`IpfsGatewayResolver::new`] to add an
60    /// additional primary gateway (e.g. a Kubo node on a non-default port).
61    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    /// Override the per-request timeout used for WASM fetches.
137    /// The default when not set is 10 seconds.
138    /// Has no effect on native (the client-level 4 s timeout applies there).
139    #[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    /// Update the WASM per-request timeout at runtime.
148    /// Pass `None` to revert to the 10-second built-in default.
149    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    // Try DAG-CBOR first (canonical wire format; what dweb.link and Kubo return
350    // when the client sends Accept: application/vnd.ipld.dag-cbor).
351    if let Ok(doc) = Document::decode(bytes) {
352        return Ok(doc);
353    }
354    // Fallback: some gateways (e.g. a local Kubo that ignores the Accept header)
355    // may return DAG-JSON or plain JSON.
356    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/"); // exact duplicate
420        push_gateway(&mut gateways, "https://dweb.link"); // no trailing slash
421        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        // cache_miss_enabled = false → miss should not be returned
475        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        // Set expiry in the past.
491        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}