Skip to main content

zenkey_fleet/bus/blob/
transfer.rs

1//! The two bus operations RFC 07 §2.5 sanctions, in the order it sanctions
2//! them: probe across origins with a tiny reply, then fetch from the one origin
3//! you chose. Behind the `blob` feature, which is what pulls in the reference
4//! client ([`zblob`]).
5
6use std::path::Path;
7use std::time::Duration;
8
9use crate::{Error, Result};
10use zenkey::grammar::{self, ContentHash, Origin};
11use zenkey::{RegistrySlice, RemoteOrigin, ServiceOrigin};
12use zenoh::qos::Priority;
13
14use super::{BlobTarget, declared_by};
15use crate::bus::query::{Answer, FleetAnswer, GetOpts, fleet_get};
16use crate::report::{
17    BlobAvailability, BlobFetchReport, BlobHolder, BlobManifest, BlobProbeReport, BlobProgress,
18    CallError,
19};
20
21/// The priority every `@blob` GET this crate issues rides at (RFC 07 §2.6).
22///
23/// One constant, read by both the probe (which sets it on its [`GetOpts`]) and
24/// the fetch report (which names it) — so what the report says and what the
25/// wire carried cannot drift apart. The fetch itself does not read it: the
26/// reference client already defaults to `DataLow`, and re-setting it here would
27/// mean two places to change and one of them silently winning.
28pub const FETCH_PRIORITY: Priority = Priority::DataLow;
29
30/// How a fetch should behave (RFC 07 §2.1, §2.5).
31pub struct BlobFetchSpec {
32    /// Per-query timeout. A transfer spans many queries, so this bounds a
33    /// *stall*, not the transfer.
34    pub timeout: Duration,
35    /// Replace an existing destination file rather than refusing.
36    pub overwrite: bool,
37    /// The pinned content root (RFC 07 §2.1). `None` is trust-on-first-use,
38    /// which the caller had to ask for out loud — [`BlobFetchReport::root_pinned`]
39    /// reports which it was.
40    pub root: Option<ContentHash>,
41    /// Cooperative cancellation, so a GUI's stop button is not a lie.
42    pub cancel: zblob::CancelToken,
43}
44
45impl Default for BlobFetchSpec {
46    fn default() -> Self {
47        BlobFetchSpec {
48            timeout: Duration::from_secs(30),
49            overwrite: false,
50            root: None,
51            cancel: zblob::CancelToken::new(),
52        }
53    }
54}
55
56/// RFC 07 §2.5, discharged: probe across origins with a tiny reply, attribute
57/// by each reply's own key, and hand back one **concrete** key per holder.
58///
59/// The selector comes from [`zenkey::BlobProbePrefix`], which is not
60/// convertible to a `Key` — so this function is the only shape a `*`-origin
61/// `@blob` GET can take in this crate, and it can only ever ask for the tiny
62/// endpoints. Every probe GET rides at [`FETCH_PRIORITY`].
63///
64/// **Tier 2 is probed through its v1.17 endpoints** (RFC 07 §2.4/§2.5):
65/// `store/<algo>/have` answers a bitfield over exactly the asked addresses,
66/// `tree/<root>/have` answers has-index plus chunks present/total — replies
67/// that are O(question) by construction, which is what makes the wildcard
68/// origin as legitimate there as it always was on Tier 1, and what turns the
69/// old `not_probed` apology into a **possession verdict**. The one honest
70/// refusal left is a store algorithm the reference client does not speak;
71/// that still comes back as `not_probed`, with `declared_by` filled from the
72/// slices.
73pub async fn blob_probe(
74    fleet: &crate::Fleet<'_>,
75    target: &BlobTarget,
76    slices: &[RegistrySlice],
77    timeout: Duration,
78) -> Result<BlobProbeReport> {
79    let base = fleet.base();
80    let tier = target.tier();
81    let declared = declared_by(slices, tier);
82
83    let Some(id) = target.artifact_id() else {
84        return probe_tier2(fleet, target, declared, slices.len(), timeout).await;
85    };
86
87    // The wide form: `<base>/v1/*/@blob/artifact/<id>/{have,manifest}`. The
88    // prefix is zenkey's probe type; the endpoint tails come from the reference
89    // client, which is where RFC 07 §2.2's table is spelled out in code.
90    let prefix = target.probe_prefix();
91    let have = grammar::with_base(base, zblob::keys::availability_key(prefix.as_str(), id));
92    let manifest = grammar::with_base(base, zblob::keys::manifest_key(prefix.as_str(), id));
93    let asked = vec![have.clone(), manifest.clone()];
94
95    // Two independent questions to the same fleet, asked concurrently: a
96    // probe costs one timeout window, not two. Folding stays sequential and
97    // ordered (have, then manifest), so the merge is deterministic.
98    let bulk = GetOpts::new(timeout).priority(FETCH_PRIORITY);
99    let (have_answers, manifest_answers) = tokio::join!(
100        fleet_get(fleet, &have, &bulk),
101        fleet_get(fleet, &manifest, &bulk),
102    );
103    let mut holders: Vec<BlobHolder> = Vec::new();
104    for (answers, kind) in [
105        (have_answers?, Endpoint::Have),
106        (manifest_answers?, Endpoint::Manifest),
107    ] {
108        for answer in answers {
109            fold(&mut holders, base, kind, answer);
110        }
111    }
112    holders.sort_by(|a, b| a.origin.cmp(&b.origin));
113
114    let mut roots: Vec<String> = holders
115        .iter()
116        .filter_map(|h| h.manifest.as_ref().map(|m| m.root.clone()))
117        .collect();
118    roots.sort();
119    roots.dedup();
120
121    Ok(BlobProbeReport {
122        target: target.spelling(),
123        tier: tier.chunk().to_string(),
124        asked,
125        not_probed: None,
126        answered: holders.len(),
127        holders,
128        roots,
129        declared_by: declared,
130        // R7: BlobList's own solution — an empty `declared_by` over zero
131        // slices is "nobody was asked", not "nobody declares" (O4).
132        slices_considered: slices.len(),
133    })
134}
135
136/// The Tier-2 half of [`blob_probe`] (RFC 07 §2.4/§2.5, v1.17): ask the tiny
137/// endpoint whose reply size is a function of the question, and report what
138/// each holder *has* — a possession verdict, attributed by the reply's own
139/// key exactly as the Tier-1 probe attributes its holders.
140async fn probe_tier2(
141    fleet: &crate::Fleet<'_>,
142    target: &BlobTarget,
143    declared: Vec<String>,
144    slices_considered: usize,
145    timeout: Duration,
146) -> Result<BlobProbeReport> {
147    let base = fleet.base();
148    let tier = target.tier();
149    let probe_prefix = grammar::with_base(base, target.probe_prefix().as_str());
150    let report = |asked: Vec<String>, not_probed: Option<String>, holders: Vec<BlobHolder>| {
151        BlobProbeReport {
152            target: target.spelling(),
153            tier: tier.chunk().to_string(),
154            asked,
155            not_probed,
156            answered: holders.len(),
157            holders,
158            roots: Vec::new(),
159            declared_by: declared.clone(),
160            slices_considered,
161        }
162    };
163
164    // Both tier-2 probes ride the same fleet chokepoint as tier 1 (RFC 05
165    // §2.1: consolidation None, attribution by each reply's own key), and
166    // fold with the same posture: an errored or undecodable holder is
167    // *recorded*, never dropped — answered-but-unreadable is an observation
168    // about an origin, not silence (RFC 09 §5.1 O4). The reference client's
169    // own probe helpers skip such replies, which is right for a transfer
170    // client choosing a source and wrong for an explorer reporting a fleet.
171    match target {
172        BlobTarget::Store { algo, hash } => {
173            // Probing is per-algorithm like everything else on this tier
174            // (RFC 07 §2.4). The reference client speaks one; a foreign algo
175            // is the one honest `not_probed` left, and it must say so rather
176            // than answer "no holders" for a question it never asked.
177            if algo != zblob::Hash::ALGO {
178                return Ok(report(
179                    Vec::new(),
180                    Some(format!(
181                        "the reference client speaks `{}` only, so a `{algo}` chunk cannot be probed by this build (RFC 07 §2.4 — dedup and probing are per-algorithm)",
182                        zblob::Hash::ALGO
183                    )),
184                    Vec::new(),
185                ));
186            }
187            let parsed: zblob::Hash = hash
188                .as_str()
189                .parse()
190                .map_err(|e| Error::unaskable_from(hash.to_string(), e))?;
191            let have_key = zblob::keys::store_have_key(&probe_prefix, zblob::HashAlgo::Blake3);
192            let want = zblob::wire::encode(&zblob::wire::WantList::new(vec![parsed]))
193                .map_err(|e| Error::Internal(format!("encoding the want-list: {e}")))?;
194            let answers = fleet_get(
195                fleet,
196                &have_key,
197                &GetOpts::new(timeout)
198                    .payload(Some(want))
199                    .priority(FETCH_PRIORITY),
200            )
201            .await?;
202            let holders = fold_tier2(base, answers, |bytes| {
203                let bits: zblob::wire::HaveBits = zblob::wire::decode(bytes)
204                    .map_err(|e| format!("undecodable have bitfield: {e}"))?;
205                bits.validate(1)
206                    .map_err(|e| format!("invalid have bitfield: {e}"))?;
207                let held = bits.is_set(0);
208                Ok((
209                    BlobAvailability {
210                        chunk_count: 1,
211                        have: u32::from(held),
212                        complete: held,
213                    },
214                    None,
215                ))
216            });
217            Ok(report(vec![have_key], None, holders))
218        }
219        BlobTarget::Tree { root } => {
220            // The probe key must be an address the reference client could
221            // serve: `ContentHash` admits any even-length hex, `zblob::Hash`
222            // exactly one digest size — validating here keeps the probe and
223            // the fetch agreeing about what is askable, instead of the probe
224            // returning an honest-looking "nobody holds it" for a root no
225            // holder could ever have.
226            let parsed: zblob::Hash = root
227                .as_str()
228                .parse()
229                .map_err(|e| Error::unaskable_from(root.to_string(), e))?;
230            let have_key = zblob::keys::tree_have_key(&probe_prefix, &parsed.to_string());
231            let answers = fleet_get(
232                fleet,
233                &have_key,
234                &GetOpts::new(timeout).priority(FETCH_PRIORITY),
235            )
236            .await?;
237            let holders = fold_tier2(base, answers, |bytes| {
238                let probe: zblob::wire::TreeProbe = zblob::wire::decode(bytes)
239                    .map_err(|e| format!("undecodable tree probe: {e}"))?;
240                probe
241                    .validate()
242                    .map_err(|e| format!("invalid tree probe: {e}"))?;
243                // A full-looking chunk count with no index is the one verdict
244                // the counters cannot express, and it predicts exactly how a
245                // fetch from this holder fails — say it.
246                let note = (!probe.have_index && probe.chunks_present > 0).then(|| {
247                    "holds chunks but not the index — an index fetch from this origin will fail"
248                        .to_string()
249                });
250                Ok((
251                    BlobAvailability {
252                        chunk_count: probe.chunks_total,
253                        have: probe.chunks_present,
254                        complete: probe.have_index && probe.chunks_present == probe.chunks_total,
255                    },
256                    note,
257                ))
258            });
259            Ok(report(vec![have_key], None, holders))
260        }
261        BlobTarget::Artifact { .. } => Err(Error::Internal(
262            "tier-1 target reached the tier-2 probe path — a bug in blob_probe".into(),
263        )),
264    }
265}
266
267/// One tier-2 reply becomes one holder, with [`fold`]'s O4 posture: errors
268/// and unreadable payloads are recorded against the origin that produced
269/// them. The holder's `key` is the reply's own key — the same attribution
270/// evidence tier 1 keeps — and duplicate replies from one origin keep the
271/// first, exactly as the tier-1 merge does.
272fn fold_tier2(
273    base: &str,
274    answers: Vec<FleetAnswer>,
275    decode: impl Fn(&[u8]) -> Result<(BlobAvailability, Option<String>), String>,
276) -> Vec<BlobHolder> {
277    let mut holders: Vec<BlobHolder> = Vec::new();
278    for answer in answers {
279        let origin = attribute(base, &answer);
280        if holders.iter().any(|h| h.origin == origin) {
281            continue;
282        }
283        let mut holder = BlobHolder {
284            origin,
285            key: answer.key.clone(),
286            availability: None,
287            manifest: None,
288            note: None,
289            unreadable: None,
290            error: None,
291        };
292        match answer.answer {
293            Answer::Error { name, message } => {
294                holder.error = Some(CallError { name, message });
295            }
296            Answer::Value(payload) => match decode(&payload.to_bytes()) {
297                Ok((availability, note)) => {
298                    holder.availability = Some(availability);
299                    holder.note = note;
300                }
301                Err(why) => {
302                    let declared = answer.encoding.as_deref().unwrap_or("(none)");
303                    holder.unreadable = Some(format!("{why} (encoding `{declared}`)"));
304                }
305            },
306        }
307        holders.push(holder);
308    }
309    holders.sort_by(|a, b| a.origin.cmp(&b.origin));
310    holders
311}
312
313/// Verified bytes land whole or not at all: written and synced to a hidden
314/// sibling, then renamed into place, on the async runtime's I/O pool rather
315/// than blocking the executor. A crash mid-write leaves a temp file, never a
316/// `dest` that looks fetched and is not — the same failure direction the
317/// reference client chooses, sync included. The temp name carries pid and a
318/// sequence number so concurrent fetches to one destination cannot collide
319/// on it; the final rename keeps the same narrow overwrite race the
320/// reference client's `Overwrite` docs accept.
321async fn write_atomically(dest: &Path, bytes: &[u8]) -> Result<()> {
322    use tokio::io::AsyncWriteExt;
323
324    static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
325
326    let name = dest
327        .file_name()
328        .map(|n| n.to_string_lossy().into_owned())
329        .ok_or_else(|| Error::unaskable(dest.display().to_string(), "names no file to write"))?;
330    if let Some(parent) = dest.parent().filter(|p| !p.as_os_str().is_empty()) {
331        tokio::fs::create_dir_all(parent)
332            .await
333            .map_err(|e| Error::io(parent, e))?;
334    }
335    let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
336    let tmp = dest.with_file_name(format!(".{name}.{}.{seq}.zenkey-tmp", std::process::id()));
337    let write = async {
338        let mut f = tokio::fs::File::create(&tmp).await?;
339        f.write_all(bytes).await?;
340        f.sync_all().await
341    };
342    if let Err(e) = write.await {
343        let _ = tokio::fs::remove_file(&tmp).await;
344        return Err(Error::io(&tmp, e));
345    }
346    if let Err(e) = tokio::fs::rename(&tmp, dest).await {
347        let _ = tokio::fs::remove_file(&tmp).await;
348        return Err(Error::io(dest, e));
349    }
350    Ok(())
351}
352
353#[derive(Clone, Copy)]
354enum Endpoint {
355    Have,
356    Manifest,
357}
358
359/// Merge one reply into the holder list, keyed by origin: `have` and
360/// `manifest` are two GETs, and one origin answering both is one holder.
361fn fold(holders: &mut Vec<BlobHolder>, base: &str, kind: Endpoint, answer: FleetAnswer) {
362    let origin = attribute(base, &answer);
363    let idx = match holders.iter().position(|h| h.origin == origin) {
364        Some(i) => i,
365        None => {
366            holders.push(BlobHolder {
367                origin: origin.clone(),
368                key: answer.key.clone(),
369                availability: None,
370                manifest: None,
371                note: None,
372                unreadable: None,
373                error: None,
374            });
375            holders.len() - 1
376        }
377    };
378    let holder = &mut holders[idx];
379    if holder.key.is_empty() {
380        holder.key = answer.key.clone();
381    }
382
383    match answer.answer {
384        Answer::Error { name, message } => {
385            holder.error = Some(CallError { name, message });
386        }
387        Answer::Value(payload) => {
388            let bytes = payload.to_bytes();
389            let (want, decoded) = match kind {
390                Endpoint::Have => (
391                    &zblob::wire::ENC_AVAIL,
392                    decode_have(&bytes).map(|a| holder.availability = Some(a)),
393                ),
394                Endpoint::Manifest => (
395                    &zblob::wire::ENC_MANIFEST,
396                    decode_manifest(&bytes).map(|m| holder.manifest = Some(m)),
397                ),
398            };
399            if let Err(why) = decoded {
400                // It answered; we could not read it. That is an observation
401                // about this origin, not silence (RFC 09 §5.1 O4) — so it is
402                // recorded rather than dropped, with what it claimed to be.
403                let declared = answer.encoding.as_deref().unwrap_or("(none)");
404                holder.unreadable =
405                    Some(format!("{why} (encoding `{declared}`, expected `{want}`)"));
406            }
407        }
408    }
409}
410
411/// The responder's origin. `FleetAnswer::origin` is already the grammar's
412/// answer; the fallback reads position 1 off the reply's own key, so a key that
413/// does not parse under this base still *names* its holder (RFC 09 §5.1 O1) —
414/// which is the whole point of a probe.
415fn attribute(base: &str, answer: &FleetAnswer) -> String {
416    if answer.origin != "?" {
417        return answer.origin.clone();
418    }
419    let stripped = answer
420        .key
421        .strip_prefix(base)
422        .map(|s| s.trim_start_matches('/'))
423        .unwrap_or(&answer.key);
424    stripped
425        .split('/')
426        .nth(1)
427        .filter(|c| !c.is_empty())
428        .unwrap_or("?")
429        .to_string()
430}
431
432fn decode_have(bytes: &[u8]) -> Result<BlobAvailability, String> {
433    let avail: zblob::wire::Availability =
434        zblob::wire::decode(bytes).map_err(|e| format!("undecodable availability: {e}"))?;
435    Ok(BlobAvailability {
436        chunk_count: avail.chunk_count,
437        have: avail.count(),
438        complete: avail.count() == avail.chunk_count,
439    })
440}
441
442fn decode_manifest(bytes: &[u8]) -> Result<BlobManifest, String> {
443    let m: zblob::Manifest =
444        zblob::wire::decode(bytes).map_err(|e| format!("undecodable manifest: {e}"))?;
445    // The chunk count is the reference client's own arithmetic now (v3) —
446    // but a manifest whose sizing does not divide still *names a root*, and
447    // the root is what the §2.1 disagreement check feeds on. So the manifest
448    // is kept and the count degrades to zero: a 0-chunk row renders oddly, a
449    // discarded root renders as *agreement*, and only one of those is a lie.
450    let chunk_count = m.chunk_count().unwrap_or(0);
451    Ok(BlobManifest {
452        chunk_count,
453        id: m.id.to_string(),
454        filename: m.filename,
455        total_len: m.total_len,
456        chunk_size: m.chunk_size,
457        root: m.root.to_string(),
458        created_ms: m.created_ms,
459    })
460}
461
462/// Fetch from **one** origin's concrete key, at data-low, verifying every reply
463/// against the content root before disk (RFC 07 §2.1, §2.5, §2.6).
464///
465/// `origin` is parsed through [`RemoteOrigin::parse`] / [`ServiceOrigin::new`],
466/// both of which reject `*` — so a wildcard fetch is refused here *and*
467/// unspellable upstream, which is the layering the plane's whole design rests
468/// on. The transfer itself is the reference client's: every slice is verified
469/// against the root as it arrives, and a rejected reply never reaches the
470/// destination file.
471pub async fn blob_fetch(
472    fleet: &crate::Fleet<'_>,
473    origin: &str,
474    target: &BlobTarget,
475    dest: &Path,
476    spec: &BlobFetchSpec,
477    on_progress: &(dyn Fn(BlobProgress) + Send + Sync),
478) -> Result<BlobFetchReport> {
479    let (session, base) = (fleet.session(), fleet.base());
480    let origin = parse_origin(origin)?;
481    let Some(id) = target.artifact_id() else {
482        return fetch_tier2(fleet, &origin, target, dest, spec, on_progress).await;
483    };
484
485    let prefix = grammar::with_base(base, target.prefix_at(&origin).as_str());
486    let key = grammar::with_base(base, target.key_at(&origin)?.as_str());
487
488    let prefix = zblob::QueryPrefix::new(prefix).map_err(|e| {
489        Error::unaskable(
490            format!("{}'s artifact prefix", origin.chunk()),
491            format!("is not queryable: {e}"),
492        )
493    })?;
494    let client = zblob::BlobClient::builder(session, prefix)
495        // Priority is deliberately not set: the reference client already
496        // defaults to DataLow, which is how RFC 07 §2.6 says a conformant
497        // caller behaves without touching the setting. Setting it again here
498        // would create a second source of truth for `FETCH_PRIORITY`.
499        .query_timeout(spec.timeout)
500        .overwrite(if spec.overwrite {
501            zblob::Overwrite::Replace
502        } else {
503            zblob::Overwrite::Refuse
504        })
505        .build();
506
507    let request = match &spec.root {
508        Some(root) => {
509            let parsed: zblob::Hash = root
510                .as_str()
511                .parse()
512                .map_err(|e| Error::unaskable_from(root.to_string(), e))?;
513            zblob::DownloadRequest::pinned(id, parsed)
514        }
515        None => zblob::DownloadRequest::new(id),
516    };
517    let root_pinned = request.expected_root.is_some();
518
519    let sink = move |p: zblob::Progress| on_progress(translate(p));
520    let stats = client
521        .download_to(&request, dest)
522        .progress(&sink)
523        .cancel(&spec.cancel)
524        .await
525        // The origin is named here, once, so every failure this fetch can
526        // produce — a hash mismatch above all — says which origin produced it.
527        // A verification failure that does not name its source is an
528        // unactionable one.
529        .map_err(|e| Error::bus("fetch", origin.chunk(), e.to_string()))?;
530
531    Ok(BlobFetchReport {
532        origin: origin.chunk().to_string(),
533        key,
534        dest: dest.display().to_string(),
535        bytes: stats.bytes_fetched,
536        chunks: stats.chunks_fetched,
537        chunks_resumed: stats.chunks_resumed,
538        rejected: stats.rejected,
539        retries: stats.retries,
540        elapsed_ms: stats.elapsed.as_millis() as u64,
541        root: request
542            .expected_root
543            .map(|r| r.to_string())
544            .unwrap_or_default(),
545        root_pinned,
546        priority: priority_name(FETCH_PRIORITY).to_string(),
547    })
548}
549
550/// The Tier-2 half of [`blob_fetch`] (RFC 07 §2.4, v1.17): one verified,
551/// content-addressed chunk from one origin. The address *is* the pin — a
552/// reply that unframes to anything else is rejected naming the origin, so
553/// trust-on-first-use is unspellable on this path by construction.
554async fn fetch_tier2(
555    fleet: &crate::Fleet<'_>,
556    origin: &Origin,
557    target: &BlobTarget,
558    dest: &Path,
559    spec: &BlobFetchSpec,
560    on_progress: &(dyn Fn(BlobProgress) + Send + Sync),
561) -> Result<BlobFetchReport> {
562    let (session, base) = (fleet.session(), fleet.base());
563    let started = std::time::Instant::now();
564    match target {
565        BlobTarget::Store { algo, hash } => {
566            if algo != zblob::Hash::ALGO {
567                return Err(Error::unaskable(
568                    target.spelling(),
569                    format!(
570                        "cannot be fetched by this build: the reference client \
571                         speaks `{}` only (RFC 07 §2.4 — addressing is \
572                         per-algorithm)",
573                        zblob::Hash::ALGO
574                    ),
575                ));
576            }
577            // The key *is* the pin (RFC 07 §2.1), so a caller-supplied root
578            // is either redundant or a contradiction — and a contradiction
579            // must refuse, not be silently out-voted by the address.
580            if let Some(pin) = &spec.root
581                && pin != hash
582            {
583                return Err(Error::unaskable(
584                    format!("the pinned root {pin}"),
585                    format!(
586                        "contradicts the content address {hash}: a store fetch \
587                         is pinned by its key (RFC 07 §2.1) — drop the pin, or \
588                         fetch the address you mean"
589                    ),
590                ));
591            }
592            let parsed: zblob::Hash = hash
593                .as_str()
594                .parse()
595                .map_err(|e| Error::unaskable_from(hash.to_string(), e))?;
596            let prefix_str = grammar::with_base(
597                base,
598                grammar::blob_tier_prefix(origin, grammar::BlobTier::Store).as_str(),
599            );
600            let prefix = zblob::QueryPrefix::new(prefix_str.clone())
601                .map_err(|e| Error::unaskable_from(prefix_str.to_string(), e))?;
602            let key = zblob::keys::store_key(prefix.as_str(), zblob::HashAlgo::Blake3, &parsed);
603            // Refuse *before* fetching — 0.3's own `Overwrite::Refuse`
604            // semantics: a destination that will be refused is not worth a
605            // byte of transfer.
606            if !spec.overwrite && tokio::fs::try_exists(dest).await.unwrap_or(false) {
607                return Err(Error::unaskable(
608                    dest.display().to_string(),
609                    "already exists — pass overwrite to replace it",
610                ));
611            }
612            let client = zblob::StoreClient::builder(session, prefix)
613                .query_timeout(spec.timeout)
614                .priority(FETCH_PRIORITY)
615                .build();
616            // The reference client's chunk fetch takes no token, so the
617            // cancellation the spec promises is honoured here, with the
618            // client's own combinator: a cancelled transfer writes nothing.
619            let bytes = match spec
620                .cancel
621                .until_cancelled(client.fetch_chunk(&parsed))
622                .await
623            {
624                None => {
625                    on_progress(BlobProgress::Cancelled {
626                        received: 0,
627                        total: 1,
628                    });
629                    return Err(Error::bus("fetch", origin.chunk(), "cancelled"));
630                }
631                // The origin is named for the same reason blob_fetch names
632                // it: a verification failure that does not say which origin
633                // produced it is unactionable.
634                Some(fetched) => {
635                    fetched.map_err(|e| Error::bus("fetch", origin.chunk(), e.to_string()))?
636                }
637            };
638            on_progress(BlobProgress::Chunk {
639                index: 0,
640                received: 1,
641                total: 1,
642                bytes_received: bytes.len() as u64,
643            });
644            write_atomically(dest, &bytes).await?;
645            on_progress(BlobProgress::Completed {
646                path: dest.display().to_string(),
647            });
648            Ok(BlobFetchReport {
649                origin: origin.chunk().to_string(),
650                key,
651                dest: dest.display().to_string(),
652                bytes: bytes.len() as u64,
653                chunks: 1,
654                chunks_resumed: 0,
655                rejected: 0,
656                retries: 0,
657                elapsed_ms: started.elapsed().as_millis() as u64,
658                root: hash.to_string(),
659                // The key is the root (RFC 07 §2.1): a store fetch cannot be
660                // trust-on-first-use, so this is true by construction.
661                root_pinned: true,
662                priority: priority_name(FETCH_PRIORITY).to_string(),
663            })
664        }
665        BlobTarget::Tree { .. } => Err(Error::unaskable(
666            target.spelling(),
667            "is inspected, not downloaded, by this explorer: a validated index \
668             summary needs no content store (RFC 07 §2.3, v1.17) — the \
669             frontends route tree targets to the tree-index report; \
670             materializing a tree is the reference client's `download_tree`, \
671             which needs a store this build deliberately does not keep",
672        )),
673        BlobTarget::Artifact { .. } => Err(Error::Internal(
674            "tier-1 target reached the tier-2 fetch path — a bug in blob_fetch".into(),
675        )),
676    }
677}
678
679/// Fetch and fully validate one origin's index for `tree/<root>`, returning
680/// the summary an explorer renders (RFC 07 §2.3, v1.17) — **no content store
681/// involved**: the stats make inspecting a huge tree cheap, which is the
682/// difference between browsing a snapshot and downloading one.
683pub async fn blob_tree_index(
684    fleet: &crate::Fleet<'_>,
685    origin: &str,
686    root: &ContentHash,
687    timeout: Duration,
688) -> Result<crate::report::BlobTreeIndexReport> {
689    let (session, base) = (fleet.session(), fleet.base());
690    let started = std::time::Instant::now();
691    let origin = parse_origin(origin)?;
692    let tree_str = grammar::with_base(
693        base,
694        grammar::blob_tier_prefix(&origin, grammar::BlobTier::Tree).as_str(),
695    );
696    let store_str = grammar::with_base(
697        base,
698        grammar::blob_tier_prefix(&origin, grammar::BlobTier::Store).as_str(),
699    );
700    let tree_prefix = zblob::QueryPrefix::new(tree_str.clone())
701        .map_err(|e| Error::unaskable_from(tree_str.to_string(), e))?;
702    let store_prefix = zblob::QueryPrefix::new(store_str.clone())
703        .map_err(|e| Error::unaskable_from(store_str.to_string(), e))?;
704    let parsed: zblob::Hash = root
705        .as_str()
706        .parse()
707        .map_err(|e| Error::unaskable_from(root.to_string(), e))?;
708    let key = zblob::keys::tree_key(tree_prefix.as_str(), root.as_str());
709    // No priority setter: the reference client defaults to data-low, which is
710    // FETCH_PRIORITY — the §2.6 conformant untouched default.
711    let client = zblob::TreeClient::builder(session, store_prefix, tree_prefix)
712        .query_timeout(timeout)
713        .build();
714    let index = client
715        .fetch_index_by_root(&parsed)
716        .await
717        .map_err(|e| Error::bus("fetch", origin.chunk(), e.to_string()))?;
718    Ok(crate::report::BlobTreeIndexReport {
719        origin: origin.chunk().to_string(),
720        key,
721        root: root.to_string(),
722        entries: index.entries().len(),
723        files: index.file_count(),
724        total_size: index.total_size(),
725        chunks: index.needed_chunk_refs().len(),
726        elapsed_ms: started.elapsed().as_millis() as u64,
727        priority: priority_name(FETCH_PRIORITY).to_string(),
728    })
729}
730
731/// One concrete origin, host or service. Both constructors reject `*`, which is
732/// what makes "fetch from one origin" a type-level guarantee rather than a
733/// convention.
734fn parse_origin(origin: &str) -> Result<Origin> {
735    if let Some(service) = origin.strip_prefix('@') {
736        let _ = service;
737        let svc =
738            ServiceOrigin::new(origin).map_err(|e| Error::unaskable_from(origin.to_string(), e))?;
739        return Ok(Origin::Service(svc));
740    }
741    let host = RemoteOrigin::parse(origin).map_err(|e| {
742        Error::unaskable(
743            origin.to_string(),
744            format!(
745                "is not one concrete origin: {e}. A fetch names exactly one \
746                 holder (RFC 07 §2.5) — probe first, then fetch from an origin \
747                 the probe reported."
748            ),
749        )
750    })?;
751    Ok(Origin::Host(host.host_id().clone()))
752}
753
754fn translate(p: zblob::Progress) -> BlobProgress {
755    match p {
756        zblob::Progress::Started {
757            total_len,
758            chunk_count,
759        } => BlobProgress::Started {
760            total_len,
761            chunk_count,
762        },
763        zblob::Progress::Resumed { received, total } => BlobProgress::Resumed { received, total },
764        zblob::Progress::Chunk {
765            index,
766            received,
767            total,
768            bytes_received,
769        } => BlobProgress::Chunk {
770            index,
771            received,
772            total,
773            bytes_received,
774        },
775        zblob::Progress::Verifying => BlobProgress::Verifying,
776        zblob::Progress::Completed { path } => BlobProgress::Completed {
777            path: path.display().to_string(),
778        },
779        zblob::Progress::Cancelled { received, total } => {
780            BlobProgress::Cancelled { received, total }
781        }
782        zblob::Progress::Failed { error } => BlobProgress::Failed { error },
783        // The reference client's progress type is #[non_exhaustive]; a variant
784        // added upstream must not be silently swallowed, so it surfaces as what
785        // it is — an event this build does not understand.
786        other => BlobProgress::Failed {
787            error: format!("unrecognised progress event from the reference client: {other:?}"),
788        },
789    }
790}
791
792fn priority_name(p: Priority) -> &'static str {
793    match p {
794        Priority::RealTime => "real-time",
795        Priority::InteractiveHigh => "interactive-high",
796        Priority::InteractiveLow => "interactive-low",
797        Priority::DataHigh => "data-high",
798        Priority::Data => "data",
799        Priority::DataLow => "data-low",
800        Priority::Background => "background",
801    }
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807
808    #[test]
809    fn a_wildcard_origin_is_not_an_origin() {
810        for spelled in ["*", "**", "h-*", "", "not-a-host"] {
811            assert!(
812                parse_origin(spelled).is_err(),
813                "`{spelled}` must not parse as a fetch origin"
814            );
815        }
816        assert!(parse_origin("h-3fa9c2d41b7e").is_ok());
817        assert!(parse_origin("@catalog").is_ok());
818    }
819
820    #[test]
821    fn the_reported_priority_is_the_one_the_client_uses() {
822        // The report's sentence and the wire's behaviour come from one
823        // constant; this pins the rendering of it.
824        assert_eq!(priority_name(FETCH_PRIORITY), "data-low");
825    }
826
827    #[test]
828    fn an_unparseable_key_still_names_its_holder() {
829        // O1: the grammar could not classify this key, and the probe must
830        // still say who answered — that is what a probe is for.
831        let answer = FleetAnswer {
832            origin: "?".to_string(),
833            key: "zensight/v1/h-3fa9c2d41b7e/@blob/artifact/NOPE/have".to_string(),
834            encoding: None,
835            attachment: None,
836            answer: Answer::Error {
837                name: "error/x".into(),
838                message: String::new(),
839            },
840        };
841        assert_eq!(attribute("zensight", &answer), "h-3fa9c2d41b7e");
842    }
843}