Skip to main content

ma_core/ipfs/
gateway_resolver.rs

1//! DID document resolution over the IPFS gateway pool.
2//!
3//! Thin composition layer: [`GatewayPool`] does the HTTP work, a
4//! [`TtlCache`] remembers outcomes, and this module only knows how to turn
5//! gateway bytes into validated [`Document`]s.
6
7use super::gateway::GatewayPool;
8use super::ttl_cache::{Cached, TtlCache};
9use crate::Document;
10use async_trait::async_trait;
11use web_time::Duration;
12
13/// Trait for resolving a DID to its DID document.
14///
15/// Ship with `IpfsGatewayResolver` for HTTP gateway resolution.
16/// Implement this trait for custom resolution strategies.
17#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
18#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
19pub trait DidDocumentResolver: Send + Sync {
20    async fn resolve(&self, did: &str) -> crate::error::Result<Document>;
21
22    /// Update resolver cache TTLs at runtime.
23    ///
24    /// Default implementation is a no-op for resolvers without mutable cache policy.
25    fn set_cache_ttls(&self, _positive_ttl: Duration, _negative_ttl: Duration) {}
26
27    /// Return current resolver cache TTLs when supported.
28    fn cache_ttls(&self) -> Option<(Duration, Duration)> {
29        None
30    }
31}
32
33/// Trait for resolving an `/ipns/<name>` path to its current `/ipfs/<cid>` path.
34///
35/// Implemented by [`IpfsGatewayResolver`] (HTTP gateways); alternative
36/// backends (e.g. a local Kubo RPC) can implement it too.
37#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
38#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
39pub trait IpnsPathResolver: Send + Sync {
40    async fn resolve_ipns_path(&self, path: &str) -> crate::error::Result<String>;
41}
42
43/// Resolves DID documents via an IPFS/IPNS HTTP gateway.
44///
45/// The gateway must serve DID documents at `/ipns/<key-id>`.
46/// Cached document bytes and negative outcomes expire on independent TTLs.
47pub struct IpfsGatewayResolver {
48    pool: GatewayPool,
49    cache: TtlCache<Vec<u8>>,
50}
51
52impl Default for IpfsGatewayResolver {
53    /// Build a local-first resolver for development and native runtimes.
54    fn default() -> Self {
55        Self::from_pool(GatewayPool::default())
56    }
57}
58
59impl From<GatewayPool> for IpfsGatewayResolver {
60    fn from(pool: GatewayPool) -> Self {
61        Self::from_pool(pool)
62    }
63}
64
65impl IpfsGatewayResolver {
66    /// Build a public-gateway resolver with no localhost probing.
67    #[must_use]
68    pub fn public_default() -> Self {
69        Self::from_pool(GatewayPool::public_default())
70    }
71
72    /// Build a resolver using the caller-provided primary gateway followed by
73    /// the standard public fallbacks. Localhost is used only if `gateway_url`
74    /// itself points at localhost.
75    #[must_use]
76    pub fn new(gateway_url: impl Into<String>) -> Self {
77        Self::from_pool(GatewayPool::new(gateway_url))
78    }
79
80    /// Build a local-first resolver: localhost, then the caller-provided
81    /// primary gateway, then the standard public fallbacks.
82    #[must_use]
83    pub fn local_first(gateway_url: impl Into<String>) -> Self {
84        Self::from_pool(GatewayPool::local_first(gateway_url))
85    }
86
87    fn from_pool(pool: GatewayPool) -> Self {
88        Self {
89            pool,
90            cache: TtlCache::new(Duration::from_mins(1), Duration::from_secs(10)),
91        }
92    }
93
94    /// The underlying gateway pool, for generic content fetches.
95    #[must_use]
96    pub fn pool(&self) -> &GatewayPool {
97        &self.pool
98    }
99
100    #[must_use]
101    pub fn with_cache_ttls(self, positive_ttl: Duration, negative_ttl: Duration) -> Self {
102        self.cache.set_ttls(positive_ttl, negative_ttl);
103        self
104    }
105
106    /// Override the base per-gateway failure cooldown. The cooldown
107    /// escalates Fibonacci-style per consecutive failure and resets on
108    /// success. `Duration::ZERO` disables cooldowns entirely.
109    #[must_use]
110    pub fn with_base_cooldown(mut self, cooldown: Duration) -> Self {
111        self.pool = self.pool.with_base_cooldown(cooldown);
112        self
113    }
114
115    /// Override the per-request timeout (default 6 seconds). Covers the
116    /// whole request, connection through body transfer.
117    #[must_use]
118    pub fn with_request_timeout(self, timeout: Duration) -> Self {
119        self.pool.set_request_timeout(Some(timeout));
120        self
121    }
122
123    /// Update the per-request timeout at runtime.
124    /// Pass `None` to revert to the 6-second built-in default.
125    pub fn set_request_timeout(&self, timeout: Option<Duration>) {
126        self.pool.set_request_timeout(timeout);
127    }
128
129    /// Resolve an `/ipns/<name>` reference to its current `/ipfs/<cid>` path.
130    pub async fn resolve_ipns_path(&self, path: &str) -> crate::error::Result<String> {
131        self.pool.resolve_ipns_path(path).await
132    }
133
134    fn cached_result(cached: Cached<Vec<u8>>, did: String) -> crate::error::Result<Document> {
135        match cached {
136            Cached::Hit(body) => {
137                parse_document_bytes(&body).map_err(|detail| crate::error::Error::Resolution {
138                    did,
139                    detail: format!("cached document parse failed: {detail}"),
140                })
141            }
142            Cached::Miss(detail) => Err(crate::error::Error::Resolution { did, detail }),
143        }
144    }
145}
146
147#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
148#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
149impl DidDocumentResolver for IpfsGatewayResolver {
150    async fn resolve(&self, did: &str) -> crate::error::Result<Document> {
151        let parsed = crate::Did::try_from(did).map_err(crate::error::Error::Validation)?;
152        let did_key = did.to_string();
153
154        if let Some(cached) = self.cache.read(&did_key) {
155            return Self::cached_result(cached, did_key);
156        }
157
158        let resolve_lock = self.cache.lock_for(&did_key);
159        let _resolve_guard = resolve_lock.lock().await;
160
161        // Another caller may have populated the cache while this caller waited
162        // for the per-DID lock.
163        if let Some(cached) = self.cache.read(&did_key) {
164            self.cache.release_lock(&did_key, &resolve_lock);
165            return Self::cached_result(cached, did_key);
166        }
167
168        let path = format!("/ipns/{}", parsed.ipns);
169        let fetched = self
170            .pool
171            .fetch(&path, Some("application/vnd.ipld.dag-cbor"), |body| {
172                parse_document_bytes(body)
173                    .map(|document| (document, body.to_vec()))
174                    .map_err(|detail| format!("invalid DID document: {detail}"))
175            })
176            .await;
177
178        match fetched {
179            Ok((document, body)) => {
180                self.cache.write_hit(did_key.clone(), body);
181                self.cache.release_lock(&did_key, &resolve_lock);
182                Ok(document)
183            }
184            Err(detail) => {
185                tracing::warn!(did = %did_key, error = %detail, "DID document resolve failed");
186                self.cache.write_miss(did_key.clone(), detail.clone());
187                self.cache.release_lock(&did_key, &resolve_lock);
188                Err(crate::error::Error::Resolution {
189                    did: did_key,
190                    detail,
191                })
192            }
193        }
194    }
195
196    fn set_cache_ttls(&self, positive_ttl: Duration, negative_ttl: Duration) {
197        self.cache.set_ttls(positive_ttl, negative_ttl);
198    }
199
200    fn cache_ttls(&self) -> Option<(Duration, Duration)> {
201        Some((self.cache.positive_ttl(), self.cache.negative_ttl()))
202    }
203}
204
205#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
206#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
207impl IpnsPathResolver for IpfsGatewayResolver {
208    async fn resolve_ipns_path(&self, path: &str) -> crate::error::Result<String> {
209        self.pool.resolve_ipns_path(path).await
210    }
211}
212
213fn parse_document_bytes(bytes: &[u8]) -> std::result::Result<Document, String> {
214    let document =
215        Document::decode(bytes).map_err(|err| format!("DAG-CBOR decode failed: {err}"))?;
216    document
217        .validate()
218        .map_err(|err| format!("document validation failed: {err}"))?;
219    document
220        .verify()
221        .map_err(|err| format!("document proof verification failed: {err}"))?;
222    Ok(document)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::{parse_document_bytes, IpfsGatewayResolver};
228    use crate::{
229        generate_identity_from_secret, ipfs::ttl_cache::Cached,
230        multiformat::signature_multibase_encode, CODEC_EDDSA_SIG,
231    };
232
233    #[test]
234    fn parses_dag_cbor_documents() {
235        let identity = generate_identity_from_secret([7u8; 32]).expect("identity");
236        let cbor = identity.document.encode().expect("cbor");
237        let parsed = parse_document_bytes(&cbor).expect("parsed cbor");
238        assert_eq!(parsed, identity.document);
239    }
240
241    #[test]
242    fn rejects_non_document_payloads() {
243        let err = parse_document_bytes(b"<html>nope</html>").expect_err("invalid payload");
244        assert!(err.contains("DAG-CBOR decode failed"));
245    }
246
247    #[test]
248    fn rejects_json_documents() {
249        let identity = generate_identity_from_secret([5u8; 32]).expect("identity");
250        let json = serde_json::to_vec(&identity.document).expect("json serialize");
251        let err = parse_document_bytes(&json).expect_err("resolver requires DAG-CBOR");
252        assert!(err.contains("DAG-CBOR decode failed"));
253    }
254
255    #[test]
256    fn rejects_document_with_mutated_payload() {
257        let identity = generate_identity_from_secret([9u8; 32]).expect("identity");
258        let mut document = identity.document;
259        document.updated_at = "2026-08-08T12:00:00Z".to_string();
260
261        let err = parse_document_bytes(&document.encode().expect("cbor"))
262            .expect_err("mutated payload must fail proof verification");
263        assert!(err.contains("document proof verification failed"));
264    }
265
266    #[test]
267    fn rejects_document_with_malformed_proof() {
268        let identity = generate_identity_from_secret([11u8; 32]).expect("identity");
269        let mut document = identity.document;
270        document.proof.proof_value = "not-multibase".to_string();
271
272        let err = parse_document_bytes(&document.encode().expect("cbor"))
273            .expect_err("malformed proof must fail verification");
274        assert!(err.contains("document proof verification failed"));
275    }
276
277    #[test]
278    fn rejects_document_with_unknown_proof_key() {
279        let identity = generate_identity_from_secret([13u8; 32]).expect("identity");
280        let mut document = identity.document;
281        document.proof.verification_method = format!("{}#unknown", document.id);
282
283        let err = parse_document_bytes(&document.encode().expect("cbor"))
284            .expect_err("unknown proof key must fail verification");
285        assert!(err.contains("document proof verification failed"));
286    }
287
288    #[test]
289    fn rejects_document_without_assertion_relationship() {
290        let identity = generate_identity_from_secret([15u8; 32]).expect("identity");
291        let mut document = identity.document;
292        document.assertion_method.clear();
293
294        let err = parse_document_bytes(&document.encode().expect("cbor"))
295            .expect_err("missing assertion relationship must fail validation");
296        assert!(err.contains("document validation failed"));
297    }
298
299    #[test]
300    fn rejects_document_with_invalid_signature() {
301        let identity = generate_identity_from_secret([17u8; 32]).expect("identity");
302        let mut document = identity.document;
303        document.proof.proof_value = signature_multibase_encode(CODEC_EDDSA_SIG, &[0; 64]);
304
305        let err = parse_document_bytes(&document.encode().expect("cbor"))
306            .expect_err("invalid signature must fail proof verification");
307        assert!(err.contains("document proof verification failed"));
308    }
309
310    #[test]
311    fn rejects_unverified_cached_document() {
312        let identity = generate_identity_from_secret([19u8; 32]).expect("identity");
313        let did = identity.document.id.clone();
314        let mut document = identity.document;
315        document.updated_at = "2026-08-08T12:00:00Z".to_string();
316
317        let err =
318            IpfsGatewayResolver::cached_result(Cached::Hit(document.encode().expect("cbor")), did)
319                .expect_err("cached document must be proof-verified");
320        assert!(err.to_string().contains("cached document parse failed"));
321    }
322
323    #[test]
324    fn resolver_constructors_delegate_to_pool() {
325        let resolver = IpfsGatewayResolver::new("https://example.test/ipfs");
326        assert_eq!(
327            resolver.pool().gateways(),
328            [
329                "https://example.test/ipfs/".to_string(),
330                "https://dweb.link/".to_string(),
331                "https://4everland.io/".to_string(),
332            ]
333        );
334    }
335}