1use 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
40const KNOWN_TIERS: [&str; 3] = ["artifact", "tree", "store"];
42
43#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum BlobTarget {
53 Artifact { id: String },
55 Tree { root: ContentHash },
57 Store { algo: String, hash: ContentHash },
59}
60
61impl BlobTarget {
62 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 pub fn probe_prefix(&self) -> BlobProbePrefix {
139 BlobProbePrefix::new(self.tier())
140 }
141
142 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 pub fn prefix_at(&self, origin: &Origin) -> Key {
158 grammar::blob_tier_prefix(origin, self.tier())
159 }
160
161 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 #[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
192pub fn blob_list(
205 slices: &[RegistrySlice],
206 roster: Option<&BTreeMap<String, Vec<String>>>,
207 source: BlobListSource,
208) -> BlobList {
209 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
257pub 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 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 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 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 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}