Skip to main content

ma_core/kubo/
kubo.rs

1//! Kubo RPC client for IPFS operations.
2//!
3//! HTTP helpers for the Kubo `/api/v0/` endpoints: data add/cat, DAG
4//! put/get, IPNS name publish/resolve, key management, and pinning.
5
6use crate::{Did, Document};
7use anyhow::{anyhow, Result};
8use reqwest::multipart;
9use serde::de::DeserializeOwned;
10use serde::{Deserialize, Serialize};
11use tokio::time::sleep;
12use tracing::warn;
13use web_time::Duration;
14
15// ─── Response types ─────────────────────────────────────────────────────────
16
17#[derive(Debug, Deserialize)]
18struct AddResponse {
19    #[serde(rename = "Hash")]
20    hash: String,
21}
22
23#[derive(Debug, Deserialize)]
24struct DagPutCid {
25    #[serde(rename = "/")]
26    slash: String,
27}
28
29#[derive(Debug, Deserialize)]
30struct DagPutResponse {
31    #[serde(default, rename = "Cid")]
32    cid_upper: Option<DagPutCid>,
33    #[serde(default)]
34    cid: Option<DagPutCid>,
35}
36
37#[derive(Debug, Deserialize)]
38struct NamePublishResponse {
39    #[serde(default, rename = "Value")]
40    value_upper: String,
41    #[serde(default, rename = "value")]
42    value_lower: String,
43}
44
45#[derive(Debug, Deserialize)]
46struct NameResolveResponse {
47    #[serde(default, rename = "Path")]
48    path_upper: String,
49    #[serde(default, rename = "path")]
50    path_lower: String,
51}
52
53#[derive(Debug, Deserialize)]
54struct VersionResponse {
55    #[serde(default, rename = "Version")]
56    version_upper: String,
57    #[serde(default, rename = "version")]
58    version_lower: String,
59}
60
61#[derive(Debug, Deserialize)]
62struct KeyListEntry {
63    #[serde(default, rename = "Name")]
64    name: String,
65    #[serde(default, rename = "name")]
66    name_lower: String,
67    #[serde(default, rename = "Id")]
68    id: String,
69    #[serde(default, rename = "id")]
70    id_lower: String,
71}
72
73#[derive(Debug, Deserialize)]
74struct KeyListResponse {
75    #[serde(default, rename = "Keys")]
76    keys: Vec<KeyListEntry>,
77}
78
79#[derive(Debug, Deserialize)]
80struct KeyImportResponse {
81    #[serde(default, rename = "Name")]
82    name_upper: String,
83    #[serde(default, rename = "name")]
84    name_lower: String,
85    #[serde(default, rename = "Id")]
86    id_upper: String,
87    #[serde(default, rename = "id")]
88    id_lower: String,
89}
90
91#[derive(Clone, Debug)]
92pub struct KuboKey {
93    pub name: String,
94    pub id: String,
95}
96
97// ─── Publish options ────────────────────────────────────────────────────────
98
99#[derive(Clone, Debug)]
100pub struct IpnsPublishOptions {
101    pub timeout: Duration,
102    pub allow_offline: bool,
103    pub lifetime: String,
104    pub ttl: Option<String>,
105    pub resolve: bool,
106    pub quieter: bool,
107}
108
109impl Default for IpnsPublishOptions {
110    fn default() -> Self {
111        Self {
112            timeout: Duration::from_mins(2),
113            allow_offline: true,
114            lifetime: "8760h".to_string(),
115            ttl: None,
116            resolve: false,
117            quieter: true,
118        }
119    }
120}
121
122// ─── Readiness ──────────────────────────────────────────────────────────────
123
124pub async fn wait_for_api(kubo_url: &str, attempts: u32) -> Result<()> {
125    if attempts == 0 {
126        return Err(anyhow!("kubo readiness attempts must be >= 1"));
127    }
128
129    let base = kubo_url.trim_end_matches('/');
130    let url = format!("{base}/api/v0/version");
131    let client = reqwest::Client::builder()
132        .timeout(Duration::from_secs(6))
133        .build()?;
134
135    let mut fib_prev = Duration::from_millis(0);
136    let mut fib_curr = Duration::from_millis(200);
137    let mut last_err: Option<anyhow::Error> = None;
138
139    for attempt in 1..=attempts {
140        let result = async {
141            let response = client.post(&url).send().await?.error_for_status()?;
142            let body = response.text().await?;
143            let parsed: VersionResponse = serde_json::from_str(&body)
144                .map_err(|e| anyhow!("failed parsing version response: {} body={}", e, body))?;
145            let version = if !parsed.version_upper.is_empty() {
146                parsed.version_upper
147            } else {
148                parsed.version_lower
149            };
150            if version.trim().is_empty() {
151                return Err(anyhow!("missing version field in response: {}", body));
152            }
153            Ok::<(), anyhow::Error>(())
154        }
155        .await;
156
157        match result {
158            Ok(()) => return Ok(()),
159            Err(err) => {
160                warn!("kubo readiness {}/{}: {}", attempt, attempts, err);
161                last_err = Some(err);
162                if attempt < attempts {
163                    sleep(fib_curr).await;
164                    let next_ms = fib_prev.as_millis().saturating_add(fib_curr.as_millis());
165                    fib_prev = fib_curr;
166                    fib_curr = Duration::from_millis(std::cmp::min(next_ms, 5_000) as u64);
167                }
168            }
169        }
170    }
171    Err(anyhow!(
172        "kubo API not ready after {} attempts: {}",
173        attempts,
174        last_err
175            .map(|e| e.to_string())
176            .unwrap_or_else(|| "unknown error".to_string())
177    ))
178}
179
180// ─── Add / Cat ──────────────────────────────────────────────────────────────
181
182pub async fn ipfs_add(kubo_url: &str, data: Vec<u8>) -> Result<String> {
183    let base = kubo_url.trim_end_matches('/');
184    let url = format!("{base}/api/v0/add");
185
186    let part = multipart::Part::bytes(data).file_name("data");
187    let form = multipart::Form::new().part("file", part);
188
189    let client = reqwest::Client::builder()
190        .timeout(Duration::from_secs(10))
191        .build()?;
192
193    let body = client
194        .post(url)
195        .query(&[("pin", "true")])
196        .multipart(form)
197        .send()
198        .await?
199        .error_for_status()?
200        .text()
201        .await?;
202
203    let parsed: AddResponse = serde_json::from_str(&body)
204        .map_err(|e| anyhow!("failed parsing add response: {} body={}", e, body))?;
205    Ok(parsed.hash)
206}
207
208pub async fn cat_bytes(kubo_url: &str, cid: &str) -> Result<Vec<u8>> {
209    let base = kubo_url.trim_end_matches('/');
210    let url = format!("{base}/api/v0/cat");
211
212    let client = reqwest::Client::builder()
213        .timeout(Duration::from_secs(30))
214        .build()?;
215
216    let bytes = client
217        .post(url)
218        .query(&[("arg", cid)])
219        .send()
220        .await?
221        .error_for_status()?
222        .bytes()
223        .await?;
224
225    Ok(bytes.to_vec())
226}
227
228pub async fn cat_text(kubo_url: &str, cid: &str) -> Result<String> {
229    let bytes = cat_bytes(kubo_url, cid).await?;
230    String::from_utf8(bytes).map_err(|e| anyhow!("non-utf8 content from {}: {}", cid, e))
231}
232
233// ─── DAG ────────────────────────────────────────────────────────────────────
234
235pub async fn dag_put<T: Serialize>(kubo_url: &str, value: &T) -> Result<String> {
236    let base = kubo_url.trim_end_matches('/');
237    let url = format!("{base}/api/v0/dag/put");
238    let payload = serde_json::to_vec(value)?;
239
240    let part = multipart::Part::bytes(payload)
241        .file_name("node.json")
242        .mime_str("application/json")?;
243    let form = multipart::Form::new().part("file", part);
244
245    let client = reqwest::Client::builder()
246        .timeout(Duration::from_secs(10))
247        .build()?;
248
249    let body = client
250        .post(url)
251        .query(&[
252            ("store-codec", "dag-cbor"),
253            ("input-codec", "dag-json"),
254            ("pin", "true"),
255        ])
256        .multipart(form)
257        .send()
258        .await?
259        .error_for_status()?
260        .text()
261        .await?;
262
263    let parsed: DagPutResponse = serde_json::from_str(&body)
264        .map_err(|e| anyhow!("failed parsing dag/put response: {} body={}", e, body))?;
265    parsed
266        .cid_upper
267        .or(parsed.cid)
268        .map(|c| c.slash)
269        .ok_or_else(|| anyhow!("missing CID in dag/put response: {}", body))
270}
271
272pub async fn dag_get<T: DeserializeOwned>(kubo_url: &str, cid: &str) -> Result<T> {
273    let base = kubo_url.trim_end_matches('/');
274    let url = format!("{base}/api/v0/dag/get");
275
276    let client = reqwest::Client::builder()
277        .timeout(Duration::from_secs(10))
278        .build()?;
279
280    let body = client
281        .post(url)
282        .query(&[("arg", cid)])
283        .send()
284        .await?
285        .error_for_status()?
286        .text()
287        .await?;
288
289    serde_json::from_str::<T>(&body).map_err(|e| {
290        anyhow!(
291            "failed parsing dag/get response for {}: {} body={}",
292            cid,
293            e,
294            body
295        )
296    })
297}
298
299// ─── Name publish / resolve ─────────────────────────────────────────────────
300
301fn normalize_ipfs_arg(cid_or_path: &str) -> String {
302    let mut value = cid_or_path.trim().to_string();
303    while let Some(rest) = value.strip_prefix("/ipfs/") {
304        value = rest.to_string();
305    }
306    while let Some(rest) = value.strip_prefix('/') {
307        value = rest.to_string();
308    }
309    format!("/ipfs/{value}")
310}
311
312pub async fn name_publish(kubo_url: &str, key_name: &str, cid: &str) -> Result<String> {
313    let options = IpnsPublishOptions::default();
314    name_publish_with_options(kubo_url, key_name, cid, &options).await
315}
316
317pub async fn name_publish_with_options(
318    kubo_url: &str,
319    key_name: &str,
320    cid: &str,
321    options: &IpnsPublishOptions,
322) -> Result<String> {
323    let base = kubo_url.trim_end_matches('/');
324    let url = format!("{base}/api/v0/name/publish");
325    let arg = normalize_ipfs_arg(cid);
326
327    let client = reqwest::Client::builder()
328        .timeout(options.timeout)
329        .build()?;
330
331    let allow_offline = if options.allow_offline {
332        "true"
333    } else {
334        "false"
335    };
336    let resolve = if options.resolve { "true" } else { "false" };
337    let quieter = if options.quieter { "true" } else { "false" };
338
339    let mut params: Vec<(&str, &str)> = vec![
340        ("arg", arg.as_str()),
341        ("key", key_name),
342        ("allow-offline", allow_offline),
343        ("lifetime", options.lifetime.as_str()),
344        ("resolve", resolve),
345        ("quieter", quieter),
346    ];
347    if let Some(ref ttl) = options.ttl {
348        params.push(("ttl", ttl.as_str()));
349    }
350
351    let body = client
352        .post(url)
353        .query(&params)
354        .send()
355        .await?
356        .error_for_status()?
357        .text()
358        .await?;
359
360    let parsed: NamePublishResponse = serde_json::from_str(&body)
361        .map_err(|e| anyhow!("failed parsing name/publish response: {} body={}", e, body))?;
362    let value = if !parsed.value_upper.is_empty() {
363        parsed.value_upper
364    } else {
365        parsed.value_lower
366    };
367    if value.is_empty() {
368        return Err(anyhow!("missing value in name/publish response: {}", body));
369    }
370    Ok(value)
371}
372
373pub async fn name_publish_with_retry(
374    kubo_url: &str,
375    key_name: &str,
376    cid: &str,
377    options: &IpnsPublishOptions,
378    attempts: u32,
379    initial_backoff: Duration,
380) -> Result<String> {
381    if attempts == 0 {
382        return Err(anyhow!("name publish attempts must be >= 1"));
383    }
384
385    let mut fib_prev = Duration::from_millis(0);
386    let mut fib_curr = initial_backoff;
387    let mut last_err: Option<anyhow::Error> = None;
388
389    for attempt in 1..=attempts {
390        match name_publish_with_options(kubo_url, key_name, cid, options).await {
391            Ok(value) => return Ok(value),
392            Err(err) => {
393                if let Ok(value) = verify_name_target_after_error(kubo_url, key_name, cid).await {
394                    warn!(
395                        "name publish attempt {}/{} reported error for key '{}' but resolve confirms target; accepting: {}",
396                        attempt, attempts, key_name, value
397                    );
398                    return Ok(value);
399                }
400                warn!(
401                    "name publish attempt {}/{} failed for key '{}' cid '{}': {}",
402                    attempt, attempts, key_name, cid, err
403                );
404                last_err = Some(err);
405                if attempt < attempts {
406                    sleep(fib_curr).await;
407                    let next_ms = fib_prev.as_millis().saturating_add(fib_curr.as_millis());
408                    fib_prev = fib_curr;
409                    fib_curr = Duration::from_millis(std::cmp::min(next_ms, 30_000) as u64);
410                }
411            }
412        }
413    }
414
415    Err(anyhow!(
416        "name publish failed after {} attempt(s): {}",
417        attempts,
418        last_err
419            .map(|e| e.to_string())
420            .unwrap_or_else(|| "unknown error".to_string())
421    ))
422}
423
424async fn verify_name_target_after_error(
425    kubo_url: &str,
426    key_name: &str,
427    cid: &str,
428) -> Result<String> {
429    let expected = normalize_ipfs_arg(cid);
430    let resolved = name_resolve(kubo_url, &format!("/ipns/{key_name}"), true).await?;
431    if resolved.trim() == expected {
432        return Ok(resolved);
433    }
434    Err(anyhow!(
435        "post-error resolve mismatch for key '{}': expected '{}' got '{}'",
436        key_name,
437        expected,
438        resolved
439    ))
440}
441
442pub async fn name_resolve(kubo_url: &str, path: &str, recursive: bool) -> Result<String> {
443    let base = kubo_url.trim_end_matches('/');
444    let url = format!("{base}/api/v0/name/resolve");
445
446    let client = reqwest::Client::builder()
447        .timeout(Duration::from_secs(15))
448        .build()?;
449
450    let recursive_flag = if recursive { "true" } else { "false" };
451    let body = client
452        .post(url)
453        .query(&[("arg", path), ("recursive", recursive_flag)])
454        .send()
455        .await?
456        .error_for_status()?
457        .text()
458        .await?;
459
460    let parsed: NameResolveResponse = serde_json::from_str(&body)
461        .map_err(|e| anyhow!("failed parsing name/resolve response: {} body={}", e, body))?;
462    let resolved = if !parsed.path_upper.is_empty() {
463        parsed.path_upper
464    } else {
465        parsed.path_lower
466    };
467    if resolved.is_empty() {
468        return Err(anyhow!("missing path in name/resolve response: {}", body));
469    }
470    Ok(resolved)
471}
472
473// ─── DID document fetch ─────────────────────────────────────────────────────
474
475pub async fn fetch_did_document(kubo_url: &str, did: &Did) -> Result<Document> {
476    let ipns_path = format!("/ipns/{}", did.ipns);
477    let mut fib_prev = Duration::from_millis(0);
478    let mut fib_curr = Duration::from_millis(150);
479    let mut last_err: Option<anyhow::Error> = None;
480    let mut document: Option<Document> = None;
481
482    for attempt in 1..=4 {
483        // DID documents are stored as DAG-CBOR via dag/put.
484        let dag_err = match dag_get::<Document>(kubo_url, &ipns_path).await {
485            Ok(doc) => {
486                document = Some(doc);
487                break;
488            }
489            Err(e) => e,
490        };
491
492        // Fallback: resolve IPNS manually then dag_get the CID.
493        match name_resolve(kubo_url, &ipns_path, true).await {
494            Err(resolve_err) => {
495                last_err = Some(anyhow!(
496                    "dag_get and name/resolve both failed for {}: dag={} resolve={}",
497                    ipns_path,
498                    dag_err,
499                    resolve_err
500                ));
501                if !should_retry_name_resolve_error(&resolve_err) {
502                    break;
503                }
504            }
505            Ok(resolved_path) => match dag_get::<Document>(kubo_url, &resolved_path).await {
506                Ok(doc) => {
507                    document = Some(doc);
508                    break;
509                }
510                Err(err) => {
511                    last_err = Some(anyhow!(
512                        "dag_get failed for {}: direct={} resolved={}",
513                        ipns_path,
514                        dag_err,
515                        err
516                    ));
517                }
518            },
519        }
520
521        if attempt < 4 {
522            sleep(fib_curr).await;
523            let next_ms = fib_prev.as_millis().saturating_add(fib_curr.as_millis());
524            fib_prev = fib_curr;
525            fib_curr = Duration::from_millis(std::cmp::min(next_ms, 2_000) as u64);
526        }
527    }
528
529    let document = document.ok_or_else(|| {
530        anyhow!(
531            "failed to fetch DID document for {} via {} after retries: {}",
532            did.id(),
533            ipns_path,
534            last_err
535                .map(|e| e.to_string())
536                .unwrap_or_else(|| "unknown error".to_string())
537        )
538    })?;
539
540    document.validate()?;
541    document.verify()?;
542
543    let doc_did = Did::try_from(document.id.as_str())
544        .map_err(|e| anyhow!("DID document has invalid id '{}': {}", document.id, e))?;
545    if doc_did.ipns != did.ipns {
546        return Err(anyhow!(
547            "DID document IPNS mismatch: expected {} but document id is {}",
548            did.base_id(),
549            document.id
550        ));
551    }
552
553    Ok(document)
554}
555
556fn should_retry_name_resolve_error(err: &anyhow::Error) -> bool {
557    let text = err.to_string().to_ascii_lowercase();
558    if text.contains("http status client error") {
559        return false;
560    }
561    if text.contains("missing path in name/resolve response") {
562        return false;
563    }
564    true
565}
566
567// ─── Pin ────────────────────────────────────────────────────────────────────
568
569pub async fn pin_add_named(kubo_url: &str, cid: &str, name: &str) -> Result<()> {
570    let base = kubo_url.trim_end_matches('/');
571    let url = format!("{base}/api/v0/pin/add");
572    let arg = normalize_ipfs_arg(cid);
573
574    let client = reqwest::Client::builder()
575        .timeout(Duration::from_secs(10))
576        .build()?;
577
578    client
579        .post(url)
580        .query(&[("arg", arg.as_str()), ("recursive", "true"), ("name", name)])
581        .send()
582        .await?
583        .error_for_status()?;
584
585    Ok(())
586}
587
588pub async fn pin_rm(kubo_url: &str, cid: &str) -> Result<()> {
589    let base = kubo_url.trim_end_matches('/');
590    let url = format!("{base}/api/v0/pin/rm");
591    let arg = normalize_ipfs_arg(cid);
592
593    let client = reqwest::Client::builder()
594        .timeout(Duration::from_secs(10))
595        .build()?;
596
597    client
598        .post(url)
599        .query(&[("arg", arg.as_str()), ("recursive", "true")])
600        .send()
601        .await?
602        .error_for_status()?;
603
604    Ok(())
605}
606
607// ─── Key management ─────────────────────────────────────────────────────────
608
609pub async fn generate_key(kubo_url: &str, key_name: &str) -> Result<()> {
610    let base = kubo_url.trim_end_matches('/');
611    let url = format!("{base}/api/v0/key/gen");
612
613    reqwest::Client::builder()
614        .timeout(Duration::from_secs(10))
615        .build()?
616        .post(url)
617        .query(&[("arg", key_name), ("type", "ed25519")])
618        .send()
619        .await?
620        .error_for_status()?;
621
622    Ok(())
623}
624
625pub async fn import_key(kubo_url: &str, key_name: &str, key_bytes: Vec<u8>) -> Result<KuboKey> {
626    let base = kubo_url.trim_end_matches('/');
627    let url = format!("{base}/api/v0/key/import");
628
629    let part = multipart::Part::bytes(key_bytes)
630        .file_name("ipns.key")
631        .mime_str("application/octet-stream")?;
632    let form = multipart::Form::new().part("file", part);
633
634    let response = reqwest::Client::builder()
635        .timeout(Duration::from_secs(10))
636        .build()?
637        .post(url)
638        .query(&[
639            ("arg", key_name),
640            ("ipns-base", "base36"),
641            ("allow-any-key-type", "true"),
642        ])
643        .multipart(form)
644        .send()
645        .await?
646        .error_for_status()?;
647
648    let body = response.text().await?;
649    let parsed: KeyImportResponse = serde_json::from_str(&body)
650        .map_err(|e| anyhow!("failed parsing key/import response: {} body={}", e, body))?;
651
652    let name = if !parsed.name_upper.trim().is_empty() {
653        parsed.name_upper.trim().to_string()
654    } else {
655        parsed.name_lower.trim().to_string()
656    };
657    let id = if !parsed.id_upper.trim().is_empty() {
658        parsed.id_upper.trim().to_string()
659    } else {
660        parsed.id_lower.trim().to_string()
661    };
662
663    if name.is_empty() || id.is_empty() {
664        return Err(anyhow!("missing name/id in key/import response: {}", body));
665    }
666
667    Ok(KuboKey { name, id })
668}
669
670pub async fn list_keys(kubo_url: &str) -> Result<Vec<KuboKey>> {
671    let base = kubo_url.trim_end_matches('/');
672    let url = format!("{base}/api/v0/key/list");
673
674    let body = reqwest::Client::builder()
675        .timeout(Duration::from_secs(10))
676        .build()?
677        .post(url)
678        .send()
679        .await?
680        .error_for_status()?
681        .text()
682        .await?;
683
684    let parsed: KeyListResponse = serde_json::from_str(&body)
685        .map_err(|e| anyhow!("failed parsing key/list response: {} body={}", e, body))?;
686    Ok(parsed
687        .keys
688        .into_iter()
689        .filter_map(|k| {
690            let name = if !k.name.trim().is_empty() {
691                k.name.trim().to_string()
692            } else {
693                k.name_lower.trim().to_string()
694            };
695            let id = if !k.id.trim().is_empty() {
696                k.id.trim().to_string()
697            } else {
698                k.id_lower.trim().to_string()
699            };
700            if name.is_empty() {
701                None
702            } else {
703                Some(KuboKey { name, id })
704            }
705        })
706        .collect())
707}
708
709pub async fn list_key_names(kubo_url: &str) -> Result<Vec<String>> {
710    let keys = list_keys(kubo_url).await?;
711    Ok(keys.into_iter().map(|k| k.name).collect())
712}
713
714/// Remove a named key from the Kubo keystore.
715pub async fn remove_key(kubo_url: &str, key_name: &str) -> Result<()> {
716    let base = kubo_url.trim_end_matches('/');
717    let url = format!("{base}/api/v0/key/rm");
718
719    reqwest::Client::builder()
720        .timeout(Duration::from_secs(10))
721        .build()?
722        .post(url)
723        .query(&[("arg", key_name)])
724        .send()
725        .await?
726        .error_for_status()?;
727
728    Ok(())
729}
730
731// ─── Tests ──────────────────────────────────────────────────────────────────
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736
737    #[test]
738    fn normalize_ipfs_arg_from_raw_cid() {
739        assert_eq!(normalize_ipfs_arg("QmExampleCid"), "/ipfs/QmExampleCid");
740    }
741
742    #[test]
743    fn normalize_ipfs_arg_from_prefixed_path() {
744        assert_eq!(
745            normalize_ipfs_arg("/ipfs/QmExampleCid"),
746            "/ipfs/QmExampleCid"
747        );
748    }
749
750    #[test]
751    fn normalize_ipfs_arg_from_double_prefixed_path() {
752        assert_eq!(
753            normalize_ipfs_arg("/ipfs//ipfs/QmExampleCid"),
754            "/ipfs/QmExampleCid"
755        );
756    }
757
758    #[test]
759    fn does_not_retry_http_client_status_errors() {
760        let err = anyhow!(
761            "HTTP status client error (404 Not Found) for url (http://127.0.0.1:5001/api/v0/name/resolve)"
762        );
763        assert!(!should_retry_name_resolve_error(&err));
764    }
765
766    #[test]
767    fn retries_http_server_status_errors() {
768        let err = anyhow!(
769            "HTTP status server error (500 Internal Server Error) for url (http://127.0.0.1:5001/api/v0/name/resolve)"
770        );
771        assert!(should_retry_name_resolve_error(&err));
772    }
773
774    #[test]
775    fn retries_network_errors() {
776        let err =
777            anyhow!("error sending request for url (http://127.0.0.1:5001/api/v0/name/resolve)");
778        assert!(should_retry_name_resolve_error(&err));
779    }
780}