| + |
use std::collections::HashSet;
|
| |
use std::path::{Path, PathBuf};
|
| + |
use std::str::FromStr;
|
| |
|
| + |
use cid::Cid;
|
| + |
use futures_lite::StreamExt;
|
| |
use iroh::protocol::Router;
|
| + |
use iroh_blobs::api::blobs::{AddPathOptions, ImportMode};
|
| + |
use iroh_blobs::api::Store;
|
| + |
use iroh_blobs::format::collection::Collection;
|
| |
use iroh_blobs::store::fs::FsStore;
|
| - |
use iroh_blobs::BlobsProtocol;
|
| + |
use iroh_blobs::{BlobFormat, BlobsProtocol, Hash, HashAndFormat};
|
| + |
use radicle_artifact::share::cid_utils::{self, ArtifactKind};
|
| |
use radicle_artifact::share::EndpointPreset;
|
| |
|
| |
use crate::error::Error;
|
| |
std::fs::write(path, secret.to_bytes())?;
|
| |
Ok(())
|
| |
}
|
| + |
|
| + |
/// Tag prefix for blobs/collections we are seeding. The presence of the tag
|
| + |
/// + a running `BlobsProtocol` is what makes content reachable to peers.
|
| + |
const SEEDED_PREFIX: &str = "seeded/";
|
| + |
|
| + |
fn seeded_tag(cid: &Cid) -> String {
|
| + |
format!("{SEEDED_PREFIX}{cid}")
|
| + |
}
|
| + |
|
| + |
/// Import a single file into the store, copying its bytes so the original
|
| + |
/// can be moved or deleted later. Verifies the import hash matches the
|
| + |
/// expected CID before returning.
|
| + |
pub async fn import_blob(store: &Store, path: &Path, expected: &Cid) -> Result<Hash, Error> {
|
| + |
let tag = store
|
| + |
.add_path_with_opts(AddPathOptions {
|
| + |
path: path.to_path_buf(),
|
| + |
format: BlobFormat::Raw,
|
| + |
mode: ImportMode::Copy,
|
| + |
})
|
| + |
.with_tag()
|
| + |
.await
|
| + |
.map_err(|e| Error::Iroh(format!("import blob: {e}")))?;
|
| + |
|
| + |
let actual = cid_utils::blake3_hash_to_cid(tag.hash, ArtifactKind::Blob);
|
| + |
if actual != *expected {
|
| + |
return Err(Error::Iroh(format!(
|
| + |
"cid mismatch: expected {expected}, got {actual}"
|
| + |
)));
|
| + |
}
|
| + |
Ok(tag.hash)
|
| + |
}
|
| + |
|
| + |
/// Import a directory as a Collection, copying each file's bytes.
|
| + |
pub async fn import_collection(store: &Store, dir: &Path, expected: &Cid) -> Result<Hash, Error> {
|
| + |
let entries = cid_utils::canonical_walk(dir)
|
| + |
.map_err(|e| Error::Iroh(format!("walk directory: {e}")))?;
|
| + |
|
| + |
let mut pairs: Vec<(String, Hash)> = Vec::new();
|
| + |
for (name, abs) in entries {
|
| + |
let tag = store
|
| + |
.add_path_with_opts(AddPathOptions {
|
| + |
path: abs,
|
| + |
format: BlobFormat::Raw,
|
| + |
mode: ImportMode::Copy,
|
| + |
})
|
| + |
.with_tag()
|
| + |
.await
|
| + |
.map_err(|e| Error::Iroh(format!("import file {name}: {e}")))?;
|
| + |
pairs.push((name, tag.hash));
|
| + |
}
|
| + |
|
| + |
let collection = Collection::from_iter(pairs);
|
| + |
let root_tag = collection
|
| + |
.store(store)
|
| + |
.await
|
| + |
.map_err(|e| Error::Iroh(format!("store collection: {e}")))?;
|
| + |
|
| + |
let actual = cid_utils::blake3_hash_to_cid(root_tag.hash(), ArtifactKind::Collection);
|
| + |
if actual != *expected {
|
| + |
return Err(Error::Iroh(format!(
|
| + |
"cid mismatch: expected {expected}, got {actual}"
|
| + |
)));
|
| + |
}
|
| + |
Ok(root_tag.hash())
|
| + |
}
|
| + |
|
| + |
/// Mark a CID as seeded by setting the `seeded/{cid}` tag pointing at the
|
| + |
/// blob hash with the format appropriate for its kind (raw vs hash-seq).
|
| + |
pub async fn register_seeded(store: &Store, cid: &Cid, hash: Hash) -> Result<(), Error> {
|
| + |
let kind =
|
| + |
cid_utils::artifact_kind(cid).map_err(|e| Error::Iroh(format!("cid kind: {e}")))?;
|
| + |
let value = match kind {
|
| + |
ArtifactKind::Blob => HashAndFormat::raw(hash),
|
| + |
ArtifactKind::Collection => HashAndFormat::hash_seq(hash),
|
| + |
};
|
| + |
store
|
| + |
.tags()
|
| + |
.set(seeded_tag(cid).as_bytes(), value)
|
| + |
.await
|
| + |
.map_err(|e| Error::Iroh(format!("set seeded tag: {e}")))?;
|
| + |
Ok(())
|
| + |
}
|
| + |
|
| + |
pub async fn unregister_seeded(store: &Store, cid: &Cid) -> Result<(), Error> {
|
| + |
store
|
| + |
.tags()
|
| + |
.delete(seeded_tag(cid).as_bytes())
|
| + |
.await
|
| + |
.map_err(|e| Error::Iroh(format!("delete seeded tag: {e}")))?;
|
| + |
Ok(())
|
| + |
}
|
| + |
|
| + |
pub async fn is_seeded(store: &Store, cid: &Cid) -> Result<bool, Error> {
|
| + |
let info = store
|
| + |
.tags()
|
| + |
.get(seeded_tag(cid).as_bytes())
|
| + |
.await
|
| + |
.map_err(|e| Error::Iroh(format!("get seeded tag: {e}")))?;
|
| + |
Ok(info.is_some())
|
| + |
}
|
| + |
|
| + |
/// Return the set of CIDs we currently have seeded locally. Decoding
|
| + |
/// failures (unlikely — we wrote the tags ourselves) are skipped.
|
| + |
pub async fn seeded_cids(store: &Store) -> Result<HashSet<Cid>, Error> {
|
| + |
let mut stream = store
|
| + |
.tags()
|
| + |
.list_prefix(SEEDED_PREFIX.as_bytes())
|
| + |
.await
|
| + |
.map_err(|e| Error::Iroh(format!("list seeded tags: {e}")))?;
|
| + |
|
| + |
let mut out = HashSet::new();
|
| + |
while let Some(item) = stream.next().await {
|
| + |
let info = item.map_err(|e| Error::Iroh(format!("seeded tag stream: {e}")))?;
|
| + |
let name = String::from_utf8_lossy(info.name.as_ref());
|
| + |
if let Some(suffix) = name.strip_prefix(SEEDED_PREFIX) {
|
| + |
if let Ok(cid) = Cid::from_str(suffix) {
|
| + |
out.insert(cid);
|
| + |
}
|
| + |
}
|
| + |
}
|
| + |
Ok(out)
|
| + |
}
|