ma_core/ipfs/
gateway_resolver.rs1use super::gateway::GatewayPool;
8use super::ttl_cache::{Cached, TtlCache};
9use crate::Document;
10use async_trait::async_trait;
11use web_time::Duration;
12
13#[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 fn set_cache_ttls(&self, _positive_ttl: Duration, _negative_ttl: Duration) {}
26
27 fn cache_ttls(&self) -> Option<(Duration, Duration)> {
29 None
30 }
31}
32
33#[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
43pub struct IpfsGatewayResolver {
48 pool: GatewayPool,
49 cache: TtlCache<Vec<u8>>,
50}
51
52impl Default for IpfsGatewayResolver {
53 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 #[must_use]
68 pub fn public_default() -> Self {
69 Self::from_pool(GatewayPool::public_default())
70 }
71
72 #[must_use]
76 pub fn new(gateway_url: impl Into<String>) -> Self {
77 Self::from_pool(GatewayPool::new(gateway_url))
78 }
79
80 #[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 #[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 #[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 #[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 pub fn set_request_timeout(&self, timeout: Option<Duration>) {
126 self.pool.set_request_timeout(timeout);
127 }
128
129 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 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}