1use std::collections::HashMap;
15use std::path::Path;
16use std::time::Duration;
17
18use crate::iroh::EndpointConfig;
19use crate::Error;
20use iroh::protocol::Router;
21use iroh_blobs::api::blobs::{AddPathOptions, ImportMode as IrohImportMode};
22use iroh_blobs::api::{Store, TempTag};
23use iroh_blobs::format::collection::Collection;
24use iroh_blobs::store::fs::{options::Options as FsStoreOptions, FsStore};
25use iroh_blobs::store::GcConfig;
26use iroh_blobs::{BlobFormat, BlobsProtocol, Hash, HashAndFormat};
27use n0_future::StreamExt;
28use radicle::git::Oid;
29use radicle::identity::RepoId;
30use radicle_artifact_core::cid::{self as cid_utils, ArtifactKind, Cid};
31
32pub use radicle_artifact_core::protocol::ImportMode;
33
34fn to_iroh_import_mode(m: ImportMode) -> IrohImportMode {
37 match m {
38 ImportMode::Copy => IrohImportMode::Copy,
39 ImportMode::Reference => IrohImportMode::TryReference,
40 }
41}
42
43pub const ARTIFACTS_DIR: &str = "artifacts";
45
46pub const STORE_DIR: &str = "store";
48
49const SEEDED_TAG_V1: u8 = 0x01;
52
53const ONLINE_TIMEOUT: Duration = Duration::from_secs(10);
55
56const GC_INTERVAL: Duration = Duration::from_secs(60 * 60);
62
63pub struct Seeder {
66 pub blobs: FsStore,
68 pub router: Router,
70 pub pkarr_urls: Vec<url::Url>,
72}
73
74pub async fn bootstrap(home: &Path, secret: iroh::SecretKey) -> Result<Seeder, Error> {
85 let dir = home.join(ARTIFACTS_DIR);
86 std::fs::create_dir_all(&dir).map_err(Error::Io)?;
87
88 let store_dir = dir.join(STORE_DIR);
92 let db_path = store_dir.join("blobs.db");
93 let mut options = FsStoreOptions::new(&store_dir);
94 options.gc = Some(GcConfig {
95 interval: GC_INTERVAL,
96 add_protected: None,
97 });
98 let blobs = FsStore::load_with_opts(db_path, options)
99 .await
100 .map_err(|e| Error::Iroh(format!("FsStore load: {e}")))?;
101
102 let preset = EndpointConfig::from_env()?;
103 tracing::info!("iroh endpoint config: {preset}");
104 let pkarr_urls = preset.pkarr_urls().to_vec();
106 let endpoint = iroh::Endpoint::builder(preset)
107 .secret_key(secret)
108 .bind()
109 .await
110 .map_err(|e| Error::Iroh(format!("endpoint bind: {e}")))?;
111
112 if tokio::time::timeout(ONLINE_TIMEOUT, endpoint.online())
117 .await
118 .is_err()
119 {
120 tracing::warn!("endpoint not relay-connected after {ONLINE_TIMEOUT:?}; continuing anyway");
121 }
122
123 let blobs_protocol = BlobsProtocol::new(&blobs, None);
124 let router = Router::builder(endpoint)
125 .accept(iroh_blobs::ALPN, blobs_protocol)
126 .spawn();
127
128 Ok(Seeder {
129 blobs,
130 router,
131 pkarr_urls,
132 })
133}
134
135fn seeded_tag(rid: &RepoId, release: &Oid, cid: &Cid) -> Vec<u8> {
147 let mut out = seeded_release_prefix(rid, release);
148 out.extend_from_slice(&cid.as_inner().to_bytes());
149 out
150}
151
152fn seeded_rid_prefix(rid: &RepoId) -> Vec<u8> {
154 let rid_b = rid_bytes(rid);
155 let mut out = Vec::with_capacity(2 + rid_b.len());
156 out.push(SEEDED_TAG_V1);
157 out.push(rid_b.len() as u8);
158 out.extend_from_slice(rid_b);
159 out
160}
161
162fn seeded_release_prefix(rid: &RepoId, release: &Oid) -> Vec<u8> {
164 let mut out = seeded_rid_prefix(rid);
165 let rel_b = AsRef::<[u8]>::as_ref(release);
166 out.push(rel_b.len() as u8);
167 out.extend_from_slice(rel_b);
168 out
169}
170
171fn rid_bytes(rid: &RepoId) -> &[u8] {
174 AsRef::<[u8]>::as_ref(&**rid)
175}
176
177fn oid_from_bytes(b: &[u8]) -> Option<Oid> {
180 match b.len() {
181 20 => Some(Oid::from_sha1(b.try_into().ok()?)),
182 _ => None,
185 }
186}
187
188fn parse_seeded_tag(name: &[u8]) -> Option<(RepoId, Oid, Cid)> {
190 let rest = name.strip_prefix(&[SEEDED_TAG_V1])?;
191 let (rid_b, rest) = take_len_prefixed(rest)?;
192 let (rel_b, cid_b) = take_len_prefixed(rest)?;
193 let rid = RepoId::from(oid_from_bytes(rid_b)?);
194 let release = oid_from_bytes(rel_b)?;
195 let cid = Cid::from(cid::Cid::try_from(cid_b).ok()?);
196 Some((rid, release, cid))
197}
198
199fn take_len_prefixed(buf: &[u8]) -> Option<(&[u8], &[u8])> {
201 let (&len, rest) = buf.split_first()?;
202 let len = usize::from(len);
203 if rest.len() < len {
204 return None;
205 }
206 Some(rest.split_at(len))
207}
208
209fn add_opts(path: std::path::PathBuf, mode: ImportMode) -> AddPathOptions {
211 AddPathOptions {
212 path,
213 format: BlobFormat::Raw,
214 mode: to_iroh_import_mode(mode),
215 }
216}
217
218pub async fn import_blob(
227 store: &Store,
228 path: &Path,
229 expected: &Cid,
230 mode: ImportMode,
231) -> Result<(Hash, TempTag), Error> {
232 let abs = dunce::canonicalize(path).map_err(|e| Error::Iroh(format!("canonicalize: {e}")))?;
234 let tt = store
235 .add_path_with_opts(add_opts(abs, mode))
236 .temp_tag()
237 .await
238 .map_err(|e| Error::Iroh(format!("import blob: {e}")))?;
239 let hash = tt.hash();
240
241 let actual = cid_utils::blake3_hash_to_cid(hash.into(), ArtifactKind::Blob);
242 if actual != *expected {
243 return Err(Error::CidMismatch {
244 expected: expected.to_string(),
245 actual: actual.to_string(),
246 });
247 }
248 Ok((hash, tt))
249}
250
251pub async fn import_collection(
262 store: &Store,
263 dir: &Path,
264 expected: &Cid,
265 mode: ImportMode,
266) -> Result<(Hash, TempTag), Error> {
267 let entries = cid_utils::canonical_walk(dir).map_err(Error::Io)?;
268
269 let mut pairs: Vec<(String, Hash)> = Vec::new();
270 let mut file_tags = Vec::with_capacity(entries.len());
273 for (name, abs) in entries {
274 let tt = store
275 .add_path_with_opts(add_opts(abs, mode))
276 .temp_tag()
277 .await
278 .map_err(|e| Error::Iroh(format!("import file {name}: {e}")))?;
279 pairs.push((name, tt.hash()));
280 file_tags.push(tt);
281 }
282
283 let collection = Collection::from_iter(pairs);
284 let root_tag = collection
285 .store(store)
286 .await
287 .map_err(|e| Error::Iroh(format!("store collection: {e}")))?;
288 drop(file_tags);
290
291 let hash = root_tag.hash();
292 let actual = cid_utils::blake3_hash_to_cid(hash.into(), ArtifactKind::Collection);
293 if actual != *expected {
294 return Err(Error::CidMismatch {
295 expected: expected.to_string(),
296 actual: actual.to_string(),
297 });
298 }
299 Ok((hash, root_tag))
300}
301
302fn tag_value(cid: &Cid, hash: Hash) -> Result<HashAndFormat, Error> {
306 Ok(match cid_utils::artifact_kind(cid)? {
307 ArtifactKind::Blob => HashAndFormat::raw(hash),
308 ArtifactKind::Collection => HashAndFormat::hash_seq(hash),
309 })
310}
311
312pub async fn tag_seeded(
318 store: &Store,
319 rid: &RepoId,
320 release: &Oid,
321 cid: &Cid,
322 hash: Hash,
323) -> Result<(), Error> {
324 let value = tag_value(cid, hash)?;
325 store
326 .tags()
327 .set(seeded_tag(rid, release, cid), value)
328 .await
329 .map_err(|e| Error::Iroh(format!("set seeded tag: {e}")))?;
330 Ok(())
331}
332
333pub async fn seed_artifact(
340 store: &Store,
341 rid: &RepoId,
342 release: &Oid,
343 cid: &Cid,
344 path: &Path,
345 kind: ArtifactKind,
346 mode: ImportMode,
347) -> Result<Hash, Error> {
348 let (hash, _tt) = match kind {
349 ArtifactKind::Blob => import_blob(store, path, cid, mode).await?,
350 ArtifactKind::Collection => import_collection(store, path, cid, mode).await?,
351 };
352 tag_seeded(store, rid, release, cid, hash).await?;
353 Ok(hash)
355}
356
357pub async fn untag_seeded(
366 store: &Store,
367 rid: &RepoId,
368 release: &Oid,
369 cid: &Cid,
370) -> Result<bool, Error> {
371 let removed = store
372 .tags()
373 .delete(seeded_tag(rid, release, cid))
374 .await
375 .map_err(|e| Error::Iroh(format!("delete seeded tag: {e}")))?;
376 Ok(removed > 0)
377}
378
379pub async fn untag_all(store: &Store, rid: &RepoId, cid: &Cid) -> Result<usize, Error> {
392 let prefix = seeded_rid_prefix(rid);
393 let mut stream = store
394 .tags()
395 .list_prefix(&prefix)
396 .await
397 .map_err(|e| Error::Iroh(format!("list seeded tags: {e}")))?;
398
399 let mut names = Vec::new();
402 while let Some(item) = stream.next().await {
403 let info = item.map_err(|e| Error::Iroh(format!("seeded tag stream: {e}")))?;
404 if let Some((_, _, tag_cid)) = parse_seeded_tag(info.name.as_ref()) {
405 if &tag_cid == cid {
406 names.push(info.name);
407 }
408 }
409 }
410
411 let mut removed = 0;
414 for name in names {
415 removed += store
416 .tags()
417 .delete(name)
418 .await
419 .map_err(|e| Error::Iroh(format!("delete seeded tag: {e}")))?
420 as usize;
421 }
422 Ok(removed)
423}
424
425pub async fn is_seeded(
427 store: &Store,
428 rid: &RepoId,
429 release: &Oid,
430 cid: &Cid,
431) -> Result<bool, Error> {
432 let info = store
433 .tags()
434 .get(seeded_tag(rid, release, cid))
435 .await
436 .map_err(|e| Error::Iroh(format!("get seeded tag: {e}")))?;
437 Ok(info.is_some())
438}
439
440pub async fn is_seeded_any(store: &Store, rid: &RepoId, cid: &Cid) -> Result<bool, Error> {
446 let prefix = seeded_rid_prefix(rid);
447 let mut stream = store
448 .tags()
449 .list_prefix(&prefix)
450 .await
451 .map_err(|e| Error::Iroh(format!("list seeded tags: {e}")))?;
452
453 while let Some(item) = stream.next().await {
454 let info = item.map_err(|e| Error::Iroh(format!("seeded tag stream: {e}")))?;
455 if let Some((_, _, tag_cid)) = parse_seeded_tag(info.name.as_ref()) {
456 if &tag_cid == cid {
457 return Ok(true);
458 }
459 }
460 }
461 Ok(false)
462}
463
464pub async fn seeded_cids(
473 store: &Store,
474 rid: &RepoId,
475) -> Result<HashMap<Cid, (Hash, Vec<Oid>)>, Error> {
476 let prefix = seeded_rid_prefix(rid);
477 let mut stream = store
478 .tags()
479 .list_prefix(&prefix)
480 .await
481 .map_err(|e| Error::Iroh(format!("list seeded tags: {e}")))?;
482
483 let mut out = HashMap::new();
484 while let Some(item) = stream.next().await {
485 let info = item.map_err(|e| Error::Iroh(format!("seeded tag stream: {e}")))?;
486 if let Some((_, release, cid)) = parse_seeded_tag(info.name.as_ref()) {
487 out.entry(cid)
488 .or_insert_with(|| (info.hash, Vec::new()))
489 .1
490 .push(release);
491 }
492 }
493 Ok(out)
494}
495
496pub async fn all_seeded(store: &Store) -> Result<Vec<(RepoId, Oid, Cid, Hash)>, Error> {
505 let mut stream = store
506 .tags()
507 .list_prefix([SEEDED_TAG_V1])
508 .await
509 .map_err(|e| Error::Iroh(format!("list seeded tags: {e}")))?;
510
511 let mut out = Vec::new();
512 while let Some(item) = stream.next().await {
513 let info = item.map_err(|e| Error::Iroh(format!("seeded tag stream: {e}")))?;
514 if let Some((rid, release, cid)) = parse_seeded_tag(info.name.as_ref()) {
515 out.push((rid, release, cid, info.hash));
516 }
517 }
518 Ok(out)
519}
520
521pub async fn artifact_size_for(store: &Store, cid: &Cid, hash: Hash) -> u64 {
528 let Ok(kind) = cid_utils::artifact_kind(cid) else {
529 return 0;
530 };
531 match kind {
532 ArtifactKind::Blob => blob_size(store, hash).await,
533 ArtifactKind::Collection => match Collection::load(hash, store).await {
534 Ok(collection) => {
535 let mut total = 0u64;
536 for (_, child) in collection.iter() {
537 total = total.saturating_add(blob_size(store, *child).await);
538 }
539 total
540 }
541 Err(_) => 0,
542 },
543 }
544}
545
546pub async fn artifact_is_complete(store: &Store, cid: &Cid, hash: Hash) -> Option<bool> {
552 let value = tag_value(cid, hash).ok()?;
553 let info = store.remote().local(value).await.ok()?;
554 Some(info.is_complete())
555}
556
557async fn blob_size(store: &Store, hash: Hash) -> u64 {
558 use iroh_blobs::api::proto::BlobStatus;
559 match store.blobs().status(hash).await {
560 Ok(BlobStatus::Complete { size }) => size,
561 Ok(BlobStatus::Partial { size }) => size.unwrap_or(0),
562 _ => 0,
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use std::collections::HashSet;
569 use std::str::FromStr;
570
571 use super::*;
572
573 fn blob_cid(data: &[u8]) -> Cid {
578 use cid::multihash::Multihash;
579 let digest = blake3::hash(data);
580 let mh = Multihash::<64>::wrap(cid_utils::HASH_CODE_BLAKE3, digest.as_bytes()).unwrap();
581 Cid::from(cid::Cid::new_v1(cid_utils::RAW_CODEC, mh))
582 }
583
584 fn rid_pair() -> (RepoId, RepoId) {
586 let a = RepoId::from_str("rad:z2u2CP3ZJzB7ZqE8jHrau19yjpdip").unwrap();
588 let b = RepoId::from_str("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5").unwrap();
589 assert_ne!(a, b);
590 (a, b)
591 }
592
593 fn release(n: u8) -> Oid {
595 Oid::from_str(&format!("{n:040x}")).unwrap()
596 }
597
598 #[test]
602 fn per_repo_tags_isolate() {
603 let rt = tokio::runtime::Runtime::new().unwrap();
604 rt.block_on(async {
605 let tmp = tempfile::tempdir().unwrap();
606 let store = FsStore::load(tmp.path()).await.unwrap();
607
608 let (rid_a, rid_b) = rid_pair();
609 let rel = release(1);
610 let cid = blob_cid(b"shared bytes");
611 let hash = Hash::new(b"shared bytes");
614
615 tag_seeded(&store, &rid_a, &rel, &cid, hash).await.unwrap();
616 tag_seeded(&store, &rid_b, &rel, &cid, hash).await.unwrap();
617
618 assert!(is_seeded(&store, &rid_a, &rel, &cid).await.unwrap());
619 assert!(is_seeded(&store, &rid_b, &rel, &cid).await.unwrap());
620
621 let cids_a = seeded_cids(&store, &rid_a).await.unwrap();
622 let cids_b = seeded_cids(&store, &rid_b).await.unwrap();
623 assert_eq!(cids_a.len(), 1);
624 assert_eq!(cids_b.len(), 1);
625 assert!(cids_a.contains_key(&cid));
626 assert!(cids_b.contains_key(&cid));
627
628 untag_seeded(&store, &rid_a, &rel, &cid).await.unwrap();
629 assert!(!is_seeded(&store, &rid_a, &rel, &cid).await.unwrap());
630 assert!(is_seeded(&store, &rid_b, &rel, &cid).await.unwrap());
631
632 let cids_a = seeded_cids(&store, &rid_a).await.unwrap();
634 let cids_b = seeded_cids(&store, &rid_b).await.unwrap();
635 assert!(cids_a.is_empty());
636 assert_eq!(cids_b.len(), 1);
637 });
638 }
639
640 #[test]
644 fn per_release_tags_isolate() {
645 let rt = tokio::runtime::Runtime::new().unwrap();
646 rt.block_on(async {
647 let tmp = tempfile::tempdir().unwrap();
648 let store = FsStore::load(tmp.path()).await.unwrap();
649
650 let (rid, _) = rid_pair();
651 let (rel_a, rel_b) = (release(1), release(2));
652 let cid = blob_cid(b"shared across releases");
653 let hash = Hash::new(b"shared across releases");
654
655 tag_seeded(&store, &rid, &rel_a, &cid, hash).await.unwrap();
656 tag_seeded(&store, &rid, &rel_b, &cid, hash).await.unwrap();
657
658 assert_eq!(all_seeded(&store).await.unwrap().len(), 2);
660 let cids = seeded_cids(&store, &rid).await.unwrap();
661 assert_eq!(cids.len(), 1);
662 let (_, mut releases) = cids[&cid].clone();
663 releases.sort();
664 let mut expected = vec![rel_a, rel_b];
665 expected.sort();
666 assert_eq!(releases, expected);
667
668 untag_seeded(&store, &rid, &rel_a, &cid).await.unwrap();
670 assert!(!is_seeded(&store, &rid, &rel_a, &cid).await.unwrap());
671 assert!(is_seeded(&store, &rid, &rel_b, &cid).await.unwrap());
672 assert!(is_seeded_any(&store, &rid, &cid).await.unwrap());
673
674 tag_seeded(&store, &rid, &rel_a, &cid, hash).await.unwrap();
676 assert_eq!(untag_all(&store, &rid, &cid).await.unwrap(), 2);
677 assert!(!is_seeded_any(&store, &rid, &cid).await.unwrap());
678 });
679 }
680
681 #[test]
683 fn unregister_unknown_is_noop() {
684 let rt = tokio::runtime::Runtime::new().unwrap();
685 rt.block_on(async {
686 let tmp = tempfile::tempdir().unwrap();
687 let store = FsStore::load(tmp.path()).await.unwrap();
688 let (rid_a, _) = rid_pair();
689 let rel = release(1);
690 let cid = blob_cid(b"never seeded");
691
692 untag_seeded(&store, &rid_a, &rel, &cid).await.unwrap();
694 assert!(!is_seeded(&store, &rid_a, &rel, &cid).await.unwrap());
695 });
696 }
697
698 #[test]
702 fn all_seeded_round_trip() {
703 let rt = tokio::runtime::Runtime::new().unwrap();
704 rt.block_on(async {
705 let tmp = tempfile::tempdir().unwrap();
706 let store = FsStore::load(tmp.path()).await.unwrap();
707 let (rid_a, rid_b) = rid_pair();
708 let (rel_1, rel_2) = (release(1), release(2));
709 let cid_x = blob_cid(b"x");
710 let cid_y = blob_cid(b"y");
711 let cid_z = blob_cid(b"z");
712 let hash = Hash::new(b"value");
713
714 let triples = [
715 (rid_a, rel_1, cid_x),
716 (rid_a, rel_2, cid_y),
717 (rid_b, rel_1, cid_x),
718 (rid_b, rel_1, cid_z),
719 ];
720 for (rid, rel, cid) in &triples {
721 tag_seeded(&store, rid, rel, cid, hash).await.unwrap();
722 }
723
724 let got: HashSet<(RepoId, Oid, Cid, Hash)> =
727 all_seeded(&store).await.unwrap().into_iter().collect();
728 let want: HashSet<(RepoId, Oid, Cid, Hash)> = triples
729 .into_iter()
730 .map(|(rid, rel, cid)| (rid, rel, cid, hash))
731 .collect();
732 assert_eq!(got, want);
733 });
734 }
735
736 #[test]
740 fn seeded_tag_layout() {
741 const SHA1_LEN: usize = 20;
744
745 let (rid, _) = rid_pair();
746 let rel = release(7);
747 let cid = blob_cid(b"layout");
748 let tag = seeded_tag(&rid, &rel, &cid);
749
750 let rid_off = 2;
751 let rel_len_off = rid_off + SHA1_LEN;
752 let rel_off = rel_len_off + 1;
753 let cid_off = rel_off + SHA1_LEN;
754
755 assert_eq!(tag.len(), cid_off + cid.as_inner().to_bytes().len());
756 assert_eq!(tag[0], SEEDED_TAG_V1);
757 assert_eq!(usize::from(tag[1]), SHA1_LEN);
758 assert_eq!(&tag[rid_off..rel_len_off], AsRef::<[u8]>::as_ref(&*rid));
759 assert_eq!(usize::from(tag[rel_len_off]), SHA1_LEN);
760 assert_eq!(&tag[rel_off..cid_off], AsRef::<[u8]>::as_ref(&rel));
761 assert_eq!(Cid::from(cid::Cid::try_from(&tag[cid_off..]).unwrap()), cid);
762
763 let (rid_back, rel_back, cid_back) = parse_seeded_tag(&tag).expect("decodes");
764 assert_eq!(rid_back, rid);
765 assert_eq!(rel_back, rel);
766 assert_eq!(cid_back, cid);
767 }
768}