Radish alpha
r
Radicle desktop app
Radicle
Git (anonymous pull)
Log in to clone via SSH
Add seeder import + tag helpers
Daniel Norman committed 7 days ago
commit 443ac67d49434a7e3d228bfce25600b7b91211d2
parent 7cc9bd20f09995bf13e6790f1c0c67f78d85c3a2
1 file changed +131 -1
modified crates/radicle-types/src/seeder.rs
@@ -1,8 +1,16 @@
+
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;
@@ -73,3 +81,125 @@ fn write_key(path: &Path, secret: &iroh::SecretKey) -> Result<(), 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)
+
}