1use 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#[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#[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
122pub 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
180pub 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
233pub 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
299fn 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(¶ms)
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
473pub 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 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 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
567pub 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
607pub 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
714pub 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#[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}