1use 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
21pub const FETCH_PRIORITY: Priority = Priority::DataLow;
29
30pub struct BlobFetchSpec {
32 pub timeout: Duration,
35 pub overwrite: bool,
37 pub root: Option<ContentHash>,
41 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
56pub 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 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 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 slices_considered: slices.len(),
133 })
134}
135
136async 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 match target {
172 BlobTarget::Store { algo, hash } => {
173 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 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 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
267fn 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
313async 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
359fn 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 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
411fn 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 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
462pub 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 .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 .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
550async 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 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 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 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 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 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
679pub 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 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
731fn 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 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 assert_eq!(priority_name(FETCH_PRIORITY), "data-low");
825 }
826
827 #[test]
828 fn an_unparseable_key_still_names_its_holder() {
829 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}