Skip to main content

radicle_artifact_node/
seeder.rs

1//! Seeder primitives: persistent iroh-blobs store, per-repo tag management,
2//! import + size helpers.
3//!
4//! This module holds the bytes-and-tags layer of artifact seeding. It knows
5//! nothing about COBs or signing — callers compose `seed()` with the
6//! appropriate `add_location` write themselves.
7//!
8//! Tags are scoped per repository and release: `seeded/{rid}/{release}/{cid}`.
9//! The same CID seeded under two releases (or two repos) produces distinct
10//! tags pointing at one underlying blob; unseeding one release does not
11//! drop bytes another release still needs. GC marks from every tag, so the
12//! blob survives until the last release referencing it is unseeded.
13
14use 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
34/// Map the wire-protocol import mode onto the iroh-blobs one. A free
35/// function because both types are foreign here (orphan rule).
36fn to_iroh_import_mode(m: ImportMode) -> IrohImportMode {
37    match m {
38        ImportMode::Copy => IrohImportMode::Copy,
39        ImportMode::Reference => IrohImportMode::TryReference,
40    }
41}
42
43/// Directory name (under `<home>`) that holds the seeder's state.
44pub const ARTIFACTS_DIR: &str = "artifacts";
45
46/// Subdirectory of [`ARTIFACTS_DIR`] holding the FsStore.
47pub const STORE_DIR: &str = "store";
48
49/// First byte of every `seeded` tag — distinguishes them from any
50/// future tag class we add. Binary, not UTF-8: see [`seeded_tag`].
51const SEEDED_TAG_V1: u8 = 0x01;
52
53/// Best-effort bound on waiting for a relay connection during bootstrap.
54const ONLINE_TIMEOUT: Duration = Duration::from_secs(10);
55
56/// How often the store's background GC sweep runs.
57///
58/// GC marks every blob reachable from a tag (or live temp tag) and
59/// sweeps the rest. The interval bounds how long an unseeded blob's
60/// bytes linger on disk after its `seeded/...` tag is removed.
61const GC_INTERVAL: Duration = Duration::from_secs(60 * 60);
62
63/// A running iroh-blobs seeder: an `FsStore` plus the iroh `Router` that
64/// accepts blob fetches against it.
65pub struct Seeder {
66    /// The persistent blob store.
67    pub blobs: FsStore,
68    /// The iroh protocol router serving blobs to peers.
69    pub router: Router,
70    /// Pkarr relays the endpoint publishes to.
71    pub pkarr_urls: Vec<url::Url>,
72}
73
74/// Bootstrap the iroh seeder.
75///
76/// Opens the persistent `FsStore` under `<home>/artifacts/store/`, binds
77/// an iroh `Endpoint` to the supplied `SecretKey`, and spawns the
78/// `BlobsProtocol` router. Anything previously tagged in the store is
79/// reachable the moment this returns.
80///
81/// The caller supplies the iroh `SecretKey` — typically derived from the
82/// user's radicle keystore via [`radicle_artifact_core::keys::radicle_secret_to_iroh`]. This
83/// module never reads the keystore directly.
84pub 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    // FsStore::load defaults to gc: None — blobs would linger on disk
89    // forever after untag_seeded. Enable the periodic mark-and-sweep
90    // GC; our `seeded/{rid}/{release}/{cid}` tags are the live roots.
91    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    // The builder consumes the preset, so keep the URLs for `Status`.
105    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    // bind() only guarantees a local socket; until a relay is picked,
113    // peers resolving our endpoint id can't reach us. Wait (best-effort,
114    // bounded) so we don't add locations the network can't route to
115    // yet. online() can block indefinitely when offline, so cap it.
116    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
135/// Binary tag key for a `(rid, release, cid)` triple.
136///
137/// Layout:
138/// `[SEEDED_TAG_V1][rid_len: u8][rid_bytes][rel_len: u8][rel_bytes][Cid binary form]`.
139/// Both ids are length-prefixed Oids, keeping the format hash-agnostic; a
140/// SHA-256 id (32 bytes) slots in without a new sentinel byte. The CID's
141/// self-describing binary form runs to the end.
142///
143/// The release sits in the key so the same CID seeded under two releases
144/// yields two distinct tags over one blob: unseeding one leaves the other's
145/// bytes protected from GC.
146fn 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
152/// Binary prefix matching every seeded tag for `rid`, across all releases.
153fn 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
162/// Binary prefix matching every seeded tag for `(rid, release)`.
163fn 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
171/// Raw bytes of the [`Oid`] backing a `RepoId` — 20 for SHA-1, 32 for
172/// SHA-256 once radicle moves over.
173fn rid_bytes(rid: &RepoId) -> &[u8] {
174    AsRef::<[u8]>::as_ref(&**rid)
175}
176
177/// Reconstruct an [`Oid`] from its byte form, dispatching on length so
178/// SHA-256 RIDs can join SHA-1 RIDs in the same store.
179fn oid_from_bytes(b: &[u8]) -> Option<Oid> {
180    match b.len() {
181        20 => Some(Oid::from_sha1(b.try_into().ok()?)),
182        // TODO(sha256): when radicle exposes a 32-byte Oid, dispatch here:
183        // 32 => Some(Oid::from_sha256(b.try_into().ok()?)),
184        _ => None,
185    }
186}
187
188/// Inverse of [`seeded_tag`]: decode a tag name into `(RepoId, Oid, Cid)`.
189fn 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
199/// Split a `[len: u8][bytes]` field off the front, returning `(bytes, rest)`.
200fn 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
209/// Build `AddPathOptions` for an absolute path under the requested mode.
210fn 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
218/// Import a single file into the store and verify it matches the expected CID.
219///
220/// `mode` selects copy-vs-reference semantics — see [`ImportMode`].
221///
222/// Returns the blob `Hash` together with the [`TempTag`] that protects it.
223/// The caller must keep the temp tag alive until a persistent tag covers
224/// the blob, otherwise GC may sweep it. Prefer [`seed_artifact`], which
225/// holds the temp tag across [`tag_seeded`].
226pub async fn import_blob(
227    store: &Store,
228    path: &Path,
229    expected: &Cid,
230    mode: ImportMode,
231) -> Result<(Hash, TempTag), Error> {
232    // iroh-blobs requires an absolute path for in-place reference imports.
233    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
251/// Import a directory as a [`Collection`] and verify it matches the expected CID.
252///
253/// Each file becomes a collection entry keyed by its relative path; files
254/// are imported in canonical (sorted) order for determinism.
255///
256/// Returns the root `Hash` and the [`TempTag`] that protects the
257/// collection. Holding the root temp tag covers child blobs too — GC's
258/// mark phase expands the hash-seq from any live root. The caller must
259/// keep the temp tag alive until a persistent tag covers the root;
260/// prefer [`seed_artifact`], which does this.
261pub 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    // Hold per-file temp tags until the root tag is created — once the
271    // root exists, GC mark expansion via hash-seq covers the children.
272    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    // Root temp tag now protects the whole hash-seq; drop per-file tags.
289    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
302/// The store-level value a CID's tag points at: a raw blob or a hash
303/// sequence, per the CID's kind. Shared by the tag writer and the
304/// completeness check so both describe the same content.
305fn 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
312/// Mark a `(rid, release, cid)` triple as actively seeded.
313///
314/// Sets the `seeded/{rid}/{release}/{cid}` tag pointing at `hash` with the
315/// format matching the CID's kind. Idempotent; re-tagging with the same
316/// hash is a no-op at the iroh-blobs layer.
317pub 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
333/// Import an artifact and register it as seeded in a single step.
334///
335/// Holds the import temp tag alive across [`tag_seeded`] so the
336/// freshly imported bytes are never momentarily unprotected — if GC's
337/// mark phase landed between the temp tag dropping and the persistent
338/// tag being set, the blob would be swept.
339pub 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    // _tt drops here, after the persistent seeded tag protects the bytes.
354    Ok(hash)
355}
356
357/// Remove the `seeded/{rid}/{release}/{cid}` tag for one release.
358///
359/// Returns whether a tag was actually present and removed; the delete itself
360/// reports the count, so callers learn this without a separate read that a
361/// concurrent unseed could invalidate. Idempotent: deleting a tag that
362/// doesn't exist returns `Ok(false)`. The underlying blob bytes are not
363/// removed by this call; iroh-blobs' GC reclaims them on its next sweep once
364/// no tags reference them. Bytes another release still tags survive.
365pub 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
379/// Remove the seeded tag for `cid` under every release of `rid`.
380///
381/// Fully stops seeding the CID in this repo regardless of how many releases
382/// reference it. Returns the number of release tags removed.
383///
384/// The CID sits at the tail of the key, not in the listing prefix, so we
385/// can't delete by prefix in one shot; we list once, filter to the matching
386/// release tags, and delete that snapshot. We deliberately do not re-list and
387/// converge: a seed landing after the snapshot is a newer intent than the
388/// unseed, so its tag should survive rather than be swept by an unseed the
389/// user issued earlier. The scope is the releases that referenced the CID
390/// when the request was processed.
391pub 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    // Collect matching names first; deleting mid-stream would mutate the
400    // listing we're iterating.
401    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    // Tally the delete results, not the snapshot length, so the count ignores
412    // a tag a concurrent unseed already dropped.
413    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
425/// Whether `(rid, release, cid)` is currently tagged as seeded.
426pub 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
440/// Whether `cid` is tagged as seeded under any release of `rid`.
441///
442/// Streams the `rid` prefix and stops at the first matching tag rather than
443/// building the full CID map, so a hit costs only as many decodes as it takes
444/// to reach it.
445pub 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
464/// Return every CID currently seeded under `rid`, mapped to its blob hash
465/// and the releases that seed it.
466///
467/// Walks the `[SEEDED_TAG_V1][rid_bytes]` prefix and collapses the
468/// per-release tags: a CID seeded under several releases appears once, since
469/// all those tags point at the same blob. The hash rides along so callers
470/// can size each artifact without a second lookup. Decoding failures
471/// (corrupt tag names, unlikely since we write them ourselves) are skipped.
472pub 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
496/// Walk every seeded tag in the store, regardless of repo or release.
497///
498/// Yields each `(rid, release, cid, hash)` currently tagged as seeded. The
499/// hash comes straight from the tag listing so callers can size the artifact
500/// without a second tag lookup (see [`artifact_size_for`]). A blob shared
501/// across releases appears once per release tag; callers that sum bytes
502/// should dedup by hash. Tag names that don't parse cleanly are skipped; we
503/// own the writer, so this only fires on corrupt stores.
504pub 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
521/// Stored byte total for an already-resolved `(cid, hash)`.
522///
523/// Blobs report their own size; collections walk their hash sequence and
524/// sum the children. Errors resolve to `0` so a `Status` view can render
525/// the row even if iroh is momentarily unhappy. Callers hold the hash
526/// already (from [`seeded_cids`] or [`all_seeded`]), so no tag lookup runs.
527pub 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
546/// Whether the store holds every byte of an already-resolved `(cid, hash)`.
547///
548/// This is a metadata check: it trusts the bitfield written when the bytes
549/// landed (bao-verified at that point) and never re-reads the files, so
550/// on-disk corruption still reports complete.
551pub 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    /// Build a fake raw-codec CID over `data`. The `tag_seeded`
574    /// path requires a valid codec to pick `HashAndFormat::raw` vs
575    /// `hash_seq`; the actual digest value doesn't have to match the
576    /// blob hash for tag-layer tests.
577    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    /// Two distinct RepoIds we can refer to in tests.
585    fn rid_pair() -> (RepoId, RepoId) {
586        // Both share the `rad:` prefix and a 20-byte Git-style Oid base58 body.
587        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    /// A `Oid` from a 40-hex-char (20-byte SHA-1) Oid.
594    fn release(n: u8) -> Oid {
595        Oid::from_str(&format!("{n:040x}")).unwrap()
596    }
597
598    /// Per-repo tag scoping: seeding the same CID in two repos creates two
599    /// independent tags backed by one blob; unseeding in one repo leaves
600    /// the other untouched.
601    #[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            // Real (rather than fabricated) iroh hash so iroh-blobs accepts
612            // the tag value when we set it.
613            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            // Per-rid enumeration stays clean across the removal.
633            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    /// Per-release tag scoping: the same CID seeded under two releases of one
641    /// repo creates two tags over one blob. Unseeding one release leaves the
642    /// other's tag, the GC root that keeps the shared blob alive.
643    #[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            // Two distinct release tags, but the CID collapses to one entry.
659            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            // Unseeding one release keeps the other's tag alive.
669            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            // `untag_all` sweeps whatever release tags remain for the CID.
675            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    /// `untag_seeded` on an untagged triple is a no-op (idempotent).
682    #[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            // No tag set; deleting it must still succeed.
693            untag_seeded(&store, &rid_a, &rel, &cid).await.unwrap();
694            assert!(!is_seeded(&store, &rid_a, &rel, &cid).await.unwrap());
695        });
696    }
697
698    /// `all_seeded` returns every `(rid, release, cid)` tagged across all
699    /// repos, exercising the `parse_seeded_tag` decode path that
700    /// `seeded_cids` alone doesn't cover.
701    #[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            // The hash rides along from the tag listing, so callers can size
725            // each artifact without a second lookup.
726            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    /// Lock the binary tag-name layout: sentinel byte, RID length+bytes,
737    /// release length+bytes, then the CID's canonical binary form. Also
738    /// exercise the encode/decode round-trip via `parse_seeded_tag`.
739    #[test]
740    fn seeded_tag_layout() {
741        // Today's ids are SHA-1; the format itself doesn't bake that in,
742        // which is the point of the length prefixes.
743        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}