Skip to main content

zenkey_fleet/
blob.rs

1//! The `@blob` plane, as an explorer sees it (RFC 07 §2; issues #58, #68).
2//!
3//! RFC v1.8 modelled `[[blob]]` in the registry, in its own words, "so that an
4//! explorer can see which origins serve blobs, and of which tier". This module
5//! is the half that makes that true: the registry projection ([`blob_list`]),
6//! the addressing type ([`BlobTarget`]), and — behind the `blob` feature — the
7//! two bus operations §2.5 sanctions, in the order it sanctions them.
8//!
9//! **The shape of the plane, and why it is not just another fan-out.** Every
10//! other plane an explorer reads answers in kilobytes. `@blob` answers in
11//! files. So RFC 07 §2.5 splits the interaction in two: *probe* across origins
12//! with a tiny reply (`have`, `manifest`), then *fetch* from the one origin you
13//! chose, at its concrete key. A wildcard-origin bulk fetch is not a slow path
14//! to be discouraged — Zenoh cannot cancel replies already in flight, so N
15//! holders cost N× the bytes with no way to stop them — and this crate makes it
16//! unspellable rather than unfashionable: the wide form is a
17//! [`BlobProbePrefix`], which is not a [`Key`] and does not convert into one,
18//! and `blob_fetch` takes a concrete origin that goes through
19//! [`zenkey::RemoteOrigin::parse`]. (The two functions are named without
20//! links because they exist only under the `blob` feature, and a link that
21//! resolves in one build configuration and not the other is a docs-lane
22//! failure waiting for whoever turns the feature off.)
23//!
24//! **What this module does not implement.** Verified streaming. RFC 07 §2
25//! names `zblob` the reference client and §2.1 makes per-reply verification
26//! *before disk* normative; a second implementation of an integrity anchor is a
27//! second thing that can be wrong about the same bytes. So the fetch path is
28//! zblob's, and this module's job is to spell the keys through zenkey's typed
29//! builders, attribute replies the way RFC 05 §2.1 requires, and report the
30//! result in a shape both frontends can render.
31
32use std::collections::{BTreeMap, BTreeSet};
33
34use anyhow::{Result, bail};
35use zenkey::grammar::{self, BlobTier, ContentHash, Origin};
36use zenkey::{BlobProbePrefix, Key, RegistrySlice};
37
38use crate::report::{BlobList, BlobListSource, BlobTierRow};
39
40/// The three reserved tier tokens, in RFC 07 §2's order.
41const KNOWN_TIERS: [&str; 3] = ["artifact", "tree", "store"];
42
43/// What a blob command addresses: RFC 07 §2's three shapes, each validated.
44///
45/// There is deliberately no `String` constructor for the content-addressed
46/// tiers — a `tree` or `store` address is a [`ContentHash`] or it does not
47/// exist. RFC 07 §2.3 revoked the caller-chosen tree name (`tree/nightly`) in
48/// v1.7, and the generated builders have refused to spell one ever since; this
49/// type refuses for the same reason, at the point where an operator's typing
50/// enters the system.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum BlobTarget {
53    /// Tier-1: a named artifact, whose endpoints live under it (RFC 07 §2.2).
54    Artifact { id: String },
55    /// Tier-2: a directory index, keyed by its own root (RFC 07 §2.3).
56    Tree { root: ContentHash },
57    /// Tier-2: one content-addressed chunk (RFC 07 §2.4).
58    Store { algo: String, hash: ContentHash },
59}
60
61impl BlobTarget {
62    /// Parse the one spelling both frontends accept:
63    ///
64    /// ```text
65    /// <id>  |  artifact/<id>  |  tree/<hex>  |  store/<algo>/<hex>
66    /// ```
67    ///
68    /// A bare id is Tier-1, because that is the tier an operator has an id
69    /// *for*: Tier-2 addresses are hashes, and nobody types one from memory.
70    ///
71    /// An id that is not a valid RFC 03 §2 plain chunk is refused with the
72    /// citation rather than lowercased. A canonical ULID is uppercase Crockford
73    /// base32 and a key chunk has no uppercase spelling (RFC 07 §2.2 as
74    /// amended in v1.11) — but silently rewriting the caller's id would mean
75    /// probing for something they did not ask for and reporting holders of it,
76    /// which is worse than refusing.
77    pub fn parse(spec: &str) -> Result<BlobTarget> {
78        let spec = spec.trim().trim_matches('/');
79        if spec.is_empty() {
80            bail!(
81                "empty blob target: expected <id>, artifact/<id>, tree/<hex>, or store/<algo>/<hex>"
82            );
83        }
84        let parts: Vec<&str> = spec.split('/').collect();
85        match parts.as_slice() {
86            ["artifact", id] => Self::artifact(id),
87            ["tree"] => bail!(
88                "tree/ needs the tree's root hash: `tree/<hex>` (RFC 07 §2.3 — a tree is keyed by its own root, and a caller-chosen name has no spelling)"
89            ),
90            ["tree", root] => Ok(BlobTarget::Tree {
91                root: content_hash(root, "tree")?,
92            }),
93            ["store"] | ["store", _] => {
94                bail!("store/ needs both chunks: `store/<algo>/<hex>` (RFC 07 §2.4)")
95            }
96            ["store", algo, hash] => {
97                if !grammar::is_valid_plain_chunk(algo) {
98                    bail!(
99                        "`{algo}` is not a valid algorithm chunk: RFC 03 §2 requires [a-z0-9]([a-z0-9._-]*[a-z0-9])?"
100                    );
101                }
102                Ok(BlobTarget::Store {
103                    algo: (*algo).to_string(),
104                    hash: content_hash(hash, "store")?,
105                })
106            }
107            [id] => Self::artifact(id),
108            _ => bail!(
109                "`{spec}` is not a blob target: expected <id>, artifact/<id>, tree/<hex>, or store/<algo>/<hex>"
110            ),
111        }
112    }
113
114    fn artifact(id: &str) -> Result<BlobTarget> {
115        if !grammar::is_valid_plain_chunk(id) {
116            let hint = if id.chars().any(|c| c.is_ascii_uppercase()) {
117                " — a ULID is key-encoded in lowercase (RFC 03 §2, RFC 07 §2.2); lowercase it at the source rather than here, so the id you probe for is the id you were given"
118            } else {
119                ""
120            };
121            bail!(
122                "`{id}` is not a valid artifact id: RFC 03 §2 requires one plain chunk matching [a-z0-9]([a-z0-9._-]*[a-z0-9])?{hint}"
123            );
124        }
125        Ok(BlobTarget::Artifact { id: id.to_string() })
126    }
127
128    pub fn tier(&self) -> BlobTier {
129        match self {
130            BlobTarget::Artifact { .. } => BlobTier::Artifact,
131            BlobTarget::Tree { .. } => BlobTier::Tree,
132            BlobTarget::Store { .. } => BlobTier::Store,
133        }
134    }
135
136    /// The `*`-origin probe prefix (RFC 07 §2.5) — the only wildcard form that
137    /// exists for this plane, and not a [`Key`].
138    pub fn probe_prefix(&self) -> BlobProbePrefix {
139        BlobProbePrefix::new(self.tier())
140    }
141
142    /// This target's concrete key under one origin — the only fetchable form.
143    ///
144    /// For Tier-1 that is the artifact's base key, which the endpoint tails of
145    /// RFC 07 §2.2 hang off; for Tier-2 the key *is* the object.
146    pub fn key_at(&self, origin: &Origin) -> Result<Key> {
147        let key = match self {
148            BlobTarget::Artifact { id } => grammar::blob_key(origin, BlobTier::Artifact, &[id])?,
149            BlobTarget::Tree { root } => grammar::blob_tree_key(origin, root)?,
150            BlobTarget::Store { algo, hash } => grammar::blob_store_key(origin, algo, hash)?,
151        };
152        Ok(key)
153    }
154
155    /// The tier prefix under one origin: `v1/<origin>/@blob/<tier>`. This is
156    /// what the reference client's endpoint helpers append to.
157    pub fn prefix_at(&self, origin: &Origin) -> Key {
158        grammar::blob_tier_prefix(origin, self.tier())
159    }
160
161    /// The canonical spelling, which round-trips through [`parse`](Self::parse).
162    pub fn spelling(&self) -> String {
163        match self {
164            BlobTarget::Artifact { id } => format!("artifact/{id}"),
165            BlobTarget::Tree { root } => format!("tree/{root}"),
166            BlobTarget::Store { algo, hash } => format!("store/{algo}/{hash}"),
167        }
168    }
169
170    /// The tier-1 id, for the reference client's per-id endpoint helpers.
171    ///
172    /// Feature-gated with its only callers: without the transport there is no
173    /// per-id endpoint to build, and an always-compiled private helper nobody
174    /// calls is a dead-code warning in every build that turns `blob` off.
175    #[cfg(feature = "blob")]
176    pub(crate) fn artifact_id(&self) -> Option<&str> {
177        match self {
178            BlobTarget::Artifact { id } => Some(id),
179            _ => None,
180        }
181    }
182}
183
184fn content_hash(text: &str, tier: &str) -> Result<ContentHash> {
185    ContentHash::parse(text).map_err(|e| {
186        anyhow::anyhow!(
187            "`{text}` is not a content hash for `{tier}`: {e} (RFC 07 §2.3/§2.4 — the key is the digest, so it is lowercase hex of even length)"
188        )
189    })
190}
191
192/// Which producers declare which `@blob` tiers, from registry slices.
193///
194/// **No bus traffic.** This is what a slice *says*, which is the only thing
195/// RFC 08 §2's `[[blob]]` table can tell anyone: a producer declaring a tier
196/// claims it serves that tier's endpoints, never that it holds any particular
197/// blob. Possession is a probe's answer, and only a probe's.
198///
199/// `roster` is the liveliness map as [`crate::roster()`] returns it (origin →
200/// producers), inverted here to fill `origins`. Pass `None` when it was not
201/// asked — an offline `--registry` read has learned nothing about who is up,
202/// and `origins: None` is how that stays distinguishable from "declared, but
203/// nobody is serving it" (RFC 09 §5.1 O4).
204pub fn blob_list(
205    slices: &[RegistrySlice],
206    roster: Option<&BTreeMap<String, Vec<String>>>,
207    source: BlobListSource,
208) -> BlobList {
209    // roster is origin → producers; the row wants producer → origins.
210    let by_producer: Option<BTreeMap<&str, Vec<String>>> = roster.map(|r| {
211        let mut out: BTreeMap<&str, Vec<String>> = BTreeMap::new();
212        for (origin, producers) in r {
213            for producer in producers {
214                out.entry(producer.as_str())
215                    .or_default()
216                    .push(origin.clone());
217            }
218        }
219        out
220    });
221
222    let mut tiers = Vec::new();
223    let mut slices_without_blob = 0usize;
224    for slice in slices {
225        if slice.blob.is_empty() {
226            slices_without_blob += 1;
227            continue;
228        }
229        for decl in &slice.blob {
230            tiers.push(BlobTierRow {
231                producer: slice.name.clone(),
232                registry_version: slice.version.clone(),
233                known_tier: KNOWN_TIERS.contains(&decl.tier.as_str()),
234                tier: decl.tier.clone(),
235                endpoints: decl.endpoints.clone(),
236                algo: decl.algo.clone(),
237                reference: decl.reference.clone(),
238                encoding: decl.encoding.clone(),
239                since: decl.since.clone(),
240                description: decl.description.clone(),
241                origins: by_producer
242                    .as_ref()
243                    .map(|m| m.get(slice.name.as_str()).cloned().unwrap_or_default()),
244            });
245        }
246    }
247    tiers.sort_by(|a, b| (&a.producer, &a.tier).cmp(&(&b.producer, &b.tier)));
248
249    BlobList {
250        tiers,
251        source,
252        slices_considered: slices.len(),
253        slices_without_blob,
254    }
255}
256
257/// Producers whose slice declares `tier` — the capability claim behind a
258/// probe, so silence stays legible (RFC 05 §3.1).
259pub fn declared_by(slices: &[RegistrySlice], tier: BlobTier) -> Vec<String> {
260    let mut names: BTreeSet<String> = BTreeSet::new();
261    for slice in slices {
262        if slice.serves_blob_tier(tier.chunk()) {
263            names.insert(slice.name.clone());
264        }
265    }
266    names.into_iter().collect()
267}
268
269#[cfg(feature = "blob")]
270mod bus;
271#[cfg(feature = "blob")]
272pub use bus::{BlobFetchSpec, FETCH_PRIORITY, blob_fetch, blob_probe, blob_tree_index};
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
279
280    fn origin() -> Origin {
281        Origin::Host(zenkey::HostId::parse("h-3fa9c2d41b7e").unwrap())
282    }
283
284    #[test]
285    fn a_bare_id_is_tier_one() {
286        assert_eq!(
287            BlobTarget::parse("01jqz3demo0001").unwrap(),
288            BlobTarget::Artifact {
289                id: "01jqz3demo0001".into()
290            }
291        );
292        assert_eq!(
293            BlobTarget::parse("artifact/01jqz3demo0001").unwrap(),
294            BlobTarget::parse("01jqz3demo0001").unwrap()
295        );
296    }
297
298    #[test]
299    fn every_target_round_trips_through_its_spelling() {
300        for spec in [
301            "artifact/01jqz3demo0001",
302            &format!("tree/{HASH}"),
303            &format!("store/blake3/{HASH}"),
304        ] {
305            let target = BlobTarget::parse(spec).unwrap();
306            assert_eq!(target.spelling(), spec);
307            assert_eq!(BlobTarget::parse(&target.spelling()).unwrap(), target);
308        }
309    }
310
311    #[test]
312    fn an_uppercase_ulid_is_refused_with_the_citation() {
313        // The canonical display form of a ULID. RFC 03 §2 has no uppercase
314        // spelling, so this key cannot exist — and lowercasing it silently
315        // would probe for an id the caller never gave us.
316        let err = BlobTarget::parse("01HQXK8F9C2N4PZQ")
317            .unwrap_err()
318            .to_string();
319        assert!(err.contains("RFC 03 §2"), "{err}");
320        assert!(err.contains("lowercase"), "{err}");
321    }
322
323    #[test]
324    fn a_wildcard_is_not_a_target() {
325        for spec in ["*", "**", "artifact/*", "v1/*/@blob/artifact", "a/b/c/d"] {
326            assert!(
327                BlobTarget::parse(spec).is_err(),
328                "`{spec}` must not parse as a blob target"
329            );
330        }
331    }
332
333    #[test]
334    fn tier_two_needs_a_hash_not_a_name() {
335        // RFC 07 §2.3's revoked spelling, and the shapes around it.
336        for spec in ["tree/nightly", "tree", "store", "store/blake3", "tree/abc"] {
337            assert!(
338                BlobTarget::parse(spec).is_err(),
339                "`{spec}` must not parse as a blob target"
340            );
341        }
342        assert!(BlobTarget::parse(&format!("tree/{HASH}")).is_ok());
343    }
344
345    #[test]
346    fn keys_come_out_of_the_typed_builders() {
347        let o = origin();
348        assert_eq!(
349            BlobTarget::parse("01jqz3demo0001")
350                .unwrap()
351                .key_at(&o)
352                .unwrap()
353                .as_str(),
354            "v1/h-3fa9c2d41b7e/@blob/artifact/01jqz3demo0001"
355        );
356        assert_eq!(
357            BlobTarget::parse(&format!("store/blake3/{HASH}"))
358                .unwrap()
359                .key_at(&o)
360                .unwrap()
361                .as_str(),
362            format!("v1/h-3fa9c2d41b7e/@blob/store/blake3/{HASH}")
363        );
364        assert_eq!(
365            BlobTarget::parse("01jqz3demo0001")
366                .unwrap()
367                .prefix_at(&o)
368                .as_str(),
369            "v1/h-3fa9c2d41b7e/@blob/artifact"
370        );
371        assert_eq!(
372            BlobTarget::parse("01jqz3demo0001")
373                .unwrap()
374                .probe_prefix()
375                .as_str(),
376            "v1/*/@blob/artifact"
377        );
378    }
379
380    fn slice_with_blob(name: &str, body: &str) -> RegistrySlice {
381        let toml = format!(
382            "[registry]\nversion = \"7\"\napp = \"demo\"\nconvention = 1\n\n\
383             [producer]\nname = \"{name}\"\n\n{body}"
384        );
385        zenkey::parse_slice(&toml).unwrap()
386    }
387
388    #[test]
389    fn a_declaration_without_a_roster_says_so() {
390        let slices = vec![
391            slice_with_blob(
392                "netring",
393                "[[blob]]\ntier = \"artifact\"\nendpoints = [\"manifest\", \"have\"]\n",
394            ),
395            slice_with_blob("quiet", ""),
396        ];
397        let list = blob_list(&slices, None, BlobListSource::RegistryDirs);
398        assert_eq!(list.tiers.len(), 1);
399        assert_eq!(list.slices_considered, 2);
400        assert_eq!(list.slices_without_blob, 1);
401        // O4: nobody asked who is up, so this is not "no origin serves it".
402        assert!(list.tiers[0].origins.is_none());
403
404        let roster = BTreeMap::from([("h-3fa9c2d41b7e".to_string(), vec!["netring".to_string()])]);
405        let joined = blob_list(&slices, Some(&roster), BlobListSource::Bus);
406        assert_eq!(
407            joined.tiers[0].origins.as_deref(),
408            Some(["h-3fa9c2d41b7e".to_string()].as_slice())
409        );
410    }
411
412    #[test]
413    fn an_unreserved_tier_survives_flagged_rather_than_dropped() {
414        // O1: a declaration this build does not understand is a fact about the
415        // fleet, and dropping it would report a registry we did not read.
416        let slices = vec![slice_with_blob("future", "[[blob]]\ntier = \"hologram\"\n")];
417        let list = blob_list(&slices, None, BlobListSource::Bus);
418        assert_eq!(list.tiers.len(), 1);
419        assert_eq!(list.tiers[0].tier, "hologram");
420        assert!(!list.tiers[0].known_tier);
421    }
422
423    #[test]
424    fn declared_by_names_the_claimants() {
425        let slices = vec![
426            slice_with_blob("netring", "[[blob]]\ntier = \"artifact\"\n"),
427            slice_with_blob("logs", "[[blob]]\ntier = \"store\"\nalgo = \"blake3\"\n"),
428        ];
429        assert_eq!(declared_by(&slices, BlobTier::Artifact), vec!["netring"]);
430        assert_eq!(declared_by(&slices, BlobTier::Store), vec!["logs"]);
431        assert!(declared_by(&slices, BlobTier::Tree).is_empty());
432    }
433}