use cid::Cid;
use cid::multihash::Multihash;
use multibase::Base;
use crate::crypto;
use crate::error::{Error, Result};
use super::CasStore;
#[derive(Debug)]
pub struct IpfsCas {
api: reqwest::blocking::Client,
base: String,
}
const MAX_BLOCK_BYTES: usize = 1024 * 1024;
impl IpfsCas {
pub fn from_spec(spec: &str) -> Result<Self> {
let rest = spec
.strip_prefix("ipfs://")
.ok_or_else(|| Error::InvalidArg {
arg: "--casdir",
reason: format!("IPFS CAS spec must start with ipfs://, got '{spec}'"),
})?;
if rest.is_empty() {
return Err(Error::InvalidArg {
arg: "--casdir",
reason: "IPFS CAS spec needs a host, e.g. ipfs://localhost:5001".into(),
});
}
let (scheme, authority) = if rest.contains("://") {
let (s, r) = rest.split_once("://").expect("checked");
(s.to_string(), r.to_string())
} else {
("http".to_string(), rest.to_string())
};
let authority = authority.trim_end_matches('/');
let base = format!("{scheme}://{authority}");
let api = reqwest::blocking::Client::builder()
.pool_max_idle_per_host(0)
.timeout(std::time::Duration::from_secs(60))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| Error::InvalidArg {
arg: "--casdir",
reason: format!("cannot build IPFS client: {e}"),
})?;
Ok(Self { api, base })
}
fn rpc_error(&self, op: &'static str, e: reqwest::Error) -> Error {
Error::CasBackend {
backend: "ipfs",
op,
detail: e.to_string(),
}
}
}
pub fn cid_for_hash(hash: &str) -> Result<String> {
super::validate_cas_hash(hash)?;
let digest = hex::decode(hash).map_err(|_| Error::CasHashInvalid { hash: hash.into() })?;
let mh = Multihash::<64>::wrap(0x16, &digest).expect("sha3-256 digest is 32 bytes");
let cid = Cid::new_v1(0x55, mh);
Ok(cid
.to_string_of_base(Base::Base32Lower)
.expect("base32 of a v1 CID"))
}
pub fn hash_for_cid(cid: &str) -> Result<String> {
let cid = Cid::try_from(cid.to_string()).map_err(|e| Error::CasHashInvalid {
hash: format!("not a CID: {e}"),
})?;
if cid.codec() != 0x55 {
return Err(Error::CasHashInvalid {
hash: format!("CID codec {:#x} is not raw (0x55)", cid.codec()),
});
}
let mh = cid.hash();
if mh.code() != 0x16 {
return Err(Error::CasHashInvalid {
hash: format!("CID multihash {:#x} is not sha3-256 (0x16)", mh.code()),
});
}
let hex = hex::encode(mh.digest());
super::validate_cas_hash(&hex)?;
Ok(hex)
}
impl CasStore for IpfsCas {
fn name(&self) -> &'static str {
"ipfs"
}
fn save(&self, blob: &[u8], policy: &dyn crypto::CryptoPolicy) -> Result<String> {
if blob.len() > MAX_BLOCK_BYTES {
return Err(Error::CasUnsupported {
op: "ipfs save (blob exceeds Kubo's 1 MiB max block size; use the s3 backend for large segments)",
});
}
let hash = crypto::hexdigest("sha3-256", blob, policy)?;
let cid = cid_for_hash(&hash)?;
let part = reqwest::blocking::multipart::Part::bytes(blob.to_vec()).file_name(hash.clone());
let form = reqwest::blocking::multipart::Form::new().part("file", part);
let resp = self
.api
.post(format!(
"{}/api/v0/add?hash=sha3-256&raw-leaves=true&cid-version=1&chunker=size-1048576&pin=true",
self.base
))
.multipart(form)
.send()
.map_err(|e| self.rpc_error("add", e))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
return Err(Error::CasBackend {
backend: "ipfs",
op: "add",
detail: format!("HTTP {} {body}", status.as_u16()),
});
}
let body = resp.text().map_err(|e| self.rpc_error("add (read)", e))?;
let returned = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.get("Key")
.or_else(|| v.get("Hash"))
.and_then(|h| h.as_str())
.map(String::from)
});
match returned {
Some(r) if hash_for_cid(&r)? == hash => Ok(hash),
Some(r) => Err(Error::CasHashMismatch {
expected: cid,
actual: r,
}),
None => Err(Error::CasBackend {
backend: "ipfs",
op: "add",
detail: format!("unparsable response: {body}"),
}),
}
}
fn load(&self, hash: &str, policy: &dyn crypto::CryptoPolicy) -> Result<Vec<u8>> {
super::validate_cas_hash(hash)?;
if !self.contains(hash, policy)? {
return Err(Error::CasNotFound { hash: hash.into() });
}
let cid = cid_for_hash(hash)?;
let resp = self
.api
.post(format!("{}/api/v0/cat?arg={cid}", self.base))
.header(reqwest::header::CONTENT_LENGTH, "0")
.send()
.map_err(|e| self.rpc_error("cat", e))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND
|| resp.status() == reqwest::StatusCode::BAD_REQUEST
{
let body = resp.text().unwrap_or_default();
if body.contains("could not find") || body.contains("not found") {
return Err(Error::CasNotFound { hash: hash.into() });
}
return Err(Error::CasBackend {
backend: "ipfs",
op: "cat",
detail: format!("HTTP {body}"),
});
}
let bytes = resp
.bytes()
.map_err(|e| self.rpc_error("cat (read body)", e))?
.to_vec();
let actual = crypto::hexdigest("sha3-256", &bytes, policy)?;
if actual != hash {
return Err(Error::CasHashMismatch {
expected: hash.into(),
actual,
});
}
Ok(bytes)
}
fn contains(&self, hash: &str, _policy: &dyn crypto::CryptoPolicy) -> Result<bool> {
super::validate_cas_hash(hash)?;
let cid = cid_for_hash(hash)?;
let resp = self
.api
.post(format!(
"{}/api/v0/pin/ls?arg={cid}&type=recursive",
self.base
))
.header(reqwest::header::CONTENT_LENGTH, "0")
.send()
.map_err(|e| self.rpc_error("pin/ls", e))?;
if resp.status().is_success() {
return Ok(true);
}
let body = resp.text().unwrap_or_default();
if body.contains("not pinned") || body.contains("not found") {
return Ok(false);
}
Err(Error::CasBackend {
backend: "ipfs",
op: "pin/ls",
detail: body,
})
}
fn list(&self) -> Result<Vec<String>> {
let resp = self
.api
.post(format!("{}/api/v0/pin/ls?type=recursive", self.base))
.header(reqwest::header::CONTENT_LENGTH, "0")
.send()
.map_err(|e| self.rpc_error("pin/ls", e))?;
if !resp.status().is_success() {
return Err(Error::CasBackend {
backend: "ipfs",
op: "pin/ls",
detail: format!("HTTP {}", resp.status()),
});
}
let body = resp
.text()
.map_err(|e| self.rpc_error("pin/ls (read)", e))?;
let mut hashes = Vec::new();
for line in body.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let Some(keys) = v.get("Keys").and_then(|k| k.as_object()) else {
continue;
};
for cid_str in keys.keys() {
if let Ok(h) = hash_for_cid(cid_str) {
hashes.push(h);
}
}
}
hashes.sort();
hashes.dedup();
Ok(hashes)
}
fn delete(&self, hash: &str) -> Result<()> {
super::validate_cas_hash(hash)?;
let cid = cid_for_hash(hash)?;
let resp = self
.api
.post(format!("{}/api/v0/pin/rm?arg={cid}", self.base))
.header(reqwest::header::CONTENT_LENGTH, "0")
.send()
.map_err(|e| self.rpc_error("pin/rm", e))?;
if !resp.status().is_success() {
return Err(Error::CasBackend {
backend: "ipfs",
op: "pin/rm",
detail: format!("HTTP {}", resp.status()),
});
}
Ok(())
}
}