Skip to main content

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;