Skip to main content

ma_core/
resolve.rs

1//! DID document resolution traits and implementations.
2
3use async_trait::async_trait;
4use did_ma::Document;
5#[cfg(not(target_arch = "wasm32"))]
6use std::collections::HashMap;
7#[cfg(not(target_arch = "wasm32"))]
8use std::sync::atomic::{AtomicU64, Ordering};
9#[cfg(not(target_arch = "wasm32"))]
10use std::sync::Mutex;
11use std::time::Duration;
12#[cfg(not(target_arch = "wasm32"))]
13use std::time::Instant;
14
15/// Trait for resolving a DID to its DID document.
16///
17/// Ship with `GatewayResolver` for HTTP gateway resolution.
18/// Implement this trait for custom resolution strategies.
19#[async_trait]
20pub trait DidResolver: Send + Sync {
21    async fn resolve(&self, did: &str) -> crate::error::Result<Document>;
22
23    /// Update resolver cache TTLs at runtime.
24    ///
25    /// Default implementation is a no-op for resolvers without mutable cache policy.
26    fn set_cache_ttls(&self, _positive_ttl: Duration, _negative_ttl: Duration) {}
27
28    /// Return current resolver cache TTLs when supported.
29    fn cache_ttls(&self) -> Option<(Duration, Duration)> {
30        None
31    }
32}
33
34/// Resolves DID documents via an IPFS/IPNS HTTP gateway.
35///
36/// The gateway must serve DID documents at `/ipns/<key-id>`.
37#[cfg(not(target_arch = "wasm32"))]
38pub struct GatewayResolver {
39    gateways: Vec<String>,
40    client: reqwest::Client,
41    positive_ttl_secs: AtomicU64,
42    negative_ttl_secs: AtomicU64,
43    localhost_cooldown: Duration,
44    cache: Mutex<HashMap<String, CacheEntry>>,
45    localhost_blocked_until: Mutex<Option<Instant>>,
46}
47
48#[cfg(not(target_arch = "wasm32"))]
49#[derive(Clone)]
50struct CacheEntry {
51    expires_at: Instant,
52    value: CacheValue,
53}
54
55#[cfg(not(target_arch = "wasm32"))]
56#[derive(Clone)]
57enum CacheValue {
58    Hit(String),
59    Miss(String),
60}
61
62#[cfg(not(target_arch = "wasm32"))]
63impl GatewayResolver {
64    const LOCALHOST_GATEWAY: &'static str = "http://127.0.0.1:8080/";
65    const DEFAULT_PUBLIC_GATEWAYS: [&'static str; 2] = ["https://dweb.link/", "https://w3s.link/"];
66
67    pub fn new(gateway_url: impl Into<String>) -> Self {
68        let primary = normalize_gateway_url(&gateway_url.into());
69
70        let mut gateways = Vec::new();
71        push_gateway(&mut gateways, Self::LOCALHOST_GATEWAY);
72        push_gateway(&mut gateways, &primary);
73        for fallback in Self::DEFAULT_PUBLIC_GATEWAYS {
74            push_gateway(&mut gateways, fallback);
75        }
76
77        let client = reqwest::Client::builder()
78            .timeout(Duration::from_secs(4))
79            .build()
80            .unwrap_or_else(|_| reqwest::Client::new());
81
82        Self {
83            gateways,
84            client,
85            positive_ttl_secs: AtomicU64::new(60),
86            negative_ttl_secs: AtomicU64::new(10),
87            localhost_cooldown: Duration::from_secs(20),
88            cache: Mutex::new(HashMap::new()),
89            localhost_blocked_until: Mutex::new(None),
90        }
91    }
92
93    #[must_use]
94    pub fn with_cache_ttls(self, positive_ttl: Duration, negative_ttl: Duration) -> Self {
95        self.set_cache_ttls_inner(positive_ttl, negative_ttl);
96        self
97    }
98
99    fn set_cache_ttls_inner(&self, positive_ttl: Duration, negative_ttl: Duration) {
100        self.positive_ttl_secs
101            .store(positive_ttl.as_secs(), Ordering::Relaxed);
102        self.negative_ttl_secs
103            .store(negative_ttl.as_secs(), Ordering::Relaxed);
104    }
105
106    fn positive_ttl(&self) -> Duration {
107        Duration::from_secs(self.positive_ttl_secs.load(Ordering::Relaxed))
108    }
109
110    fn negative_ttl(&self) -> Duration {
111        Duration::from_secs(self.negative_ttl_secs.load(Ordering::Relaxed))
112    }
113
114    #[must_use]
115    pub fn with_localhost_cooldown(mut self, cooldown: Duration) -> Self {
116        self.localhost_cooldown = cooldown;
117        self
118    }
119}
120
121#[cfg(not(target_arch = "wasm32"))]
122#[async_trait]
123impl DidResolver for GatewayResolver {
124    async fn resolve(&self, did: &str) -> crate::error::Result<Document> {
125        let parsed = did_ma::Did::try_from(did).map_err(crate::error::Error::Validation)?;
126        let did_key = did.to_string();
127        let positive_ttl = self.positive_ttl();
128        let negative_ttl = self.negative_ttl();
129        let cache_hit_enabled = !positive_ttl.is_zero();
130        let cache_miss_enabled = !negative_ttl.is_zero();
131
132        if let Some(cached) = self.read_cache(&did_key, cache_hit_enabled, cache_miss_enabled) {
133            return match cached {
134                CacheValue::Hit(body) => {
135                    Document::unmarshal(&body).map_err(|e| crate::error::Error::Resolution {
136                        did: did_key,
137                        detail: format!("cached document parse failed: {e}"),
138                    })
139                }
140                CacheValue::Miss(detail) => Err(crate::error::Error::Resolution {
141                    did: did_key,
142                    detail,
143                }),
144            };
145        }
146
147        let mut errors = Vec::new();
148        let now = Instant::now();
149
150        for gateway in &self.gateways {
151            if is_localhost_gateway(gateway) && self.localhost_is_blocked(now) {
152                errors.push(format!("{} -> skipped (cooldown)", gateway));
153                continue;
154            }
155
156            let url = format!("{}ipns/{}", gateway, parsed.ipns);
157
158            let response = match self.client.get(&url).send().await {
159                Ok(response) => response,
160                Err(err) => {
161                    if is_localhost_gateway(gateway) {
162                        self.block_localhost_until(Some(now + self.localhost_cooldown));
163                    }
164                    errors.push(format!("{url} -> {err}"));
165                    continue;
166                }
167            };
168
169            if !response.status().is_success() {
170                if is_localhost_gateway(gateway) {
171                    self.block_localhost_until(Some(now + self.localhost_cooldown));
172                }
173                errors.push(format!("{url} -> HTTP {}", response.status()));
174                continue;
175            }
176
177            let body = match response.text().await {
178                Ok(body) => body,
179                Err(err) => {
180                    if is_localhost_gateway(gateway) {
181                        self.block_localhost_until(Some(now + self.localhost_cooldown));
182                    }
183                    errors.push(format!("{url} -> {err}"));
184                    continue;
185                }
186            };
187
188            let doc = match Document::unmarshal(&body) {
189                Ok(doc) => doc,
190                Err(err) => {
191                    errors.push(format!("{url} -> invalid DID document: {err}"));
192                    continue;
193                }
194            };
195
196            if is_localhost_gateway(gateway) {
197                self.block_localhost_until(None);
198            }
199
200            if cache_hit_enabled {
201                self.write_cache(did_key.clone(), CacheValue::Hit(body), now + positive_ttl);
202            }
203            return Ok(doc);
204        }
205
206        let detail = format!("all gateways failed: {}", errors.join(" | "));
207        if cache_miss_enabled {
208            self.write_cache(
209                did_key.clone(),
210                CacheValue::Miss(detail.clone()),
211                now + negative_ttl,
212            );
213        }
214
215        Err(crate::error::Error::Resolution {
216            did: did_key,
217            detail,
218        })
219    }
220
221    fn set_cache_ttls(&self, positive_ttl: Duration, negative_ttl: Duration) {
222        self.set_cache_ttls_inner(positive_ttl, negative_ttl);
223    }
224
225    fn cache_ttls(&self) -> Option<(Duration, Duration)> {
226        Some((self.positive_ttl(), self.negative_ttl()))
227    }
228}
229
230#[cfg(not(target_arch = "wasm32"))]
231impl GatewayResolver {
232    fn read_cache(
233        &self,
234        did: &str,
235        cache_hit_enabled: bool,
236        cache_miss_enabled: bool,
237    ) -> Option<CacheValue> {
238        if !cache_hit_enabled && !cache_miss_enabled {
239            return None;
240        }
241
242        let mut cache = self.cache.lock().ok()?;
243        let entry = cache.get(did).cloned()?;
244        if entry.expires_at <= Instant::now() {
245            cache.remove(did);
246            return None;
247        }
248
249        match entry.value {
250            CacheValue::Hit(value) if cache_hit_enabled => Some(CacheValue::Hit(value)),
251            CacheValue::Miss(value) if cache_miss_enabled => Some(CacheValue::Miss(value)),
252            _ => None,
253        }
254    }
255
256    fn write_cache(&self, did: String, value: CacheValue, expires_at: Instant) {
257        if let Ok(mut cache) = self.cache.lock() {
258            cache.insert(did, CacheEntry { expires_at, value });
259        }
260    }
261
262    fn localhost_is_blocked(&self, now: Instant) -> bool {
263        let guard = match self.localhost_blocked_until.lock() {
264            Ok(guard) => guard,
265            Err(_) => return false,
266        };
267        guard.as_ref().is_some_and(|blocked| *blocked > now)
268    }
269
270    fn block_localhost_until(&self, until: Option<Instant>) {
271        if let Ok(mut guard) = self.localhost_blocked_until.lock() {
272            *guard = until;
273        }
274    }
275}
276
277#[cfg(not(target_arch = "wasm32"))]
278fn normalize_gateway_url(input: &str) -> String {
279    let mut url = input.trim().to_string();
280    if !url.ends_with('/') {
281        url.push('/');
282    }
283    url
284}
285
286#[cfg(not(target_arch = "wasm32"))]
287fn push_gateway(gateways: &mut Vec<String>, candidate: &str) {
288    let normalized = normalize_gateway_url(candidate);
289    if !gateways.iter().any(|g| g.eq_ignore_ascii_case(&normalized)) {
290        gateways.push(normalized);
291    }
292}
293
294#[cfg(not(target_arch = "wasm32"))]
295fn is_localhost_gateway(gateway: &str) -> bool {
296    gateway.starts_with("http://127.0.0.1:") || gateway.starts_with("http://localhost:")
297}