decdn_cli/commands/bundle_pull.rs
1//! The bundle route of `decdn get` (`decdn:<hash>/` and
2//! `decdn:<hash>/<path>`): fetch every blob a bundle manifest references, or
3//! the one entry the URI names, into an output directory over the paid
4//! `cdn/client/v1` path (issues #391, #2449).
5//!
6//! The pull itself is [`decdn_client::bundle::BundlePuller`]; this module is
7//! its command line. It resolves the flags and config into the puller's
8//! inputs, fetches the manifest by its own BLAKE3 hash, keeps the URI's entry
9//! or applies the `--include`/`--exclude` filter and the `--select` editor,
10//! renders progress, maps Ctrl-C to the puller's cancel token, and prints the
11//! summary (or the `--json` report).
12
13use std::collections::HashSet;
14use std::io::IsTerminal as _;
15use std::path::Path;
16use std::sync::{Arc, OnceLock};
17
18use alloy::signers::local::PrivateKeySigner;
19use anyhow::{Context as _, anyhow, bail};
20use decdn_client::PoolReplaced;
21use decdn_client::bundle::{
22 BundleEnd, BundleManifest, BundleObserver, BundlePuller, BundleReport, EntryLanded,
23 FaultContext, FaultHints, FileObserver, ForeignReason, Funding, ManifestEntry, NotAManifest,
24 STAGING_DIR, select_nodes,
25};
26use decdn_client::buyer_pool::UnconfirmedEscrows;
27use decdn_client::endpoint as client_endpoint;
28use decdn_client::uri::{DecdnUri, UriTarget};
29use decdn_common::cli::GetArgs;
30use decdn_common::cli::get::{DEFAULT_JOBS, DEFAULT_MAX_LANE_STREAMS};
31use decdn_common::config::load_file_config;
32use decdn_incentive::CapabilityGrant;
33use decdn_incentive::buyer_pool::BuyerPoolStore;
34use decdn_incentive::eth_identity::{PasswordUse, load_signer};
35use iroh::{Endpoint, RelayUrl};
36use serde::Serialize;
37use tokio_util::sync::CancellationToken;
38
39use super::buyer_store::open_client_store_for_buy;
40use super::fetch;
41use super::get::Destination;
42use super::interrupt::{Interrupt, Interrupted, name_held_escrows};
43use super::manifest::build_glob_set;
44use super::pull_progress::{self, PullProgress};
45
46/// A manifest cut down to the entries this run pulls, and how many entries
47/// the `--include`/`--exclude` filter and the `--select` editor dropped.
48#[derive(Debug)]
49struct Kept {
50 manifest: BundleManifest,
51 /// Entries the run does not pull: filtered out or deselected.
52 excluded: u64,
53}
54
55/// The run label for the progress header: the output directory's final component,
56/// or its whole path when it has none (a bare root).
57fn run_label(output: &Path) -> String {
58 output.file_name().map_or_else(
59 || output.to_string_lossy().into_owned(),
60 |n| n.to_string_lossy().into_owned(),
61 )
62}
63
64/// What selects the manifest entries a pull run fetches: the one entry an
65/// entry URI names, or the compiled `--include`/`--exclude` globs.
66///
67/// An entry URI (`decdn:<hash>/<path>`) keeps exactly the entry whose manifest
68/// `path` equals `<path>`, byte for byte, with no glob semantics.
69///
70/// The globs match an entry's POSIX relative `path` — the manifest field
71/// (`models/a.bin`), never the on-disk absolute path — with the same gitignore
72/// glob dialect `origin import --exclude` uses (`*` does not cross `/`, `**`
73/// recurses), via [`build_glob_set`]. An entry is kept iff it passes the
74/// include gate AND matches no exclude. The include gate is open when no
75/// `--include` was given (every entry passes) and otherwise requires a match
76/// against at least one include pattern; `--exclude` always wins over
77/// `--include`.
78///
79/// Every pattern matches the whole path from the bundle root, so `metal/*`
80/// matches `metal/model.bin` but not `gpt/metal/model.bin`; `**/metal/*`
81/// matches both. A pattern that matches no entry is almost always this mistake,
82/// so [`Self::apply_and_warn`] names each one.
83#[derive(Debug)]
84struct EntryFilter {
85 /// The manifest path an entry URI names.
86 entry: Option<String>,
87 include: globset::GlobSet,
88 /// The `--include` patterns as given, in the order [`build_glob_set`]
89 /// compiled them, so a [`globset::GlobSet::matches`] index names its
90 /// pattern.
91 include_patterns: Vec<String>,
92 exclude: globset::GlobSet,
93 /// The `--exclude` patterns as given, indexed like `include_patterns`.
94 exclude_patterns: Vec<String>,
95 /// The file name the entry an entry URI names lands at, directly in the
96 /// output root, in place of its manifest `path`. Set only with `entry`.
97 land_as: Option<String>,
98}
99
100/// A `--include` or `--exclude` pattern that matched no entry of the manifest.
101#[derive(Debug, Clone, PartialEq, Eq)]
102struct Unmatched {
103 /// The flag the pattern came from: `--include` or `--exclude`.
104 flag: &'static str,
105 /// The pattern as given.
106 pattern: String,
107 /// The same pattern under a leading `**/`, when that form matches at least
108 /// one entry: the likely intent of a pattern written as if it matched at
109 /// any depth.
110 suggestion: Option<String>,
111}
112
113impl Unmatched {
114 /// The warning line for this pattern, without the `warning: ` prefix.
115 fn warning(&self) -> String {
116 let hint = self
117 .suggestion
118 .as_ref()
119 .map_or_else(String::new, |suggestion| {
120 format!(
121 " (a pattern matches the whole path from the bundle root; did you mean \
122 '{suggestion}'?)"
123 )
124 });
125 format!("{} '{}' matched no entries{hint}", self.flag, self.pattern)
126 }
127}
128
129/// What [`EntryFilter::apply_reporting`] hands back.
130#[derive(Debug)]
131struct FilterPass {
132 /// The entries the filter keeps, in manifest order.
133 kept: Vec<ManifestEntry>,
134 /// How many entries the filter dropped.
135 excluded: u64,
136 /// Each pattern that matched no entry, `--include` ones first.
137 unmatched: Vec<Unmatched>,
138}
139
140impl EntryFilter {
141 /// Compile the run's entry selection: the URI's `entry` path, and the
142 /// `--include`/`--exclude` patterns. A malformed glob is a hard error naming
143 /// the flag it came from.
144 fn compile(
145 entry: Option<String>,
146 include: &[String],
147 exclude: &[String],
148 ) -> anyhow::Result<Self> {
149 Ok(Self {
150 entry,
151 include: build_glob_set(include, "--include")?,
152 include_patterns: include.to_vec(),
153 exclude: build_glob_set(exclude, "--exclude")?,
154 exclude_patterns: exclude.to_vec(),
155 land_as: None,
156 })
157 }
158
159 /// This filter with the entry the URI names landing at `name`, directly in
160 /// the output root. A filter with no entry URI has no entry to rename, so
161 /// it drops `name`.
162 fn landing_as(mut self, name: Option<String>) -> Self {
163 self.land_as = name.filter(|_| self.entry.is_some());
164 self
165 }
166
167 /// Retain only the entries the filter keeps, preserving manifest order, and
168 /// report the patterns that matched no entry. A pattern's hits are counted
169 /// over every entry of `entries`, before either gate drops any, so an
170 /// `--exclude` that matches only entries the include gate already dropped
171 /// still counts as a match.
172 fn apply_reporting(&self, entries: Vec<ManifestEntry>) -> FilterPass {
173 let mut include_hits = vec![false; self.include_patterns.len()];
174 let mut exclude_hits = vec![false; self.exclude_patterns.len()];
175 let keep: Vec<bool> = entries
176 .iter()
177 .map(|e| {
178 let p = Path::new(&e.path);
179 let included = mark_hits(&self.include, p, &mut include_hits);
180 let excluded = mark_hits(&self.exclude, p, &mut exclude_hits);
181 let named = self.entry.as_ref().is_none_or(|entry| *entry == e.path);
182 named && (self.include_patterns.is_empty() || included) && !excluded
183 })
184 .collect();
185 let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
186 let unmatched = unmatched_patterns("--include", &self.include_patterns, &include_hits)
187 .chain(unmatched_patterns(
188 "--exclude",
189 &self.exclude_patterns,
190 &exclude_hits,
191 ))
192 .map(|mut u| {
193 u.suggestion = anywhere_suggestion(&u.pattern, &paths);
194 u
195 })
196 .collect();
197 let raw = entries.len();
198 let kept: Vec<ManifestEntry> = entries
199 .into_iter()
200 .zip(keep)
201 .filter_map(|(e, keep)| keep.then_some(e))
202 .collect();
203 let excluded = u64::try_from(raw.saturating_sub(kept.len())).unwrap_or(u64::MAX);
204 FilterPass {
205 kept,
206 excluded,
207 unmatched,
208 }
209 }
210
211 /// [`Self::apply_reporting`], with one `warning:` line on stderr for each
212 /// pattern that matched no entry. Returns the kept entries, each moved to
213 /// `land_as` with [`ManifestEntry::land_at`] when it is set, and how many
214 /// the filter dropped.
215 ///
216 /// # Errors
217 ///
218 /// When an entry URI names a path the manifest does not hold.
219 fn apply_and_warn(
220 &self,
221 entries: Vec<ManifestEntry>,
222 ) -> anyhow::Result<(Vec<ManifestEntry>, u64)> {
223 let pass = self.apply_reporting(entries);
224 for u in &pass.unmatched {
225 eprintln!("warning: {}", u.warning());
226 }
227 if let Some(entry) = &self.entry {
228 anyhow::ensure!(
229 !pass.kept.is_empty(),
230 "the bundle has no entry {entry:?}; a URI path names one manifest `path` exactly"
231 );
232 }
233 let kept = match &self.land_as {
234 Some(name) => pass.kept.into_iter().map(|e| e.land_at(name)).collect(),
235 None => pass.kept,
236 };
237 Ok((kept, pass.excluded))
238 }
239}
240
241/// Refuse a pull into a default destination (no `-o`) that may replace a file
242/// no earlier pull of the same entry left there unchanged: one no pull wrote,
243/// one that changed after a pull wrote it, or one a pull wrote from another
244/// bundle entry. A pull fetches over a file whose bytes differ from its
245/// entry's, and the user did not choose this directory. `--overwrite` asks for
246/// the replacement, so it passes.
247///
248/// # Errors
249///
250/// When an entry path of `manifest` under `out` holds a file, directory or
251/// link that [`decdn_client::bundle::foreign_paths`] names.
252fn refuse_foreign(args: &GetArgs, out: &Path, manifest: &BundleManifest) -> anyhow::Result<()> {
253 if args.output.is_some() || args.overwrite {
254 return Ok(());
255 }
256 let found = decdn_client::bundle::foreign_paths(manifest, out);
257 let Some(first) = found.first() else {
258 return Ok(());
259 };
260 let more = match found.len() {
261 1 => String::new(),
262 n => format!(" (and {} more)", n.saturating_sub(1)),
263 };
264 let from = |uri: &Option<DecdnUri>| {
265 uri.as_ref().map_or_else(
266 || "a source this build cannot read".to_owned(),
267 ToString::to_string,
268 )
269 };
270 let why = match &first.reason {
271 ForeignReason::Unrecorded => "no earlier `decdn get` wrote it".to_owned(),
272 ForeignReason::UnreadableCache(cause) => {
273 format!("the skip-cache cannot say which `decdn get` wrote it ({cause})")
274 }
275 ForeignReason::Changed(uri) => format!(
276 "it changed after an earlier `decdn get` wrote it from {}",
277 from(uri)
278 ),
279 ForeignReason::OtherSource(uri) => {
280 format!("an earlier `decdn get` wrote it from {}", from(uri))
281 }
282 _ => "no earlier `decdn get` of this entry wrote it".to_owned(),
283 };
284 anyhow::bail!(
285 "refusing to replace {}{more}: {why}; pass `-o <dir>` to pull into another \
286 directory, or `-o .` to pull here and replace each such file whose bytes differ",
287 out.join(&first.path).display()
288 )
289}
290
291/// Mark in `hits` every pattern of `set` that matches `path`, and return
292/// whether any did.
293fn mark_hits(set: &globset::GlobSet, path: &Path, hits: &mut [bool]) -> bool {
294 let matched = set.matches(path);
295 for i in &matched {
296 if let Some(hit) = hits.get_mut(*i) {
297 *hit = true;
298 }
299 }
300 !matched.is_empty()
301}
302
303/// The patterns of `flag` whose `hits` slot is unset, with no suggestion yet.
304fn unmatched_patterns<'a>(
305 flag: &'static str,
306 patterns: &'a [String],
307 hits: &'a [bool],
308) -> impl Iterator<Item = Unmatched> + 'a {
309 patterns
310 .iter()
311 .zip(hits)
312 .filter(|(_, hit)| !**hit)
313 .map(move |(pattern, _)| Unmatched {
314 flag,
315 pattern: pattern.clone(),
316 suggestion: None,
317 })
318}
319
320/// `**/<pattern>`, when `pattern` does not start with `**` or `/` and that form
321/// matches at least one of `paths`.
322fn anywhere_suggestion(pattern: &str, paths: &[&str]) -> Option<String> {
323 if pattern.starts_with("**") || pattern.starts_with('/') {
324 return None;
325 }
326 let anywhere = format!("**/{pattern}");
327 let set = build_glob_set(std::slice::from_ref(&anywhere), "suggestion").ok()?;
328 paths
329 .iter()
330 .any(|p| set.is_match(Path::new(p)))
331 .then_some(anywhere)
332}
333
334/// Header written above the file list in the `--select` editor buffer. Explains
335/// the git-rebase-style convention: commented or deleted lines are skipped.
336const SELECT_HEADER: &str = "\
337# decdn get --select — choose which files to pull.
338#
339# Comment out (prefix with #) or delete any line to SKIP that file.
340# Save and exit to pull every file still listed below; delete them all to pull
341# nothing. Reordering has no effect, and you cannot add files here.
342#
343# Each line is <path>\t<size> — only the path (before the tab) is read.
344";
345
346/// Render the `--select` editor buffer: the header, then one line per entry as
347/// `<path>\t<human size>`. The size is a right-hand annotation for context only;
348/// [`parse_selection`] reads just the path before the first tab.
349fn render_selection(entries: &[ManifestEntry]) -> String {
350 let mut out = String::from(SELECT_HEADER);
351 for e in entries {
352 let size = e.size.map_or_else(|| "?".to_string(), human_bytes);
353 out.push_str(&e.path);
354 out.push('\t');
355 out.push_str(&size);
356 out.push('\n');
357 }
358 out
359}
360
361/// Reject a bundle whose paths cannot round-trip through the `--select` editor
362/// buffer exactly. [`parse_selection`] reads a kept line as the text before the
363/// first tab, trimmed, and skips a line whose first non-whitespace character is
364/// `#`. A path therefore fails to round-trip when it:
365/// - differs from its trimmed form (leading/trailing whitespace, which
366/// [`parse_selection`] strips — including whitespace before a `#`, which would
367/// make the line read as a comment and silently drop the file), or
368/// - has `#` as its first non-whitespace character (read as a comment), or
369/// - contains a tab, newline, or carriage return (splits or breaks its line).
370///
371/// Such paths are pathological for a POSIX relative filename; `--select` refuses
372/// them loudly rather than silently dropping a file the user meant to keep.
373/// `--include`/`--exclude` still handle these bundles.
374fn check_selectable(entries: &[ManifestEntry]) -> anyhow::Result<()> {
375 if let Some(bad) = entries.iter().find(|e| {
376 let p = e.path.as_str();
377 p != p.trim() || p.trim_start().starts_with('#') || p.contains(['\t', '\n', '\r'])
378 }) {
379 bail!(
380 "--select cannot represent the path {:?} (paths with leading/trailing \
381 whitespace, a leading '#', or a tab/newline are unsupported); use \
382 --include/--exclude instead",
383 bad.path
384 );
385 }
386 Ok(())
387}
388
389/// Apply the edited `--select` buffer back onto the entry list: keep only entries
390/// whose path still appears on a non-comment, non-blank line, preserving the
391/// original manifest order. A kept line whose path matches no bundle entry is a
392/// hard error — the editor only removes files, so an unmatched line is a typo,
393/// not an addition, and silently dropping it would be worse than failing.
394fn parse_selection(
395 edited: &str,
396 entries: Vec<ManifestEntry>,
397) -> anyhow::Result<Vec<ManifestEntry>> {
398 let known: HashSet<&str> = entries.iter().map(|e| e.path.as_str()).collect();
399 let mut kept: HashSet<String> = HashSet::new();
400 let mut unknown: Vec<&str> = Vec::new();
401 for line in edited.lines() {
402 if line.trim_start().is_empty() || line.trim_start().starts_with('#') {
403 continue;
404 }
405 // The path is the text before the size annotation (first tab), trimmed.
406 let path = line.split_once('\t').map_or(line, |(p, _)| p).trim();
407 if path.is_empty() {
408 continue;
409 }
410 if known.contains(path) {
411 kept.insert(path.to_string());
412 } else {
413 unknown.push(path);
414 }
415 }
416 if !unknown.is_empty() {
417 bail!(
418 "--select: these lines match no bundle entry (editing only removes files, \
419 it cannot add them): {}",
420 unknown.join(", ")
421 );
422 }
423 Ok(entries
424 .into_iter()
425 .filter(|e| kept.contains(&e.path))
426 .collect())
427}
428
429/// Apply the interactive `--select` step to a finalized manifest, or pass it
430/// through unchanged when `--select` is off. Each deselected entry adds to the
431/// excluded count. `Ok(None)` means the user deselected every file:
432/// [`report_nothing_to_fetch`] was already called, so the run is done.
433fn maybe_select(
434 args: &GetArgs,
435 out: &Path,
436 uri: &DecdnUri,
437 mut kept: Kept,
438) -> anyhow::Result<Option<Kept>> {
439 if args.select {
440 kept = apply_selection(kept, select_entries)?;
441 if kept.manifest.entries.is_empty() {
442 report_nothing_to_fetch(NothingReason::Deselected);
443 warn_leftover_partials(out, &[], uri.hash);
444 return Ok(None);
445 }
446 }
447 Ok(Some(kept))
448}
449
450/// Replace `kept`'s entries with the ones `select` keeps, and add each entry it
451/// drops to the excluded count.
452fn apply_selection(
453 mut kept: Kept,
454 select: impl FnOnce(Vec<ManifestEntry>) -> anyhow::Result<Vec<ManifestEntry>>,
455) -> anyhow::Result<Kept> {
456 let offered = kept.manifest.entries.len();
457 kept.manifest.entries = select(kept.manifest.entries)?;
458 let dropped = offered.saturating_sub(kept.manifest.entries.len());
459 kept.excluded = kept
460 .excluded
461 .saturating_add(u64::try_from(dropped).unwrap_or(u64::MAX));
462 Ok(kept)
463}
464
465/// Reject a `--select` invocation that cannot work: it opens an editor, so it
466/// needs an interactive terminal (both stdin and stdout) and is incompatible with
467/// the non-interactive `--json` and `--dry-run` modes. A no-op when `--select` is
468/// not set.
469fn check_select_flags(args: &GetArgs) -> anyhow::Result<()> {
470 if !args.select {
471 return Ok(());
472 }
473 if args.json {
474 bail!("--select cannot be combined with --json (it needs an interactive editor)");
475 }
476 if args.dry_run {
477 bail!("--select cannot be combined with --dry-run (it needs an interactive editor)");
478 }
479 if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
480 bail!("--select needs an interactive terminal; use --include/--exclude instead");
481 }
482 Ok(())
483}
484
485/// Run the interactive `--select` step: render the entry list, open it in the
486/// user's editor, and apply the edited buffer back. All decision logic lives in
487/// the tested [`render_selection`]/[`parse_selection`]/[`check_selectable`]; this
488/// only wires them to the editor. The caller has already verified an interactive
489/// terminal and that `--json`/`--dry-run` are not set.
490fn select_entries(entries: Vec<ManifestEntry>) -> anyhow::Result<Vec<ManifestEntry>> {
491 check_selectable(&entries)?;
492 let edited = edit_in_editor(&render_selection(&entries))?;
493 parse_selection(&edited, entries)
494}
495
496/// Resolve the editor command from `$VISUAL`/`$EDITOR` into program + arguments,
497/// falling back to `vi`. `$VISUAL` wins over `$EDITOR`; a missing or
498/// blank/whitespace-only value is treated as unset. The value may carry arguments
499/// (`code --wait`): it is split on ASCII whitespace, with no shell quoting or
500/// escaping — an argument cannot itself contain a space. The scratch file path is
501/// appended by the caller, not here. Never empty — the fallback guarantees at
502/// least `["vi"]`.
503fn editor_command(visual: Option<&str>, editor: Option<&str>) -> Vec<String> {
504 let chosen = [visual, editor]
505 .into_iter()
506 .flatten()
507 .map(str::trim)
508 .find(|s| !s.is_empty())
509 .unwrap_or("vi");
510 chosen.split_whitespace().map(String::from).collect()
511}
512
513/// Open `buffer` in the user's editor and return the saved contents. The editor
514/// is `$VISUAL`, then `$EDITOR`, then `vi`; the value may carry arguments
515/// (`code --wait`), split on ASCII whitespace with no shell quoting (see
516/// [`editor_command`]). The buffer is staged in a temporary file that is removed
517/// when this returns.
518fn edit_in_editor(buffer: &str) -> anyhow::Result<String> {
519 let mut file = tempfile::Builder::new()
520 .prefix("decdn-select-")
521 .suffix(".txt")
522 .tempfile()
523 .context("create --select scratch file")?;
524 std::io::Write::write_all(file.as_file_mut(), buffer.as_bytes())
525 .context("write --select scratch file")?;
526 file.as_file()
527 .sync_all()
528 .context("sync --select scratch file")?;
529
530 let command = editor_command(
531 std::env::var("VISUAL").ok().as_deref(),
532 std::env::var("EDITOR").ok().as_deref(),
533 );
534 let (program, rest) = command
535 .split_first()
536 .ok_or_else(|| anyhow!("empty editor command"))?;
537
538 let status = std::process::Command::new(program)
539 .args(rest)
540 .arg(file.path())
541 .status()
542 .with_context(|| format!("launch editor {program:?}"))?;
543 if !status.success() {
544 bail!("editor {program:?} exited with {status}; aborting --select");
545 }
546
547 std::fs::read_to_string(file.path()).context("read back --select scratch file")
548}
549
550/// One-line `--json` summary. `fetched`/`linked`/`skipped`/`failed` are entry
551/// counts; `downloaded` is the content bytes this run fetched and paid for across
552/// the distinct blobs fetched (a blob shared across several paths counts once,
553/// #1306; a range-dedup blob counts only the bytes it did not splice from disk)
554/// and `reconstructed` is the total bytes written to disk this run — they diverge
555/// when one blob is materialized to several paths or when range-dedup spliced part
556/// of a blob. `downloaded` is a content-size tally, not an exact on-wire
557/// measurement: it excludes bao proof overhead.
558///
559/// `paid_bytes` is the bytes this run's vouchers and hash-chain reveals paid
560/// for: how far the run grew the claims the nodes can redeem, summed over every
561/// lane. It is counted from payment, not from sizes, so it is wire bytes and
562/// includes the bao proof overhead (a clean pull pays a little more than
563/// `downloaded`). Content delivered twice, or paid for and then dropped after
564/// a lane fault, counts each time it is paid. An entry that fails or is
565/// interrupted counts here but not in `downloaded`; a later run resumes its
566/// `.partial` and reports it as `resumed_bytes`.
567///
568/// `resumed_bytes` is the content bytes of a `.partial` prefix that an earlier,
569/// interrupted run fetched and paid for, which this run resumed rather than
570/// fetched again. It is never part of `downloaded`. A byte that this run also
571/// spliced from disk counts only in `spliced_bytes`.
572///
573/// `excluded` counts the manifest entries this run does not pull: the ones the
574/// `--include`/`--exclude` filter dropped and the ones deselected under
575/// `--select`. `not_run` counts the entries it meant to pull but did not run:
576/// another entry ended the pull, or Ctrl-C stopped it, before their fetch
577/// ended. A rerun runs them.
578///
579/// `reused` and `reused_bytes` report the whole-file dedup outcome, counted per
580/// distinct blob exactly as `fetched`/`downloaded` are (a blob reused at several
581/// paths counts once here; its extra destinations are `linked`): `reused` is the
582/// count of distinct blobs materialized from an on-disk whole-file donor (verified
583/// by re-hash before use), and `reused_bytes` sums those blobs' sizes once each —
584/// bytes served from disk with no download and no payment.
585///
586/// `spliced_bytes` and `hints_ignored` report the range-dedup outcome so a run
587/// whose hints saved bytes is distinguishable from one whose hints did not:
588/// `spliced_bytes` is the total bytes served from a local donor splice (bytes NOT
589/// downloaded or paid for), and `hints_ignored` counts range-dedup hints dropped
590/// by a fault — a chunk set that failed to parse or whose sizes did not sum to the
591/// file size, a donor that failed its verification re-hash (or was unreadable), or
592/// a self-heal whole-file re-drive that discarded already-spliced donors. Both are
593/// reporting only and never affect payment.
594#[derive(Serialize)]
595struct PullReport {
596 output: String,
597 fetched: u64,
598 linked: u64,
599 skipped: u64,
600 /// Entries the filter or `--select` dropped.
601 excluded: u64,
602 failed: u64,
603 /// Entries the pull ended before their fetch ended.
604 not_run: u64,
605 downloaded: u64,
606 /// Wire bytes this run's vouchers and hash-chain reveals paid for, over
607 /// every lane.
608 paid_bytes: u64,
609 reconstructed: u64,
610 spliced_bytes: u64,
611 hints_ignored: u64,
612 /// Content bytes of `.partial` prefixes that earlier runs fetched and this
613 /// run resumed; never part of `downloaded`.
614 resumed_bytes: u64,
615 /// Count of distinct blobs materialized from an on-disk whole-file donor;
616 /// extra destinations of the same blob are counted in `linked`.
617 reused: u64,
618 /// Sum of those blobs' sizes (once per blob) — bytes materialized from a
619 /// whole-file donor with no download or payment.
620 reused_bytes: u64,
621}
622
623/// Load the buyer's Ethereum signer (vouchers + any openPool/topUp tx),
624/// prompting for the keystore password once. Password from
625/// `$DECDN_KEYSTORE_PASSWORD`, else `--keystore-password-file`, else a TTY
626/// prompt.
627fn load_buyer_signer(chain: &fetch::ResolvedChain) -> anyhow::Result<Arc<PrivateKeySigner>> {
628 let password = super::chain_ctx::read_keystore_password(
629 &super::chain_ctx::password_sources(
630 chain.keystore_password_file.as_deref(),
631 PasswordUse::Unlock,
632 ),
633 "eth keystore password",
634 )?
635 .into_secret();
636 Ok(Arc::new(load_signer(&chain.keystore, &password)?))
637}
638
639/// `decdn get decdn:<hash>/` and `decdn get decdn:<hash>/<path>`: pull the
640/// bundle `uri` names, or its one entry, into `dest.path`. With
641/// `dest.entry_name`, the entry lands at that name directly in `dest.path`.
642pub(crate) async fn bundle_pull(
643 args: &GetArgs,
644 dest: &Destination,
645 uri: &DecdnUri,
646 config_path: Option<&Path>,
647) -> anyhow::Result<()> {
648 // `-o -` is stdout, which a pull cannot write a tree to; the blob fetch
649 // takes every `-o -` run. Fail rather than create a directory named `-`.
650 anyhow::ensure!(
651 dest.path != Path::new("-"),
652 "`-o -` (stdout) does not take a bundle pull"
653 );
654 // Validate the flag combination first — BEFORE the dry-run short-circuit — so a
655 // bad combo (e.g. `--provider-address` without `--node-id`) or an unusable
656 // timeout pair is rejected even for `--dry-run`. This rule does not run at
657 // parse time, so `bundle_pull` calls it BEFORE the dry-run short-circuit. It
658 // is cheap and side-effect-free.
659 args.common.validate()?;
660
661 // Compile the entry selection BEFORE the dry-run short-circuit — same
662 // rationale as `validate()`: a malformed glob is rejected before any
663 // network, chain, or keystore work, and `--dry-run` reflects the filtered
664 // plan too.
665 let entry = match &uri.target {
666 UriTarget::BundleEntry(path) => Some(path.clone()),
667 _ => None,
668 };
669 let filter = EntryFilter::compile(entry, &args.include, &args.exclude)?
670 .landing_as(dest.entry_name.clone());
671 let out = dest.path.as_path();
672 let filters_given = !args.include.is_empty() || !args.exclude.is_empty();
673
674 // `--select` opens an editor: reject an incompatible combo or a non-terminal
675 // up front — before any network, chain, or keystore work — so it fails fast
676 // rather than after paying to fetch the manifest.
677 check_select_flags(args)?;
678
679 // Dry-run short-circuits before any network/chain/keystore activity.
680 if args.dry_run {
681 return dry_run(args, out, uri, &filter, filters_given);
682 }
683
684 let common = &args.common;
685 let relays = client_endpoint::resolve_relays(common.relay_url.as_deref(), config_path)?;
686 let disc = client_endpoint::client_discovery(config_path)?;
687 let file = load_file_config(config_path)?;
688 let mut chain = fetch::resolve_chain(common, &file)?;
689
690 // Delegated adoption: `--capability`/`--capability-file` names a pool the
691 // caller does NOT own and a capability authorizing this client's key to
692 // spend against it (every entry pulls from that one pool). `None` => the
693 // unchanged self-owned pool path; a delegated grant also zeroes the working
694 // deposit in `chain`, so its funding recovery step has nothing to add.
695 let grant = fetch::resolve_delegation_grant(common, &mut chain)?;
696
697 // Same guard as the blob route, for the same reason and before the same
698 // password prompt: a node's data dir is a buy target only when the
699 // operator named it (#2082).
700 let store: Arc<dyn BuyerPoolStore> = Arc::new(open_client_store_for_buy(
701 &chain.site.data_dir,
702 chain.data_dir_source,
703 "pull",
704 )?);
705 let endpoint = client_endpoint::client_endpoint(&relays, &disc).await?;
706 // The body runs in `pull_over`, so the endpoint closes on every exit —
707 // success, an early return, or an error — and its open connections end
708 // cleanly instead of being aborted on drop.
709 let result = pull_over(
710 args,
711 out,
712 uri,
713 &chain,
714 grant,
715 &store,
716 &endpoint,
717 &relays,
718 (&filter, filters_given),
719 )
720 .await;
721 endpoint.close().await;
722 result
723}
724
725/// The part of [`bundle_pull`] that runs over its open `endpoint`: resolve the
726/// providers and the buyer signer, build the puller, obtain the manifest, and
727/// pull it. `filter` is the compiled entry selection and whether `--include` or
728/// `--exclude` was given.
729#[allow(clippy::too_many_arguments)]
730async fn pull_over(
731 args: &GetArgs,
732 out: &Path,
733 uri: &DecdnUri,
734 chain: &fetch::ResolvedChain,
735 grant: Option<CapabilityGrant>,
736 store: &Arc<dyn BuyerPoolStore>,
737 endpoint: &Endpoint,
738 relays: &[RelayUrl],
739 (filter, filters_given): (&EntryFilter, bool),
740) -> anyhow::Result<()> {
741 let common = &args.common;
742 // The node selection is resolved BEFORE the keystore prompt, so a failed
743 // discovery never prompts. A registry read is bounded by `--timeout-ms`
744 // (#1349), inside the read so a timeout still falls through to the peer
745 // store. No sampling or probing happens here: each entry samples and
746 // probes its own candidates.
747 let discovery = fetch::discovery_options(common)?;
748 let nodes = select_nodes(&chain.config, &chain.site, &discovery).await?;
749 let signer = load_buyer_signer(chain)?;
750 let funding = match &grant {
751 None => Funding::SelfOwned {
752 owner: signer,
753 deposit: chain.deposit_policy(false)?,
754 },
755 Some(grant) => Funding::Delegated {
756 slot: fetch::delegated_slot(grant, signer)?,
757 },
758 };
759
760 let observer = Arc::new(PullObserver::new(args.json, run_label(out)));
761 // Outlives the puller's passes, so an `openPool` or `topUp` that a Ctrl-C
762 // or a fault dropped before its record committed is still named, and so is
763 // one that escrowed untracked in an entry the pull dropped.
764 let escrows = UnconfirmedEscrows::default();
765 let mut puller = BundlePuller::builder(
766 chain.config.clone(),
767 chain.site.clone(),
768 endpoint.clone(),
769 Arc::clone(store),
770 funding,
771 discovery,
772 nodes,
773 )
774 .relays(relays.to_vec())
775 .namespace(uri.namespace)
776 .jobs(args.jobs.unwrap_or(DEFAULT_JOBS))
777 .max_lane_streams(usize::from(
778 args.max_lane_streams.unwrap_or(DEFAULT_MAX_LANE_STREAMS),
779 ))
780 .max_rate_per_mb(common.max_rate_per_mb)
781 .max_blob_bytes(common.max_blob_mb.saturating_mul(1024 * 1024))
782 .stop(std::io::stderr().is_terminal(), common.give_up_after())
783 .observer(Arc::clone(&observer) as Arc<dyn BundleObserver>)
784 .fault_hints(Arc::new(CliFaultHints))
785 .unconfirmed_escrows(escrows.clone())
786 .build()
787 .await?;
788
789 // Watched from before the manifest: the manifest fetch is paid, so it can
790 // open the run's pool. The first Ctrl-C stops the run where it is; a
791 // second one exits at once.
792 let mut interrupt = Interrupt::watch(escrows.clone());
793 let result = pull_manifest(
794 &mut puller,
795 &mut interrupt,
796 &observer,
797 (args, out),
798 uri,
799 (filter, filters_given),
800 )
801 .await;
802 // On every exit, the manifest fetch's and a cancelled pull's included:
803 // each pool a recovery step closed may still hold a balance.
804 fetch::print_reclaim_notes(&puller.replacements());
805 // Every exit of the manifest or the pull can drop an `openPool` or a
806 // `topUp` before its record commits, and the transaction may still escrow,
807 // or leave an untracked escrow on the handle that no returned error names.
808 let result = name_held_escrows(result, &escrows);
809 // On every exit, a Ctrl-C's included: what the entries queued is durable
810 // before the command returns.
811 puller.finish().await;
812 result
813}
814
815/// Print the run's wallet-shortfall warning, if it has one, for a run that
816/// ends before its summary.
817fn warn_shortfall(puller: &BundlePuller) {
818 if let Some(shortfall) = puller.funding_shortfall() {
819 eprintln!("warning: {shortfall}");
820 }
821}
822
823/// Obtain the bundle manifest through `puller`, pull every kept entry, and
824/// print the run summary.
825async fn pull_manifest(
826 puller: &mut BundlePuller,
827 interrupt: &mut Interrupt,
828 observer: &PullObserver,
829 (args, out): (&GetArgs, &Path),
830 uri: &DecdnUri,
831 (filter, filters_given): (&EntryFilter, bool),
832) -> anyhow::Result<()> {
833 // Obtain the manifest: the bundle blob fetched and filtered here. `None` =>
834 // filtered to empty (already reported). `--select`: let the user trim the
835 // (already glob-filtered) list in their editor. Everything deselected ends
836 // the run (reported) like an empty filter.
837 let selected = async {
838 let fetch = obtain_manifest(puller, (args, out), uri, filter, filters_given);
839 let Some(kept) = until_interrupted(fetch, interrupt).await? else {
840 return Ok(None);
841 };
842 let kept = maybe_select(args, out, uri, kept)?;
843 if let Some(Kept { manifest, .. }) = &kept {
844 refuse_foreign(args, out, manifest)?;
845 }
846 anyhow::Ok(kept)
847 }
848 .await;
849 // The manifest fetch is paid, so a run that ends here still owes the
850 // wallet-shortfall warning the summary below prints otherwise.
851 let Some(Kept { manifest, excluded }) = selected.inspect_err(|_| warn_shortfall(puller))?
852 else {
853 warn_shortfall(puller);
854 return Ok(());
855 };
856
857 // The first Ctrl-C cancels the pull, which stops every fetch where it is
858 // and still reports what landed.
859 let cancel = CancellationToken::new();
860 let outcome = {
861 let watch = async {
862 interrupt.wait().await;
863 cancel.cancel();
864 std::future::pending::<std::convert::Infallible>().await
865 };
866 tokio::select! {
867 outcome = puller.pull(&manifest, out, args.overwrite, &cancel) => outcome,
868 never = watch => match never {},
869 }
870 };
871 observer.finish();
872 warn_leftover_partials(out, &manifest.entries, uri.hash);
873 let reported = report(&outcome.report, excluded, out, args.json);
874 // A fault that ended the whole pull is its result, so `main` maps it to
875 // its exit code (75 for a give-up with no `openPool` or `topUp` in
876 // flight, 1 otherwise).
877 match outcome.end {
878 BundleEnd::Interrupted => Err(Interrupted.into()),
879 BundleEnd::FundingNeeded { error, .. } | BundleEnd::Stopped(error) => Err(error),
880 _ => reported,
881 }
882}
883
884/// `fetch`'s result, or [`Interrupted`] when the first Ctrl-C arrives first.
885/// The Ctrl-C drops `fetch` where it is, which queues the watermark writes of
886/// the lanes it drew on; the puller's `finish` settles them.
887async fn until_interrupted<T>(
888 fetch: impl std::future::Future<Output = anyhow::Result<T>>,
889 interrupt: &mut Interrupt,
890) -> anyhow::Result<T> {
891 tokio::select! {
892 got = fetch => got,
893 () = interrupt.wait() => Err(Interrupted.into()),
894 }
895}
896
897/// The kept manifest for the run: the bundle blob fetched and filtered here.
898/// The fetch reads the output root's manifest cache first, unless
899/// `--overwrite`. `Ok(None)` means the manifest is empty after filtering —
900/// [`report_nothing_to_fetch`] was already called, so the caller returns
901/// `Ok(())`.
902async fn obtain_manifest(
903 puller: &BundlePuller,
904 (args, out): (&GetArgs, &Path),
905 uri: &DecdnUri,
906 filter: &EntryFilter,
907 filters_given: bool,
908) -> anyhow::Result<Option<Kept>> {
909 let mut m = puller
910 .fetch_manifest(uri.hash, out, !args.overwrite)
911 .await
912 .map_err(not_a_manifest_hint)?;
913 let raw_empty = m.entries.is_empty();
914 let (entries, excluded) = filter.apply_and_warn(m.entries)?;
915 m.entries = entries;
916 if m.entries.is_empty() {
917 report_nothing_to_fetch(NothingReason::from_filter(filters_given, raw_empty));
918 warn_leftover_partials(out, &[], uri.hash);
919 return Ok(None);
920 }
921 Ok(Some(Kept {
922 manifest: m,
923 excluded,
924 }))
925}
926
927/// The CLI's [`BundleObserver`]: the multi-bar progress renderer, built once
928/// the run's totals are known; the `-v` line for each landed entry; and a
929/// `warning:` or `note:` line on stderr for what does not fail the pull.
930struct PullObserver {
931 json: bool,
932 label: String,
933 progress: OnceLock<PullProgress>,
934}
935
936impl PullObserver {
937 /// An observer that renders bars unless `json`, under the header `label`.
938 const fn new(json: bool, label: String) -> Self {
939 Self {
940 json,
941 label,
942 progress: OnceLock::new(),
943 }
944 }
945
946 /// Clear the bars at the end of the run; the command then prints its
947 /// summary.
948 fn finish(&self) {
949 if let Some(progress) = self.progress.get() {
950 progress.finish();
951 }
952 }
953}
954
955impl BundleObserver for PullObserver {
956 fn run_started(&self, download_total: Option<u64>, content_total: Option<u64>) {
957 // The kept manifest is known: enable the renderer (silent off a
958 // terminal or under `--json`). Its total bar meters the run's
959 // download, with the whole on-disk content size in the header.
960 let _ = self.progress.set(PullProgress::new(
961 self.json,
962 download_total,
963 content_total,
964 &self.label,
965 ));
966 }
967
968 fn file_started(
969 &self,
970 paths: &[String],
971 download_total: u64,
972 reconstruct_total: u64,
973 ) -> Arc<dyn FileObserver> {
974 let label = pull_progress::file_label(paths);
975 let bar = match self.progress.get() {
976 Some(progress) => progress.file_bar(&label, download_total, reconstruct_total),
977 None => PullProgress::disabled().file_bar(&label, download_total, reconstruct_total),
978 };
979 Arc::new(bar)
980 }
981
982 fn entry_landed(&self, landed: &EntryLanded) {
983 let bytes = EntryBytes {
984 paid: landed.paid,
985 spliced: landed.spliced,
986 resumed: landed.resumed,
987 };
988 tracing::info!(
989 "{}",
990 entry_done_line(landed.hash, landed.size, bytes, landed.elapsed)
991 );
992 }
993
994 fn warning(&self, warning: &str) {
995 eprintln!("warning: {warning}");
996 }
997
998 fn pool_replaced(&self, replaced: &PoolReplaced) {
999 eprintln!("note: {replaced}");
1000 }
1001}
1002
1003/// The CLI's [`FaultHints`]: the remedies that name its own flags and
1004/// commands ([`fetch::annotate`]).
1005struct CliFaultHints;
1006
1007impl FaultHints for CliFaultHints {
1008 fn annotate(&self, err: anyhow::Error, at: &FaultContext) -> anyhow::Error {
1009 fetch::annotate(err, at)
1010 }
1011}
1012
1013/// The content bytes one landed entry paid for, spliced from disk, and
1014/// resumed from an earlier run. No byte counts in two of them.
1015#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1016struct EntryBytes {
1017 paid: u64,
1018 spliced: u64,
1019 resumed: u64,
1020}
1021
1022/// A finished entry's line at `-v` (#2120): its size, the time it took, and
1023/// its rate over the bytes this run paid for. When the entry resumed bytes
1024/// from an earlier run or spliced bytes from disk, the line reports each
1025/// non-zero one apart from the paid bytes (#2189, #2236). The time covers the
1026/// whole entry: probing, every drive, any splice and the whole-file check.
1027fn entry_done_line(
1028 hash: [u8; 32],
1029 size: u64,
1030 bytes: EntryBytes,
1031 elapsed: std::time::Duration,
1032) -> String {
1033 let secs = elapsed.as_secs_f64();
1034 let rate = if secs > 0.0 {
1035 fetch::fmt_rate(fetch::bytes_as_f64(bytes.paid) / secs)
1036 } else {
1037 fetch::fmt_rate(0.0)
1038 };
1039 let detail = if bytes.spliced == 0 && bytes.resumed == 0 {
1040 rate
1041 } else {
1042 let mut parts = vec![format!(
1043 "{} downloaded at {rate}",
1044 indicatif::HumanBytes(bytes.paid)
1045 )];
1046 if bytes.resumed > 0 {
1047 parts.push(format!("{} resumed", indicatif::HumanBytes(bytes.resumed)));
1048 }
1049 if bytes.spliced > 0 {
1050 parts.push(format!(
1051 "{} spliced from disk",
1052 indicatif::HumanBytes(bytes.spliced)
1053 ));
1054 }
1055 parts.join(", ")
1056 };
1057 format!(
1058 "get: {}: {} in {secs:.1}s ({detail})",
1059 blake3::Hash::from_bytes(hash).to_hex(),
1060 indicatif::HumanBytes(size),
1061 )
1062}
1063
1064/// Why a pull ended with no entries to fetch — picks the message
1065/// [`report_nothing_to_fetch`] prints, so the operator learns the actual cause
1066/// (an empty bundle, globs that matched nothing, or a fully deselected list)
1067/// rather than mistaking one for another.
1068#[derive(Clone, Copy)]
1069enum NothingReason {
1070 /// The bundle manifest itself is empty.
1071 EmptyBundle,
1072 /// `--include`/`--exclude` removed every entry.
1073 Filtered,
1074 /// The user deselected every entry in the `--select` editor.
1075 Deselected,
1076}
1077
1078impl NothingReason {
1079 /// The reason for an empty result of a filter pass: [`Filtered`] when a filter
1080 /// was given and the bundle was non-empty before it, else [`EmptyBundle`].
1081 ///
1082 /// [`Filtered`]: NothingReason::Filtered
1083 /// [`EmptyBundle`]: NothingReason::EmptyBundle
1084 const fn from_filter(filters_given: bool, raw_empty: bool) -> Self {
1085 if filters_given && !raw_empty {
1086 Self::Filtered
1087 } else {
1088 Self::EmptyBundle
1089 }
1090 }
1091}
1092
1093fn report_nothing_to_fetch(reason: NothingReason) {
1094 let msg = match reason {
1095 NothingReason::EmptyBundle => "bundle has no entries; nothing to fetch",
1096 NothingReason::Filtered => {
1097 "no bundle entries match the include/exclude filters; nothing to fetch"
1098 }
1099 NothingReason::Deselected => "every file was deselected; nothing to fetch",
1100 };
1101 println!("{msg}");
1102}
1103
1104/// The hint for a bundle URI whose hash names a blob that is not a manifest:
1105/// the trailing `/` asked for a bundle, and dropping it fetches the raw blob.
1106/// Every other error passes through unchanged.
1107fn not_a_manifest_hint(err: anyhow::Error) -> anyhow::Error {
1108 if err
1109 .chain()
1110 .any(<dyn std::error::Error + 'static>::is::<NotAManifest>)
1111 {
1112 err.context(
1113 "the URI's trailing `/` names a bundle, but this blob is not a bundle manifest; \
1114 drop the trailing `/` to fetch the raw blob",
1115 )
1116 } else {
1117 err
1118 }
1119}
1120
1121/// Print the would-fetch plan and exit (no network/chain/keystore activity).
1122/// The plan lists the entries of the bundle's manifest as cached in the output
1123/// root by an earlier pull. The cache is read even under `--overwrite`, which
1124/// makes a real pull fetch the manifest again: the cache is content-addressed,
1125/// so its bytes are exactly what that fetch returns. The entry selection
1126/// `filter` applies to those entries, so the plan is the entry set a real run
1127/// starts from and the unmatched-pattern warnings are the ones it prints. A real
1128/// run then skips files already present in the output root (unless
1129/// `--overwrite`) and warns on leftover partials; the dry run does neither. It
1130/// does refuse a default destination that holds a file the pull may replace
1131/// ([`refuse_foreign`]), as a real run does. A bundle with no cached manifest
1132/// reports only the bundle and the output root, with `entries: null` under
1133/// `--json`.
1134fn dry_run(
1135 args: &GetArgs,
1136 output: &Path,
1137 uri: &DecdnUri,
1138 filter: &EntryFilter,
1139 filters_given: bool,
1140) -> anyhow::Result<()> {
1141 let out = output.display();
1142 let cached = BundleManifest::cached(output, uri.hash).map_err(not_a_manifest_hint)?;
1143 let Some(mut manifest) = cached else {
1144 let h = blake3::Hash::from_bytes(uri.hash).to_hex();
1145 if args.json {
1146 let plan = serde_json::json!({
1147 "output": out.to_string(),
1148 "hash": h.as_str(),
1149 "count": null,
1150 "entries": null,
1151 });
1152 println!("{plan}");
1153 return Ok(());
1154 }
1155 let filter_note = if filters_given {
1156 "; the include/exclude patterns are matched against them then"
1157 } else {
1158 ""
1159 };
1160 println!(
1161 "--dry-run: would fetch bundle {h} then its entries into {out} \
1162 (no cached manifest: entries are listed once a pull into this directory \
1163 caches it{filter_note})"
1164 );
1165 return Ok(());
1166 };
1167 let raw_empty = manifest.entries.is_empty();
1168 manifest.entries = filter.apply_and_warn(manifest.entries)?.0;
1169 refuse_foreign(args, output, &manifest)?;
1170 if args.json {
1171 // The `--json` plan stays machine-readable — an empty set is
1172 // `count: 0` with an empty `entries` array, no prose line. A bundle
1173 // with no cached manifest gives `count`/`entries` of `null`.
1174 let plan = serde_json::json!({
1175 "output": out.to_string(),
1176 "count": manifest.entries.len(),
1177 "entries": manifest.entries.iter().map(|e| serde_json::json!({
1178 "path": e.path, "hash": e.hash, "size": e.size,
1179 "chunks": e.chunks.as_ref().map(Vec::len),
1180 })).collect::<Vec<_>>(),
1181 });
1182 println!("{plan}");
1183 } else if manifest.entries.is_empty() {
1184 // Match the real run's empty-result message rather than printing a
1185 // "would fetch 0 entr(ies)" plan, so `--dry-run` and a live pull
1186 // agree on what an emptied set looks like.
1187 report_nothing_to_fetch(NothingReason::from_filter(filters_given, raw_empty));
1188 } else {
1189 println!(
1190 "would fetch {} entr(ies) into {out}:",
1191 manifest.entries.len()
1192 );
1193 for e in &manifest.entries {
1194 let chunks = e
1195 .chunks
1196 .as_ref()
1197 .map(|c| format!(", {} chunks", c.len()))
1198 .unwrap_or_default();
1199 match e.size {
1200 Some(n) => println!(" {} ({n} bytes{chunks})", e.path),
1201 None => println!(" {}{}", e.path, chunks),
1202 }
1203 }
1204 }
1205 Ok(())
1206}
1207
1208/// Summarize the run; return an error if any entry failed (after reporting
1209/// all). Each of the report's warnings (a blob whose size differs from its
1210/// manifest, a wallet short of USDC) prints one line on stderr and never
1211/// changes the result. `excluded` is the count of entries the run does not
1212/// pull ([`PullReport`]).
1213fn report(rep: &BundleReport, excluded: u64, output: &Path, json: bool) -> anyhow::Result<()> {
1214 for failure in &rep.failures {
1215 // A per-entry failure is a command result the user needs, not
1216 // routing narration: keep it on stderr (unconditional, and clear
1217 // of the `--json` report on stdout) rather than behind logging.
1218 eprintln!("failed: {}: {}", failure.path, failure.error);
1219 }
1220 for line in &rep.warnings {
1221 eprintln!("warning: {line}");
1222 }
1223
1224 let summary = pull_report(rep, excluded, output);
1225 if json {
1226 let line = serde_json::to_string(&summary).map_err(|e| anyhow!("serialize report: {e}"))?;
1227 println!("{line}");
1228 } else {
1229 println!("{}", counts_line(&summary));
1230 // `downloaded X (paid P) → reconstructed Y`: the arrow half only when
1231 // dedup made the two differ, the paid part unless the run neither
1232 // downloaded nor paid.
1233 println!(
1234 "{}",
1235 transfer_line(rep.downloaded, rep.paid_bytes, rep.reconstructed)
1236 );
1237 // The bytes earlier, interrupted runs fetched and this run resumed, shown
1238 // only when a run resumed any: they are not in `downloaded` or `paid`.
1239 if rep.resumed_bytes > 0 {
1240 println!(
1241 "resumed {} from earlier partials",
1242 human_bytes(rep.resumed_bytes)
1243 );
1244 }
1245 // The whole-file dedup outcome, shown only when it mattered: a run that
1246 // materialized any destination from an on-disk donor instead of fetching.
1247 if rep.reused > 0 {
1248 println!(
1249 "whole-file dedup: reused {} from disk ({} file(s))",
1250 human_bytes(rep.reused_bytes),
1251 rep.reused
1252 );
1253 }
1254 // The range-dedup outcome, shown only when a hint mattered: a run that
1255 // spliced bytes from disk, or one whose hints were dropped by a fault. A
1256 // plain pull with no usable hints stays silent (both are zero), so a
1257 // hint-carrying bundle whose hints all lie (spliced 0, some ignored)
1258 // does not print the same summary as an unhinted one.
1259 if rep.spliced_bytes > 0 || rep.hints_ignored > 0 {
1260 println!(
1261 "range-dedup: spliced {} from disk, {} hint(s) ignored",
1262 human_bytes(rep.spliced_bytes),
1263 rep.hints_ignored
1264 );
1265 }
1266 }
1267
1268 if rep.failed > 0 {
1269 bail!("{} entr(ies) failed to fetch", rep.failed);
1270 }
1271 Ok(())
1272}
1273
1274/// The run's [`PullReport`]: `rep`'s counts and byte tallies, beside the
1275/// `excluded` count and the output directory the command holds.
1276fn pull_report(rep: &BundleReport, excluded: u64, output: &Path) -> PullReport {
1277 PullReport {
1278 output: output.display().to_string(),
1279 fetched: rep.fetched,
1280 linked: rep.linked,
1281 skipped: rep.skipped,
1282 excluded,
1283 failed: rep.failed,
1284 not_run: rep.not_run,
1285 downloaded: rep.downloaded,
1286 paid_bytes: rep.paid_bytes,
1287 reconstructed: rep.reconstructed,
1288 spliced_bytes: rep.spliced_bytes,
1289 hints_ignored: rep.hints_ignored,
1290 resumed_bytes: rep.resumed_bytes,
1291 reused: rep.reused,
1292 reused_bytes: rep.reused_bytes,
1293 }
1294}
1295
1296/// The summary's first line: where the run pulled to, and every entry count.
1297/// The entries the pull did not run are named only when there are some.
1298fn counts_line(rep: &PullReport) -> String {
1299 let not_run = if rep.not_run == 0 {
1300 String::new()
1301 } else {
1302 format!(", {} not run", rep.not_run)
1303 };
1304 format!(
1305 "pulled into {} ({} fetched, {} linked, {} skipped, {} reused, {} excluded, {} \
1306 failed{not_run})",
1307 rep.output, rep.fetched, rep.linked, rep.skipped, rep.reused, rep.excluded, rep.failed
1308 )
1309}
1310
1311/// Scan `<out_root>/.decdn-partial` for leftover per-hash staging files —
1312/// `<hex>`, `<hex>.partial`, `<hex>.partial.ranges` — whose hash is not in
1313/// `keep`, and total the disk space they take ([`allocated_bytes`]). Other
1314/// names (a record's temporary file) are not counted. A missing or unreadable
1315/// directory holds nothing.
1316fn leftover_partials(out_root: &Path, keep: &HashSet<[u8; 32]>) -> Leftovers {
1317 let Ok(dir) = std::fs::read_dir(out_root.join(STAGING_DIR)) else {
1318 return Leftovers::default();
1319 };
1320 let mut blobs: HashSet<[u8; 32]> = HashSet::new();
1321 let mut bytes = 0u64;
1322 for entry in dir.flatten() {
1323 let name = entry.file_name();
1324 let Some(name) = name.to_str() else {
1325 continue;
1326 };
1327 let stem = name
1328 .strip_suffix(".partial.ranges")
1329 .or_else(|| name.strip_suffix(".partial"))
1330 .unwrap_or(name);
1331 let Ok(hash) = blake3::Hash::from_hex(stem) else {
1332 continue;
1333 };
1334 let hash = *hash.as_bytes();
1335 if keep.contains(&hash) {
1336 continue;
1337 }
1338 let Ok(meta) = entry.metadata() else {
1339 continue;
1340 };
1341 if meta.is_file() {
1342 blobs.insert(hash);
1343 bytes = bytes.saturating_add(allocated_bytes(&meta));
1344 }
1345 }
1346 Leftovers {
1347 blobs: u64::try_from(blobs.len()).unwrap_or(u64::MAX),
1348 bytes,
1349 }
1350}
1351
1352/// The disk space a file takes. A range-dedup `.partial` is sized to the whole
1353/// blob before any byte lands, so its length can be far above the space that
1354/// deleting it frees. On Unix the allocated blocks give that space; elsewhere
1355/// the length is the best figure.
1356fn allocated_bytes(meta: &std::fs::Metadata) -> u64 {
1357 #[cfg(unix)]
1358 {
1359 use std::os::unix::fs::MetadataExt;
1360 meta.blocks().saturating_mul(512)
1361 }
1362 #[cfg(not(unix))]
1363 {
1364 meta.len()
1365 }
1366}
1367
1368/// Staging files in the staging directory that a run did not use: how many
1369/// distinct blobs, and the disk space their files take.
1370#[derive(Debug, Default, PartialEq, Eq)]
1371struct Leftovers {
1372 blobs: u64,
1373 bytes: u64,
1374}
1375
1376/// The warning line for `leftovers` in `dir`, without the `warning: ` prefix,
1377/// or `None` when there are none.
1378fn leftover_warning(dir: &Path, leftovers: &Leftovers) -> Option<String> {
1379 let size = human_bytes(leftovers.bytes);
1380 let dir = dir.display();
1381 match leftovers.blobs {
1382 0 => None,
1383 1 => Some(format!(
1384 "1 partial download ({size} on disk) that this run did not use remains in \
1385 {dir}; delete it to reclaim the space"
1386 )),
1387 n => Some(format!(
1388 "{n} partial downloads ({size} on disk) that this run did not use remain in \
1389 {dir}; delete them to reclaim the space"
1390 )),
1391 }
1392}
1393
1394/// Warn on stderr about leftover staging files of blobs this run did not use
1395/// ([`leftover_partials`]): a partial of an entry an earlier run started,
1396/// which this run's filter or selection left out, or one another bundle
1397/// pulled into the same output directory left. They stay for a later run that
1398/// selects the entry again, so the warning only names them and never deletes
1399/// them. `entries` are the run's selected entries (none when the filter or
1400/// `--select` emptied the run), and `bundle_hash` is the manifest blob, which
1401/// is also this run's own.
1402fn warn_leftover_partials(out_root: &Path, entries: &[ManifestEntry], bundle_hash: [u8; 32]) {
1403 let keep: HashSet<[u8; 32]> = entries
1404 .iter()
1405 .filter_map(|e| fetch::parse_hash(&e.hash).ok())
1406 .chain([bundle_hash])
1407 .collect();
1408 let leftovers = leftover_partials(out_root, &keep);
1409 if let Some(line) = leftover_warning(&out_root.join(STAGING_DIR), &leftovers) {
1410 eprintln!("warning: {line}");
1411 }
1412}
1413
1414/// Format a byte count as a short decimal-unit label (`13.8 GB`). SI (1000-based)
1415/// units match how file and model sizes are usually quoted.
1416fn human_bytes(n: u64) -> String {
1417 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
1418 if n < 1000 {
1419 return format!("{n} B");
1420 }
1421 // Precision loss is irrelevant here: the result is a one-decimal human label.
1422 #[allow(
1423 clippy::cast_precision_loss,
1424 reason = "display-only size label; exact integer value is not needed"
1425 )]
1426 let mut value = n as f64;
1427 let mut unit = 0usize;
1428 while value >= 1000.0 && unit + 1 < UNITS.len() {
1429 value /= 1000.0;
1430 unit += 1;
1431 }
1432 let label = UNITS.get(unit).copied().unwrap_or("B");
1433 format!("{value:.1} {label}")
1434}
1435
1436/// The end-of-pull transfer line. `downloaded X` carries the wire bytes the
1437/// run paid for as `(paid P)`, left out only when the run neither downloaded
1438/// nor paid anything. `paid` is wire bytes and `downloaded` content bytes, so
1439/// on a clean pull `paid` runs ahead by the bao proof overhead. When dedup
1440/// saved a transfer, `downloaded` and `reconstructed`
1441/// differ and both are shown with an arrow; otherwise the `→ reconstructed`
1442/// half is omitted rather than repeating the same figure.
1443fn transfer_line(downloaded: u64, paid: u64, reconstructed: u64) -> String {
1444 let paid = if downloaded > 0 || paid > 0 {
1445 format!(" (paid {})", human_bytes(paid))
1446 } else {
1447 String::new()
1448 };
1449 let rebuilt = if downloaded == reconstructed {
1450 String::new()
1451 } else {
1452 format!(" → reconstructed {}", human_bytes(reconstructed))
1453 };
1454 format!("downloaded {}{paid}{rebuilt}", human_bytes(downloaded))
1455}
1456
1457#[cfg(test)]
1458#[allow(
1459 clippy::unwrap_used,
1460 clippy::expect_used,
1461 clippy::indexing_slicing,
1462 clippy::panic
1463)]
1464mod tests;