Skip to main content

decdn_cli/commands/
bundle_pull.rs

1//! `decdn bundle pull` — fetch every blob a bundle manifest references into an
2//! output directory over the paid `cdn/client/v1` path (issue #391).
3//!
4//! The manifest comes from a local file (`-i`) or is fetched first by its own
5//! BLAKE3 hash (`--hash`); either way entries are then fetched per *distinct*
6//! blob hash. Entries naming the same blob (one file at two paths) are fetched —
7//! and paid for — once and hard-linked (or copied) to each path (#1306). Node selection is
8//! per blob (#936): with an explicit `--node-id` every entry is pulled from that
9//! one node, otherwise each distinct blob discovers its own holder among the
10//! region-nearest active nodes. Every group's fetch — a plain whole-file blob,
11//! or a hint-carrying entry's pay-now-range fetch — fans out concurrently,
12//! bounded by one global `--jobs` cap (`PullCtx.gate`). An entry that only
13//! waits for a sibling's donor chunks holds no slot while it waits. A manifest `chunks`
14//! entry is never fetched or stored as its own blob: its hints only let a
15//! byte range shared with another entry be recognized and spliced from disk
16//! instead of paid for again.
17//!
18//! **Shared chunks are fetched once.** When a chunk hint hash appears in two or
19//! more entries, the run assigns that chunk to one entry — its smallest holder,
20//! computed up front from the whole manifest by `build_fetch_plan` — and only
21//! that entry pays to fetch it. Groups are scheduled smallest-first, so a holder
22//! starts before the larger entries that share its chunks. Each entry drives its
23//! own ranges (unique chunks, chunks assigned to it, and the partial groups at
24//! the two ends of each spliced run) up front and defers the rest; at its tail it splices each deferred range as the
25//! assigned holder registers it, waiting on the holder — not a clock — and driving
26//! a deferred range itself only if that holder finishes without producing it.
27//!
28//! **One shared pool.** The whole bundle pulls from the caller's single
29//! `PaymentPool` deposit (ADR 003) — opened once and reused across every
30//! provider the manifest touches. Two concurrency guards follow from that: the
31//! run's shared `LaneLedgers` (ADR 039) give each `(pool_id, signer, provider)`
32//! lane one monotonic voucher issuer, so concurrent fetches sharing a lane —
33//! including every leg of a multi-source entry's admitted provider set — draw
34//! from the same watermark instead of racing it, while their transfers still
35//! run concurrently; and a single global mutex serializes every open-or-reuse
36//! call — the pool's on-chain state (deposit, allowance) is one shared resource,
37//! regardless of which provider an entry is bound for.
38//!
39//! **Incremental re-runs.** A run reads `<out_root>/.decdn-manifest.json`
40//! before fetching and folds each completed file's record back into it as the
41//! pull progresses — rewriting the merged cache atomically whenever a batch of
42//! files or a gigabyte of new bytes has landed, and once more when the pull
43//! ends. An interrupted pull therefore leaves the files it already landed
44//! recorded, so the next run skips them rather than re-hashing the whole tree.
45//! Only a file that completed successfully this run is recorded; a still-in-flight
46//! or not-yet-fetched entry is never written from bytes this run has not landed.
47//! An in-scope path is
48//! skipped when the saved record's hash, size, and mtime match the new
49//! manifest. A path with no matching saved record is still skipped when
50//! re-hashing its on-disk bytes matches the new manifest hash. A path whose
51//! on-disk content no longer matches the new manifest hash is re-fetched, not
52//! silently kept. Every skipped or freshly written path also seeds its
53//! on-disk byte ranges as chunk donors, so a later run's or bundle's
54//! complement-range fetch can splice an unchanged range from disk instead of
55//! paying for it again.
56//!
57//! **Whole-root content reuse.** A run also reuses a byte-identical file
58//! already anywhere in the output root, not only at its own manifest path. It
59//! consults a whole-root content index built from `.decdn-manifest.json`'s
60//! saved records: target blob hash → an on-disk regular file the manifest
61//! records with that hash. When a blob's hash hits that index, the candidate
62//! is confirmed by a whole-file re-hash, then the destination is materialized
63//! by hard link (or copy) with no download and no payment. A file that only
64//! partially matches an on-disk candidate still splices its common byte
65//! ranges from disk the same way, through the donor mechanism described
66//! above.
67
68use std::collections::{BTreeMap, HashMap, HashSet};
69use std::io::IsTerminal as _;
70use std::path::{Component, Path, PathBuf};
71use std::str::FromStr;
72use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
73use std::sync::{Arc, Mutex, PoisonError};
74
75use alloy::dyn_abi::Eip712Domain;
76use alloy::primitives::Address;
77use alloy::providers::Provider;
78use alloy::signers::local::PrivateKeySigner;
79use anyhow::{Context as _, anyhow, bail};
80use decdn_common::cli::{BundlePullArgs, ClientFetchArgs};
81use decdn_common::config::load_file_config;
82use decdn_common::redact::sanitize_err_chain;
83use decdn_incentive::buyer_pool_redb::RedbBuyerPoolStore;
84use decdn_incentive::eth_identity::{PasswordUse, load_signer};
85use decdn_incentive::payment_pool::PaymentPool;
86use decdn_incentive::{slash_judge_domain, voucher_domain};
87use futures_util::StreamExt as _;
88use iroh::{Endpoint, PublicKey, RelayUrl};
89use serde::{Deserialize, Serialize};
90
91use super::bundle_cache;
92use super::bundle_manifest::{self, SavedManifest, SavedMtime};
93use super::buyer_store::open_client_store_for_buy;
94use super::chain_ctx;
95use super::fetch;
96use super::interrupt::{Interrupt, Interrupted};
97use super::manifest::build_glob_set;
98use super::ordered_writes::OrderedWrites;
99use super::pull_progress::{self, PullProgress};
100use decdn_bao_range::CHUNK_GROUP_BYTES;
101use decdn_client::discovery::{self, NodeCandidate};
102use decdn_client::endpoint as client_endpoint;
103use decdn_client::provider;
104use decdn_client::{
105    ClientRangedStore, Connections, DownloadTarget, Downloader, LaneLedgers, PeerHealth,
106    PoolReplaced, ProgressCallback, ProgressClock, PullDeadlines, RecoveryGate, StopPolicy,
107};
108
109use super::cli_sources::CliSources;
110
111type FetchTarget = (PublicKey, Address);
112
113/// Group manifest entries by their blob `hash`, preserving first-seen order both
114/// across groups and within each group. Entries that name the same blob (one
115/// file published at two paths) land in one group so it is fetched once (#1306).
116fn group_by_hash<'a>(entries: &[&'a ManifestEntry]) -> Vec<HashGroup<'a>> {
117    let mut index: HashMap<&str, usize> = HashMap::new();
118    let mut groups: Vec<HashGroup<'a>> = Vec::new();
119    for entry in entries {
120        let next = groups.len();
121        let at = *index.entry(entry.hash.as_str()).or_insert(next);
122        if at == next {
123            groups.push(HashGroup {
124                hash: entry.hash.as_str(),
125                entries: vec![*entry],
126            });
127        } else if let Some(group) = groups.get_mut(at) {
128            group.entries.push(*entry);
129        }
130    }
131    groups
132}
133
134/// Order hash-groups smallest whole-file first so a shared chunk's assigned
135/// fetcher (the smallest containing entry, per [`build_fetch_plan`]) starts — and
136/// finishes — before the larger entries that defer to it, keeping the tail-reconcile
137/// wait short. A group's entries share one blob, so the group's size is any entry's
138/// declared `size` (the same OR-across-same-hash-entries rule as
139/// [`total_content_bytes`], so a group with one unsized duplicate path still sorts
140/// by its real size); a group no entry sizes sorts last (it can never be a sized
141/// donor). Ties break on the group hash for a deterministic order.
142fn order_groups_smallest_first(mut groups: Vec<HashGroup<'_>>) -> Vec<HashGroup<'_>> {
143    groups.sort_by(|a, b| {
144        let a_size = a.entries.iter().find_map(|e| e.size);
145        let b_size = b.entries.iter().find_map(|e| e.size);
146        // A group no entry sizes sorts last: map to the max sentinel for the compare.
147        let key = |s: Option<u64>| s.unwrap_or(u64::MAX);
148        key(a_size)
149            .cmp(&key(b_size))
150            .then_with(|| a.hash.cmp(b.hash))
151    });
152    groups
153}
154
155/// Run blocking file work `f` on tokio's blocking pool and return its result.
156/// Every group of a bundle pull is a future polled by ONE task, so a blocking
157/// call on that task stops every other group's streams from reading or paying —
158/// a serving node then ends them when its proof wait faults. `what` names the
159/// work in a join error.
160async fn off_runtime<T, F>(what: &'static str, f: F) -> anyhow::Result<T>
161where
162    T: Send + 'static,
163    F: FnOnce() -> anyhow::Result<T> + Send + 'static,
164{
165    tokio::task::spawn_blocking(f)
166        .await
167        .map_err(|e| anyhow!("{what} task: {e}"))?
168}
169
170/// [`link_or_copy_atomic`] on the blocking pool: its copy fallback moves a whole
171/// blob.
172async fn link_off_runtime(src: PathBuf, dest: PathBuf) -> anyhow::Result<()> {
173    off_runtime("link", move || link_or_copy_atomic(&src, &dest)).await
174}
175
176/// Materialize `src`'s content at `dest` without re-reading it over the network:
177/// a hard link where the filesystem allows it, else a full copy (cross-device
178/// `EXDEV`, or a filesystem that can't link). Staged in `dest`'s parent and
179/// renamed into place so `dest` is only ever absent or complete — the same
180/// atomic-replace invariant [`materialize`] upholds, which [`resolve_disk_state`]
181/// and [`plan_slots`] rely on when deciding a path is skip-safe.
182fn link_or_copy_atomic(src: &Path, dest: &Path) -> anyhow::Result<()> {
183    let parent = dest
184        .parent()
185        .filter(|p| !p.as_os_str().is_empty())
186        .unwrap_or_else(|| Path::new("."));
187    // A duplicate path may live in a subdir the canonical path never created.
188    std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
189    let staged = tempfile::Builder::new()
190        .prefix(".decdn-link-")
191        .make_in(parent, |candidate| {
192            match std::fs::hard_link(src, candidate) {
193                Ok(()) => Ok(()),
194                // A fresh random candidate colliding is make_in's signal to retry.
195                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
196                // EXDEV or an unsupported link: fall back to a full byte copy.
197                Err(_) => std::fs::copy(src, candidate).map(|_| ()),
198            }
199        })
200        .with_context(|| {
201            format!(
202                "stage link/copy of {} for {}",
203                src.display(),
204                dest.display()
205            )
206        })?;
207    staged
208        .persist(dest)
209        .map_err(|e| e.error)
210        .with_context(|| format!("write {}", dest.display()))?;
211    Ok(())
212}
213
214/// A disk-seeded chunk donor: a byte range already on disk whose bytes back a
215/// chunk `hash`. Fed into [`ChunkIndex`] before fetching so a splice can reuse
216/// on-disk bytes across runs. The source is an OUTPUT path (not a staging blob),
217/// verified by the existing per-chunk re-hash before any splice trusts it.
218struct SeedDonor {
219    /// The chunk's BLAKE3 hash — the [`ChunkIndex`] key.
220    hash: [u8; 32],
221    /// The on-disk output file that holds the chunk's bytes.
222    source: PathBuf,
223    /// Byte offset of the chunk within `source`.
224    offset: u64,
225    /// Chunk length in bytes.
226    len: u64,
227}
228
229/// The result of inspecting the output tree against the new manifest and the
230/// saved skip-cache before fetching: which in-scope paths to skip, and the
231/// on-disk chunk donors to seed.
232#[derive(Default)]
233struct DiskState {
234    /// In-scope manifest paths whose on-disk file already matches the new
235    /// manifest hash (fast-skip or re-hash-confirmed) — not fetched.
236    skip: HashSet<String>,
237    /// On-disk chunk donors to seed into the run's [`ChunkIndex`].
238    seed: Vec<SeedDonor>,
239    /// Whole-file donors already on disk anywhere in the root: target blob hash
240    /// → an on-disk regular file the saved manifest records with that hash. A hit
241    /// lets a group materialize by link/copy instead of fetching — but only after
242    /// the candidate is re-hashed and confirmed (done at the use site).
243    whole_file: HashMap<[u8; 32], PathBuf>,
244}
245
246/// Classify every in-scope entry against the output tree and the saved
247/// skip-cache: fast-skip on a matching saved record (hash + size + mtime), else
248/// re-hash the on-disk bytes against the new manifest hash, else fetch. With
249/// `overwrite` nothing is skipped. The whole-file `hash` is authoritative, so a
250/// stale or absent saved record only costs a re-hash, never correctness.
251async fn resolve_disk_state(
252    entries: &[ManifestEntry],
253    saved: &SavedManifest,
254    out_root: &Path,
255    overwrite: bool,
256) -> DiskState {
257    let mut state = DiskState::default();
258    if overwrite {
259        return state;
260    }
261    for en in entries {
262        let Ok(dest) = safe_join(out_root, &en.path) else {
263            continue; // a bad path fails later in plan_slots; not skippable
264        };
265        if dest.starts_with(out_root.join(STAGING_DIR)) {
266            continue;
267        }
268        // `symlink_metadata` does not follow links: a symlink or any
269        // non-regular file is never skipped, re-hashed, or seeded as a donor —
270        // it is left to the fetch path, whose atomic materialize replaces it
271        // with the manifest's regular file. This avoids hashing a symlink target
272        // and avoids keeping a non-regular file in place on a fast-skip.
273        let Ok(meta) = std::fs::symlink_metadata(&dest) else {
274            continue; // absent/unreadable → fetch
275        };
276        if !meta.is_file() {
277            continue; // symlink / dir / non-regular → not skippable, not a donor
278        }
279        // Fast-skip: the saved record agrees with the file on hash, size, and
280        // mtime. The file is accepted by its hash: the manifest's `size` is only
281        // a first claim, and a blob whose true size differs from it is still
282        // the entry's blob, so it is not fetched again on every run.
283        let fast = saved.get(&en.path).is_some_and(|rec| {
284            rec.hash == en.hash
285                && rec.size == meta.len()
286                && SavedMtime::of(&meta).as_ref() == Some(&rec.mtime)
287        });
288        if fast {
289            state.skip.insert(en.path.clone());
290            seed_new_chunks(&mut state, en, &dest);
291            continue;
292        }
293        // Re-hash gate: confirm the on-disk bytes against the new manifest hash.
294        let Ok(want) = fetch::parse_hash(&en.hash) else {
295            continue;
296        };
297        let dest_buf = dest.clone();
298        let got = tokio::task::spawn_blocking(move || hash_partial(&dest_buf)).await;
299        if let Ok(Ok(got)) = got
300            && got == want
301        {
302            state.skip.insert(en.path.clone());
303            seed_new_chunks(&mut state, en, &dest);
304            continue;
305        }
306        // Present but mismatched: this path will be fetched. Its current bytes
307        // are the OLD file and survive on disk until this entry's own group
308        // atomically materializes (temp + rename), so they are a safe donor for
309        // any other entry that shares an old chunk in the meantime.
310        if let Some(rec) = saved.get(&en.path)
311            && let Some(old) = bundle_manifest::saved_hints(rec)
312        {
313            for (chash, offset, len) in old {
314                if let Ok(h) = fetch::parse_hash(&chash) {
315                    state.seed.push(SeedDonor {
316                        hash: h,
317                        source: dest.clone(),
318                        offset,
319                        len,
320                    });
321                }
322            }
323        }
324    }
325    // Widen reuse to the whole root: every saved record whose file still exists
326    // as a regular file is a donor keyed by content — a whole-file donor (for a
327    // no-download link, verified by re-hash at the use site) and a chunk donor
328    // (spliced under the existing per-chunk re-hash guard). This is what lets a
329    // file shared across bundles at different paths be reused.
330    //
331    // A path this run will overwrite (an in-scope entry not already in
332    // `state.skip`) is excluded from `whole_file` only: its bytes can be
333    // atomically replaced by its own group between the whole-file link's
334    // re-hash and its link/copy (TOCTOU), which would silently corrupt the
335    // link's destination with no post-link verification to catch it. Chunk
336    // donors from the same record stay in `state.seed` — a spliced range is
337    // always covered by the final whole-file re-verification, so the race
338    // there is harmless.
339    let will_write: HashSet<&str> = entries
340        .iter()
341        .map(|e| e.path.as_str())
342        .filter(|p| !state.skip.contains(*p))
343        .collect();
344    for (path, rec) in saved.records() {
345        let Ok(src) = safe_join(out_root, path) else {
346            continue;
347        };
348        if src.starts_with(out_root.join(STAGING_DIR)) {
349            continue;
350        }
351        let Ok(meta) = std::fs::symlink_metadata(&src) else {
352            continue; // gone / unreadable
353        };
354        if !meta.is_file() {
355            continue; // symlink / non-regular → never a donor
356        }
357        if !will_write.contains(path)
358            && let Ok(h) = fetch::parse_hash(&rec.hash)
359        {
360            state.whole_file.entry(h).or_insert_with(|| src.clone());
361        }
362        if let Some(hints) = bundle_manifest::saved_hints(rec) {
363            for (chash, offset, len) in hints {
364                if let Ok(h) = fetch::parse_hash(&chash) {
365                    state.seed.push(SeedDonor {
366                        hash: h,
367                        source: src.clone(),
368                        offset,
369                        len,
370                    });
371                }
372            }
373        }
374    }
375    state
376}
377
378/// Seed `state.seed` with the NEW manifest entry's chunk donors, sourced from
379/// `dest` — the entry's own output file, confirmed unchanged (skipped) this
380/// run. Seeding never gates skip/fetch; it only supplies optional splice
381/// donors for other entries.
382fn seed_new_chunks(state: &mut DiskState, en: &ManifestEntry, dest: &Path) {
383    if let Some(hints) = hints_of(en) {
384        for h in hints {
385            state.seed.push(SeedDonor {
386                hash: h.hash,
387                source: dest.to_path_buf(),
388                offset: h.offset,
389                len: h.len,
390            });
391        }
392    }
393}
394
395/// Resolve each entry's on-disk destination and classify it — a resolve failure,
396/// a path to skip, or a path to write — before any fetch. `skip` is the
397/// [`resolve_disk_state`] pre-pass result: a path lands in it on a matching
398/// saved-manifest record (fast-skip) or, failing that, on a re-hash of the
399/// on-disk bytes against the new manifest hash. A path whose content changed
400/// is written, not silently kept. Evaluated **per destination**, so one path
401/// of a duplicated blob can be skipped while another is written. With
402/// `overwrite` set, `skip` is empty and every destination is written.
403fn plan_slots<'a>(
404    entries: &[&'a ManifestEntry],
405    out_root: &Path,
406    overwrite: bool,
407    skip: &HashSet<String>,
408) -> Vec<Slot<'a>> {
409    entries
410        .iter()
411        .map(|en| match safe_join(out_root, &en.path) {
412            Err(e) => Slot::Failed(EntryOutcome::failed(&en.path, &e)),
413            // Reserve the staging dir: an entry resolving inside
414            // `<out_root>/.decdn-partial/` would collide with a per-hash staging
415            // file, and `remove_staging` could then delete a materialized output.
416            Ok(dest) if dest.starts_with(out_root.join(STAGING_DIR)) => {
417                Slot::Failed(EntryOutcome::failed(
418                    &en.path,
419                    &anyhow::anyhow!(
420                        "manifest path {:?} is inside the reserved staging directory {STAGING_DIR}/",
421                        en.path
422                    ),
423                ))
424            }
425            Ok(_) if !overwrite && skip.contains(en.path.as_str()) => Slot::Skip,
426            Ok(dest) => Slot::Write {
427                label: en.path.as_str(),
428                dest,
429            },
430        })
431        .collect()
432}
433
434/// Turn classified [`Slot`]s into outcomes: materialize the blob at the first
435/// writable destination and hard-link/copy it to the rest. `materialize` is the
436/// paid path and runs **at most once** per group — every later destination goes
437/// through the free `link`, reported as [`EntryOutcome::Linked`] so the summary
438/// never implies a second paid fetch. If the first materialize fails, the next
439/// writable path retries it from the same already-fetched staging file (no
440/// re-fetch), so one bad path can't doom the group.
441///
442/// Parameterized over the two operations so the fetch-once / link-rest invariant
443/// — the core of #1306 — is unit-testable without a live endpoint or pool. Both
444/// are futures: a copy of a multi-GB blob must run off the runtime (see
445/// [`off_runtime`]), because every group of a run shares one task.
446async fn materialize_group<MF, MFut, LF, LFut>(
447    slots: Vec<Slot<'_>>,
448    mut materialize: MF,
449    mut link: LF,
450) -> Vec<EntryOutcome>
451where
452    MF: FnMut(PathBuf) -> MFut,
453    MFut: std::future::Future<Output = anyhow::Result<u64>>,
454    LF: FnMut(PathBuf, PathBuf) -> LFut,
455    LFut: std::future::Future<Output = anyhow::Result<()>>,
456{
457    let mut canonical: Option<PathBuf> = None;
458    let mut outcomes = Vec::with_capacity(slots.len());
459    for slot in slots {
460        let outcome = match slot {
461            Slot::Failed(o) => o,
462            Slot::Skip => EntryOutcome::Skipped,
463            Slot::Write { label, dest } => match &canonical {
464                Some(src) => match link(src.clone(), dest).await {
465                    Ok(()) => EntryOutcome::Linked,
466                    Err(e) => EntryOutcome::failed(label, &e),
467                },
468                None => match materialize(dest.clone()).await {
469                    Ok(n) => {
470                        canonical = Some(dest);
471                        EntryOutcome::Fetched(n)
472                    }
473                    Err(e) => EntryOutcome::failed(label, &e),
474                },
475            },
476        };
477        outcomes.push(outcome);
478    }
479    outcomes
480}
481
482/// Re-hash an on-disk whole-file `donor` candidate against `hash` on the
483/// blocking pool and, on a match, return its on-disk length — the authoritative
484/// size of what is about to be linked, rather than the manifest's optional
485/// `size` (absent on the wire for some bundles). `None` covers a hash mismatch,
486/// a read or metadata failure, and a failed join.
487async fn verified_donor_len(donor: &Path, hash: [u8; 32]) -> Option<u64> {
488    let target = donor.to_path_buf();
489    tokio::task::spawn_blocking(move || {
490        let got = hash_partial(&target).ok()?;
491        (got == hash)
492            .then(|| std::fs::metadata(&target).ok())
493            .flatten()
494            .map(|m| m.len())
495    })
496    .await
497    .unwrap_or(None)
498}
499
500/// Materialize every write slot from an on-disk whole-file `donor` (a byte-
501/// identical file already in the root), by link/copy — no fetch, no payment.
502/// The first write is [`EntryOutcome::Deduped`] (the reused blob, counted once);
503/// each further duplicate path is [`EntryOutcome::Linked`], mirroring the
504/// fetch-once/link-rest accounting of #1306. The donor is verified by the
505/// caller (a re-hash against the target hash) before this is called.
506async fn materialize_from_donor(
507    slots: Vec<Slot<'_>>,
508    donor: &Path,
509    size: u64,
510) -> Vec<EntryOutcome> {
511    materialize_group(
512        slots,
513        |dest| {
514            let donor = donor.to_path_buf();
515            async move { link_off_runtime(donor, dest).await.map(|()| size) }
516        },
517        link_off_runtime,
518    )
519    .await
520    .into_iter()
521    .map(|o| match o {
522        // materialize_group tags the first materialize Fetched(n); relabel it
523        // Deduped since no payment or download occurred.
524        EntryOutcome::Fetched(n) => EntryOutcome::Deduped(n),
525        other => other,
526    })
527    .collect()
528}
529
530/// Read-side bundle manifest (the write-side lives in [`super::bundle`]). `size`
531/// is optional on the wire (per `appendix-bundles.md`); it is informational for
532/// the dry-run plan and not required to fetch.
533#[derive(Debug, Deserialize)]
534struct Manifest {
535    version: u32,
536    entries: Vec<ManifestEntry>,
537}
538
539/// A manifest cut down to the entries this run pulls, and how many entries
540/// the `--include`/`--exclude` filter and the `--select` editor dropped.
541#[derive(Debug)]
542struct Kept {
543    manifest: Manifest,
544    /// Entries the run does not pull: filtered out or deselected.
545    excluded: u64,
546}
547
548#[derive(Debug, Deserialize)]
549struct ManifestEntry {
550    path: String,
551    hash: String,
552    #[serde(default)]
553    size: Option<u64>,
554    /// Optional ordered chunk decomposition (a range-dedup hint, per
555    /// `appendix-bundles.md`). The whole file is always fetched and verified by
556    /// the authoritative whole-file `hash`; when this list is present its chunk
557    /// `(hash, size)` pairs let a fetch splice byte ranges already materialized
558    /// on disk by a sibling entry that shares a chunk, and pay only for the
559    /// complement. When absent the file is fetched as one blob by `hash`.
560    #[serde(default)]
561    chunks: Option<Vec<ManifestChunk>>,
562}
563
564/// One chunk of a hint-carrying [`ManifestEntry`]: an independently
565/// BLAKE3-addressed run of the file's bytes. Both fields are read — `hash`
566/// identifies the chunk in the in-run [`ChunkIndex`], and `size` places it at a
567/// byte offset (the running sum of prior chunk sizes) and bounds the range a
568/// sibling may splice.
569#[derive(Debug, Deserialize)]
570struct ManifestChunk {
571    /// The chunk's BLAKE3 content address (`b3:`hex).
572    hash: String,
573    /// The chunk's length in bytes; the chunk sizes sum to the entry's
574    /// whole-file `size`.
575    size: u64,
576}
577
578/// The run's whole-download content size — the fixed denominator for the total
579/// progress bar. Every entry declares its whole-file `size` and is deduped by
580/// `hash` (a blob fetched once and materialized to several paths counts once,
581/// matching the fetch-once grouping), whether or not it carries range-dedup
582/// chunk hints. A blob whose manifest `size` is absent contributes nothing,
583/// exactly as it then moves the total bar not at all, so the numerator and
584/// denominator stay consistent. `None` when nothing kept declares a size — the
585/// total bar is then omitted and only per-file bars render.
586fn total_content_bytes(entries: &[ManifestEntry]) -> Option<u64> {
587    // Entries keyed by whole-file hash, OR-ing in a declared size wherever one of
588    // the same-hash entries carries it (the file bar picks its size the same way).
589    let mut by_hash: HashMap<&str, Option<u64>> = HashMap::new();
590    for entry in entries {
591        let slot = by_hash.entry(entry.hash.as_str()).or_insert(None);
592        *slot = slot.or(entry.size);
593    }
594    let mut sum: u64 = 0;
595    let mut any_sized = false;
596    for size in by_hash.values().flatten() {
597        sum = sum.saturating_add(*size);
598        any_sized = true;
599    }
600    any_sized.then_some(sum)
601}
602
603/// One blob's static **download** (pay-now) bytes and its **reconstruct**
604/// (spliced-from-disk) bytes, planned against `index` under the run's
605/// [`FetchPlan`]: `download` is the sum of the blob's drive ranges (its unique and
606/// self-assigned chunks — a shared chunk it defers to a sibling is spliced, not
607/// downloaded) and `reconstruct` is the rest of its declared `size`. A plain
608/// (unhinted) blob downloads its whole `size` and reconstructs nothing. Returns
609/// `(0, 0)` when the blob declares no size. Passing an empty `index` gives the
610/// fresh-pull figures; a resumed run's pre-seeded donors only shrink `download`.
611fn blob_download_reconstruct(
612    group: &HashGroup<'_>,
613    fetch_plan: &FetchPlan,
614    index: &HashMap<[u8; 32], MaterializedRange>,
615) -> (u64, u64) {
616    let Some(total) = group.entries.iter().find_map(|e| e.size) else {
617        return (0, 0);
618    };
619    let hints = group.entries.first().and_then(|e| hints_of(e));
620    let whole = group
621        .entries
622        .first()
623        .and_then(|e| fetch::parse_hash(&e.hash).ok());
624    let download = match (hints, whole) {
625        (Some(hints), Some(whole)) => plan_reassembly(&hints, index, fetch_plan, whole, total)
626            .drive
627            .iter()
628            .map(|r| r.1)
629            .fold(0u64, u64::saturating_add),
630        // No usable hints (or an unparseable hash) → the whole blob is downloaded.
631        _ => total,
632    };
633    (download, total.saturating_sub(download))
634}
635
636/// The run's static **download** total — the content bytes it pays to fetch, deduped
637/// by whole-file `hash` and by shared chunk (each shared chunk counted once, under
638/// its assigned fetcher). This is the total progress bar's denominator; the run
639/// finishes downloading exactly these bytes and splices the rest from disk. `None`
640/// when nothing kept declares a size (no total bar). Computed against an empty index
641/// (the fresh-pull figure), so a resumed run downloads at most this.
642/// The run label for the progress header: the output directory's final component,
643/// or its whole path when it has none (a bare root).
644fn run_label(output: &Path) -> String {
645    output.file_name().map_or_else(
646        || output.to_string_lossy().into_owned(),
647        |n| n.to_string_lossy().into_owned(),
648    )
649}
650
651fn download_bytes(entries: &[ManifestEntry], fetch_plan: &FetchPlan) -> Option<u64> {
652    let refs: Vec<&ManifestEntry> = entries.iter().collect();
653    let empty = HashMap::new();
654    let mut sum = 0u64;
655    let mut any = false;
656    for group in group_by_hash(&refs) {
657        if group.entries.iter().find_map(|e| e.size).is_none() {
658            continue;
659        }
660        any = true;
661        let (download, _) = blob_download_reconstruct(&group, fetch_plan, &empty);
662        sum = sum.saturating_add(download);
663    }
664    any.then_some(sum)
665}
666
667/// The compiled `--include`/`--exclude` globs that select which manifest entries
668/// a pull run fetches. Both sets match an entry's POSIX relative `path` — the
669/// manifest field (`models/a.bin`), never the on-disk absolute path — with the
670/// same gitignore glob dialect `origin import --exclude` uses (`*` does not
671/// cross `/`, `**` recurses), via [`build_glob_set`].
672///
673/// An entry is kept iff it passes the include gate AND matches no exclude. The
674/// include gate is open when no `--include` was given (every entry passes) and
675/// otherwise requires a match against at least one include pattern; `--exclude`
676/// always wins over `--include`.
677///
678/// Every pattern matches the whole path from the bundle root, so `metal/*`
679/// matches `metal/model.bin` but not `gpt/metal/model.bin`; `**/metal/*`
680/// matches both. A pattern that matches no entry is almost always this mistake,
681/// so [`Self::apply_and_warn`] names each one.
682#[derive(Debug)]
683struct EntryFilter {
684    include: globset::GlobSet,
685    /// The `--include` patterns as given, in the order [`build_glob_set`]
686    /// compiled them, so a [`globset::GlobSet::matches`] index names its
687    /// pattern.
688    include_patterns: Vec<String>,
689    exclude: globset::GlobSet,
690    /// The `--exclude` patterns as given, indexed like `include_patterns`.
691    exclude_patterns: Vec<String>,
692}
693
694/// A `--include` or `--exclude` pattern that matched no entry of the manifest.
695#[derive(Debug, Clone, PartialEq, Eq)]
696struct Unmatched {
697    /// The flag the pattern came from: `--include` or `--exclude`.
698    flag: &'static str,
699    /// The pattern as given.
700    pattern: String,
701    /// The same pattern under a leading `**/`, when that form matches at least
702    /// one entry: the likely intent of a pattern written as if it matched at
703    /// any depth.
704    suggestion: Option<String>,
705}
706
707impl Unmatched {
708    /// The warning line for this pattern, without the `warning: ` prefix.
709    fn warning(&self) -> String {
710        let hint = self
711            .suggestion
712            .as_ref()
713            .map_or_else(String::new, |suggestion| {
714                format!(
715                    " (a pattern matches the whole path from the bundle root; did you mean \
716                     '{suggestion}'?)"
717                )
718            });
719        format!("{} '{}' matched no entries{hint}", self.flag, self.pattern)
720    }
721}
722
723/// What [`EntryFilter::apply_reporting`] hands back.
724#[derive(Debug)]
725struct FilterPass {
726    /// The entries the filter keeps, in manifest order.
727    kept: Vec<ManifestEntry>,
728    /// How many entries the filter dropped.
729    excluded: u64,
730    /// Each pattern that matched no entry, `--include` ones first.
731    unmatched: Vec<Unmatched>,
732}
733
734impl EntryFilter {
735    /// Compile the run's `--include`/`--exclude` patterns. A malformed glob is a
736    /// hard error naming the flag it came from.
737    fn compile(include: &[String], exclude: &[String]) -> anyhow::Result<Self> {
738        Ok(Self {
739            include: build_glob_set(include, "--include")?,
740            include_patterns: include.to_vec(),
741            exclude: build_glob_set(exclude, "--exclude")?,
742            exclude_patterns: exclude.to_vec(),
743        })
744    }
745
746    /// Retain only the entries the filter keeps, preserving manifest order, and
747    /// report the patterns that matched no entry. A pattern's hits are counted
748    /// over every entry of `entries`, before either gate drops any, so an
749    /// `--exclude` that matches only entries the include gate already dropped
750    /// still counts as a match.
751    fn apply_reporting(&self, entries: Vec<ManifestEntry>) -> FilterPass {
752        let mut include_hits = vec![false; self.include_patterns.len()];
753        let mut exclude_hits = vec![false; self.exclude_patterns.len()];
754        let keep: Vec<bool> = entries
755            .iter()
756            .map(|e| {
757                let p = Path::new(&e.path);
758                let included = mark_hits(&self.include, p, &mut include_hits);
759                let excluded = mark_hits(&self.exclude, p, &mut exclude_hits);
760                (self.include_patterns.is_empty() || included) && !excluded
761            })
762            .collect();
763        let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
764        let unmatched = unmatched_patterns("--include", &self.include_patterns, &include_hits)
765            .chain(unmatched_patterns(
766                "--exclude",
767                &self.exclude_patterns,
768                &exclude_hits,
769            ))
770            .map(|mut u| {
771                u.suggestion = anywhere_suggestion(&u.pattern, &paths);
772                u
773            })
774            .collect();
775        let raw = entries.len();
776        let kept: Vec<ManifestEntry> = entries
777            .into_iter()
778            .zip(keep)
779            .filter_map(|(e, keep)| keep.then_some(e))
780            .collect();
781        let excluded = u64::try_from(raw.saturating_sub(kept.len())).unwrap_or(u64::MAX);
782        FilterPass {
783            kept,
784            excluded,
785            unmatched,
786        }
787    }
788
789    /// [`Self::apply_reporting`], with one `warning:` line on stderr for each
790    /// pattern that matched no entry. Returns the kept entries and how many the
791    /// filter dropped.
792    fn apply_and_warn(&self, entries: Vec<ManifestEntry>) -> (Vec<ManifestEntry>, u64) {
793        let pass = self.apply_reporting(entries);
794        for u in &pass.unmatched {
795            eprintln!("warning: {}", u.warning());
796        }
797        (pass.kept, pass.excluded)
798    }
799}
800
801/// Mark in `hits` every pattern of `set` that matches `path`, and return
802/// whether any did.
803fn mark_hits(set: &globset::GlobSet, path: &Path, hits: &mut [bool]) -> bool {
804    let matched = set.matches(path);
805    for i in &matched {
806        if let Some(hit) = hits.get_mut(*i) {
807            *hit = true;
808        }
809    }
810    !matched.is_empty()
811}
812
813/// The patterns of `flag` whose `hits` slot is unset, with no suggestion yet.
814fn unmatched_patterns<'a>(
815    flag: &'static str,
816    patterns: &'a [String],
817    hits: &'a [bool],
818) -> impl Iterator<Item = Unmatched> + 'a {
819    patterns
820        .iter()
821        .zip(hits)
822        .filter(|(_, hit)| !**hit)
823        .map(move |(pattern, _)| Unmatched {
824            flag,
825            pattern: pattern.clone(),
826            suggestion: None,
827        })
828}
829
830/// `**/<pattern>`, when `pattern` does not start with `**` or `/` and that form
831/// matches at least one of `paths`.
832fn anywhere_suggestion(pattern: &str, paths: &[&str]) -> Option<String> {
833    if pattern.starts_with("**") || pattern.starts_with('/') {
834        return None;
835    }
836    let anywhere = format!("**/{pattern}");
837    let set = build_glob_set(std::slice::from_ref(&anywhere), "suggestion").ok()?;
838    paths
839        .iter()
840        .any(|p| set.is_match(Path::new(p)))
841        .then_some(anywhere)
842}
843
844/// Header written above the file list in the `--select` editor buffer. Explains
845/// the git-rebase-style convention: commented or deleted lines are skipped.
846const SELECT_HEADER: &str = "\
847# decdn bundle pull --select — choose which files to pull.
848#
849# Comment out (prefix with #) or delete any line to SKIP that file.
850# Save and exit to pull every file still listed below; delete them all to pull
851# nothing. Reordering has no effect, and you cannot add files here.
852#
853# Each line is  <path>\t<size>  — only the path (before the tab) is read.
854";
855
856/// Render the `--select` editor buffer: the header, then one line per entry as
857/// `<path>\t<human size>`. The size is a right-hand annotation for context only;
858/// [`parse_selection`] reads just the path before the first tab.
859fn render_selection(entries: &[ManifestEntry]) -> String {
860    let mut out = String::from(SELECT_HEADER);
861    for e in entries {
862        let size = e.size.map_or_else(|| "?".to_string(), human_bytes);
863        out.push_str(&e.path);
864        out.push('\t');
865        out.push_str(&size);
866        out.push('\n');
867    }
868    out
869}
870
871/// Reject a bundle whose paths cannot round-trip through the `--select` editor
872/// buffer exactly. [`parse_selection`] reads a kept line as the text before the
873/// first tab, trimmed, and skips a line whose first non-whitespace character is
874/// `#`. A path therefore fails to round-trip when it:
875/// - differs from its trimmed form (leading/trailing whitespace, which
876///   [`parse_selection`] strips — including whitespace before a `#`, which would
877///   make the line read as a comment and silently drop the file), or
878/// - has `#` as its first non-whitespace character (read as a comment), or
879/// - contains a tab, newline, or carriage return (splits or breaks its line).
880///
881/// Such paths are pathological for a POSIX relative filename; `--select` refuses
882/// them loudly rather than silently dropping a file the user meant to keep.
883/// `--include`/`--exclude` still handle these bundles.
884fn check_selectable(entries: &[ManifestEntry]) -> anyhow::Result<()> {
885    if let Some(bad) = entries.iter().find(|e| {
886        let p = e.path.as_str();
887        p != p.trim() || p.trim_start().starts_with('#') || p.contains(['\t', '\n', '\r'])
888    }) {
889        bail!(
890            "--select cannot represent the path {:?} (paths with leading/trailing \
891             whitespace, a leading '#', or a tab/newline are unsupported); use \
892             --include/--exclude instead",
893            bad.path
894        );
895    }
896    Ok(())
897}
898
899/// Apply the edited `--select` buffer back onto the entry list: keep only entries
900/// whose path still appears on a non-comment, non-blank line, preserving the
901/// original manifest order. A kept line whose path matches no bundle entry is a
902/// hard error — the editor only removes files, so an unmatched line is a typo,
903/// not an addition, and silently dropping it would be worse than failing.
904fn parse_selection(
905    edited: &str,
906    entries: Vec<ManifestEntry>,
907) -> anyhow::Result<Vec<ManifestEntry>> {
908    let known: HashSet<&str> = entries.iter().map(|e| e.path.as_str()).collect();
909    let mut kept: HashSet<String> = HashSet::new();
910    let mut unknown: Vec<&str> = Vec::new();
911    for line in edited.lines() {
912        if line.trim_start().is_empty() || line.trim_start().starts_with('#') {
913            continue;
914        }
915        // The path is the text before the size annotation (first tab), trimmed.
916        let path = line.split_once('\t').map_or(line, |(p, _)| p).trim();
917        if path.is_empty() {
918            continue;
919        }
920        if known.contains(path) {
921            kept.insert(path.to_string());
922        } else {
923            unknown.push(path);
924        }
925    }
926    if !unknown.is_empty() {
927        bail!(
928            "--select: these lines match no bundle entry (editing only removes files, \
929             it cannot add them): {}",
930            unknown.join(", ")
931        );
932    }
933    Ok(entries
934        .into_iter()
935        .filter(|e| kept.contains(&e.path))
936        .collect())
937}
938
939/// Apply the interactive `--select` step to a finalized manifest, or pass it
940/// through unchanged when `--select` is off. Each deselected entry adds to the
941/// excluded count. `Ok(None)` means the user deselected every file:
942/// [`report_nothing_to_fetch`] was already called, so the run is done.
943fn maybe_select(args: &BundlePullArgs, mut kept: Kept) -> anyhow::Result<Option<Kept>> {
944    if args.select {
945        kept = apply_selection(kept, select_entries)?;
946        if kept.manifest.entries.is_empty() {
947            report_nothing_to_fetch(NothingReason::Deselected);
948            warn_leftover_partials(&args.output, &[], args.hash.as_deref());
949            return Ok(None);
950        }
951    }
952    Ok(Some(kept))
953}
954
955/// Replace `kept`'s entries with the ones `select` keeps, and add each entry it
956/// drops to the excluded count.
957fn apply_selection(
958    mut kept: Kept,
959    select: impl FnOnce(Vec<ManifestEntry>) -> anyhow::Result<Vec<ManifestEntry>>,
960) -> anyhow::Result<Kept> {
961    let offered = kept.manifest.entries.len();
962    kept.manifest.entries = select(kept.manifest.entries)?;
963    let dropped = offered.saturating_sub(kept.manifest.entries.len());
964    kept.excluded = kept
965        .excluded
966        .saturating_add(u64::try_from(dropped).unwrap_or(u64::MAX));
967    Ok(kept)
968}
969
970/// Reject a `--select` invocation that cannot work: it opens an editor, so it
971/// needs an interactive terminal (both stdin and stdout) and is incompatible with
972/// the non-interactive `--json` and `--dry-run` modes. A no-op when `--select` is
973/// not set.
974fn check_select_flags(args: &BundlePullArgs) -> anyhow::Result<()> {
975    if !args.select {
976        return Ok(());
977    }
978    if args.json {
979        bail!("--select cannot be combined with --json (it needs an interactive editor)");
980    }
981    if args.dry_run {
982        bail!("--select cannot be combined with --dry-run (it needs an interactive editor)");
983    }
984    if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
985        bail!("--select needs an interactive terminal; use --include/--exclude instead");
986    }
987    Ok(())
988}
989
990/// Run the interactive `--select` step: render the entry list, open it in the
991/// user's editor, and apply the edited buffer back. All decision logic lives in
992/// the tested [`render_selection`]/[`parse_selection`]/[`check_selectable`]; this
993/// only wires them to the editor. The caller has already verified an interactive
994/// terminal and that `--json`/`--dry-run` are not set.
995fn select_entries(entries: Vec<ManifestEntry>) -> anyhow::Result<Vec<ManifestEntry>> {
996    check_selectable(&entries)?;
997    let edited = edit_in_editor(&render_selection(&entries))?;
998    parse_selection(&edited, entries)
999}
1000
1001/// Resolve the editor command from `$VISUAL`/`$EDITOR` into program + arguments,
1002/// falling back to `vi`. `$VISUAL` wins over `$EDITOR`; a missing or
1003/// blank/whitespace-only value is treated as unset. The value may carry arguments
1004/// (`code --wait`): it is split on ASCII whitespace, with no shell quoting or
1005/// escaping — an argument cannot itself contain a space. The scratch file path is
1006/// appended by the caller, not here. Never empty — the fallback guarantees at
1007/// least `["vi"]`.
1008fn editor_command(visual: Option<&str>, editor: Option<&str>) -> Vec<String> {
1009    let chosen = [visual, editor]
1010        .into_iter()
1011        .flatten()
1012        .map(str::trim)
1013        .find(|s| !s.is_empty())
1014        .unwrap_or("vi");
1015    chosen.split_whitespace().map(String::from).collect()
1016}
1017
1018/// Open `buffer` in the user's editor and return the saved contents. The editor
1019/// is `$VISUAL`, then `$EDITOR`, then `vi`; the value may carry arguments
1020/// (`code --wait`), split on ASCII whitespace with no shell quoting (see
1021/// [`editor_command`]). The buffer is staged in a temporary file that is removed
1022/// when this returns.
1023fn edit_in_editor(buffer: &str) -> anyhow::Result<String> {
1024    let mut file = tempfile::Builder::new()
1025        .prefix("decdn-select-")
1026        .suffix(".txt")
1027        .tempfile()
1028        .context("create --select scratch file")?;
1029    std::io::Write::write_all(file.as_file_mut(), buffer.as_bytes())
1030        .context("write --select scratch file")?;
1031    file.as_file()
1032        .sync_all()
1033        .context("sync --select scratch file")?;
1034
1035    let command = editor_command(
1036        std::env::var("VISUAL").ok().as_deref(),
1037        std::env::var("EDITOR").ok().as_deref(),
1038    );
1039    let (program, rest) = command
1040        .split_first()
1041        .ok_or_else(|| anyhow!("empty editor command"))?;
1042
1043    let status = std::process::Command::new(program)
1044        .args(rest)
1045        .arg(file.path())
1046        .status()
1047        .with_context(|| format!("launch editor {program:?}"))?;
1048    if !status.success() {
1049        bail!("editor {program:?} exited with {status}; aborting --select");
1050    }
1051
1052    std::fs::read_to_string(file.path()).context("read back --select scratch file")
1053}
1054
1055/// A group of manifest entries that all name the same blob `hash` — one file
1056/// published at two (or more) bundle paths. Non-empty by construction; the
1057/// shared `hash` is carried explicitly so consumers never re-derive it from an
1058/// arbitrary member.
1059#[derive(Clone)]
1060struct HashGroup<'a> {
1061    hash: &'a str,
1062    entries: Vec<&'a ManifestEntry>,
1063}
1064
1065/// Per-destination classification within a hash-group, resolved before any
1066/// fetch: a resolve failure, an already-present file to skip, or a path to
1067/// write. `label` (the entry's manifest-relative path) is retained only to tag
1068/// a later failure.
1069enum Slot<'a> {
1070    Failed(EntryOutcome),
1071    Skip,
1072    Write { label: &'a str, dest: PathBuf },
1073}
1074
1075/// Per-entry result, kept (never short-circuited) so one failure doesn't abort
1076/// the others — re-running resumes via skip-existing. `Linked` is a duplicate
1077/// destination materialized from an already-fetched sibling (hard link or copy),
1078/// kept distinct from `Fetched` (a paid network pull) so the summary never
1079/// implies a blob was paid for twice — the whole point of #1306.
1080#[derive(Clone)]
1081enum EntryOutcome {
1082    Fetched(u64),
1083    Linked,
1084    Skipped,
1085    /// A destination materialized from an on-disk whole-file donor (a byte-
1086    /// identical file already in the root, verified by re-hash) — no download,
1087    /// no payment. The `u64` is the file's size, counted as reused, never
1088    /// downloaded.
1089    Deduped(u64),
1090    Failed {
1091        path: String,
1092        err: String,
1093    },
1094}
1095
1096/// One hash-group's result.
1097struct GroupRun {
1098    /// One outcome per entry in the group, in order.
1099    outcomes: Vec<EntryOutcome>,
1100    /// The content bytes the group paid for and resumed, when its blob fetch
1101    /// landed ([`PullCtx::pull_entry_untimed`]). `None` when it fetched nothing.
1102    bytes: Option<EntryBytes>,
1103    /// The fault that ends the whole pull ([`ends_the_pull`]), when the
1104    /// group's blob fetch failed with one.
1105    stop: Option<anyhow::Error>,
1106}
1107
1108impl GroupRun {
1109    /// A result that fetched nothing.
1110    const fn done(outcomes: Vec<EntryOutcome>) -> Self {
1111        Self {
1112            outcomes,
1113            bytes: None,
1114            stop: None,
1115        }
1116    }
1117
1118    /// The group's blob fetch landed with `bytes`, and `outcomes` is how each
1119    /// destination then materialized.
1120    const fn landed(outcomes: Vec<EntryOutcome>, bytes: EntryBytes) -> Self {
1121        Self {
1122            outcomes,
1123            bytes: Some(bytes),
1124            stop: None,
1125        }
1126    }
1127
1128    /// The group's blob fetch failed with `err`: every writable slot fails,
1129    /// and `err` ends the whole pull when [`ends_the_pull`] says so.
1130    fn fetch_failed(slots: Vec<Slot<'_>>, err: anyhow::Error) -> Self {
1131        Self {
1132            outcomes: fail_all(slots, &err),
1133            bytes: None,
1134            stop: ends_the_pull(&err).then_some(err),
1135        }
1136    }
1137}
1138
1139/// What a failed entry fetch stops (ADR 039 § Failure handling: reassign-only
1140/// tail): a fault
1141/// only the user can fix stops every entry. Every other fault belongs to its
1142/// entry alone.
1143fn entry_scope(err: &anyhow::Error) -> decdn_client::FatalScope {
1144    match decdn_client::classify(err) {
1145        decdn_client::Fault::Fatal(scope) => scope,
1146        _ => decdn_client::FatalScope::Item,
1147    }
1148}
1149
1150/// Whether a failed entry fetch ends the whole pull: a command-scope fault
1151/// ([`entry_scope`]), or a give-up. A give-up is item-scoped, but every entry
1152/// shares the command's progress clock, so every other entry gives up at the
1153/// same moment.
1154fn ends_the_pull(err: &anyhow::Error) -> bool {
1155    entry_scope(err) == decdn_client::FatalScope::Command
1156        || err.downcast_ref::<decdn_client::GaveUp>().is_some()
1157}
1158
1159/// One-line `--json` summary. `fetched`/`linked`/`skipped`/`failed` are entry
1160/// counts; `downloaded` is the content bytes this run fetched and paid for across
1161/// the distinct blobs fetched (a blob shared across several paths counts once,
1162/// #1306; a range-dedup blob counts only the bytes it did not splice from disk)
1163/// and `reconstructed` is the total bytes written to disk this run — they diverge
1164/// when one blob is materialized to several paths or when range-dedup spliced part
1165/// of a blob. `downloaded` is a content-size tally, not an exact on-wire
1166/// measurement: it excludes bao proof overhead.
1167///
1168/// `resumed_bytes` is the content bytes of a `.partial` prefix that an earlier,
1169/// interrupted run fetched and paid for, which this run resumed rather than
1170/// fetched again. It is never part of `downloaded`. A byte that this run also
1171/// spliced from disk counts only in `spliced_bytes`.
1172///
1173/// `excluded` counts the manifest entries this run does not pull: the ones the
1174/// `--include`/`--exclude` filter dropped and the ones deselected under
1175/// `--select`.
1176///
1177/// `reused` and `reused_bytes` report the whole-file dedup outcome, counted per
1178/// distinct blob exactly as `fetched`/`downloaded` are (a blob reused at several
1179/// paths counts once here; its extra destinations are `linked`): `reused` is the
1180/// count of distinct blobs materialized from an on-disk whole-file donor (verified
1181/// by re-hash before use), and `reused_bytes` sums those blobs' sizes once each —
1182/// bytes served from disk with no download and no payment.
1183///
1184/// `spliced_bytes` and `hints_ignored` report the range-dedup outcome so a run
1185/// whose hints saved bytes is distinguishable from one whose hints did not:
1186/// `spliced_bytes` is the total bytes served from a local donor splice (bytes NOT
1187/// downloaded or paid for), and `hints_ignored` counts range-dedup hints dropped
1188/// by a fault — a chunk set that failed to parse or whose sizes did not sum to the
1189/// file size, a donor that failed its verification re-hash (or was unreadable), or
1190/// a self-heal whole-file re-drive that discarded already-spliced donors. Both are
1191/// reporting only and never affect payment.
1192#[derive(Serialize)]
1193struct PullReport {
1194    output: String,
1195    fetched: u64,
1196    linked: u64,
1197    skipped: u64,
1198    /// Entries the filter or `--select` dropped.
1199    excluded: u64,
1200    failed: u64,
1201    downloaded: u64,
1202    reconstructed: u64,
1203    spliced_bytes: u64,
1204    hints_ignored: u64,
1205    /// Content bytes of `.partial` prefixes that earlier runs fetched and this
1206    /// run resumed; never part of `downloaded`.
1207    resumed_bytes: u64,
1208    /// Count of distinct blobs materialized from an on-disk whole-file donor;
1209    /// extra destinations of the same blob are counted in `linked`.
1210    reused: u64,
1211    /// Sum of those blobs' sizes (once per blob) — bytes materialized from a
1212    /// whole-file donor with no download or payment.
1213    reused_bytes: u64,
1214}
1215
1216/// The run's range-dedup outcome, read off [`PullCtx::dedup_stats`] once the pull
1217/// finishes and folded into the [`PullReport`] and the human summary. See
1218/// [`PullReport`] for the exact meaning of each field.
1219#[derive(Clone, Copy, Default)]
1220struct DedupSummary {
1221    spliced_bytes: u64,
1222    hints_ignored: u64,
1223}
1224
1225/// One dedup entry's range-dedup outcome, returned by [`reassemble_dedup`].
1226/// [`PullCtx::pull_entry_untimed`] accumulates its spliced bytes and ignored
1227/// hints into [`PullCtx::dedup_stats`] and folds all of it into the entry's
1228/// [`EntryBytes`].
1229#[derive(Clone, Copy, Default, Debug)]
1230struct DedupOutcome {
1231    /// Bytes this entry served from a local donor splice — never downloaded.
1232    spliced_bytes: u64,
1233    /// Bytes this entry's ranged store held from an earlier run before this run
1234    /// drove anything, less the bytes it spliced — neither downloaded nor
1235    /// spliced here.
1236    resumed_bytes: u64,
1237    /// Range-dedup hints this entry dropped by a fault (a donor that failed its
1238    /// verification re-hash or was unreadable, or a self-heal re-drive that
1239    /// discarded every already-spliced donor).
1240    hints_ignored: u64,
1241}
1242
1243impl DedupOutcome {
1244    /// The outcome of an entry of `total` bytes whose net spliced spans are
1245    /// `spliced` and whose store held the `resumed` spans from an earlier run.
1246    /// A byte in both counts as spliced, so no byte counts twice.
1247    fn of(spliced: &[(u64, u64)], resumed: &[(u64, u64)], total: u64, hints_ignored: u64) -> Self {
1248        Self {
1249            spliced_bytes: span_bytes(spliced),
1250            resumed_bytes: span_bytes(&uncovered_runs(resumed, spliced, total)),
1251            hints_ignored,
1252        }
1253    }
1254
1255    /// This outcome, or, when a drive fetched the whole blob again after its
1256    /// finalize failed the hash (`refetched`), one with nothing spliced or
1257    /// resumed. That pass dropped every byte the store held and fetched the
1258    /// blob in full, and it always ends the entry, so this run paid for all
1259    /// of it.
1260    const fn refetched_if(self, refetched: bool) -> Self {
1261        if refetched {
1262            Self {
1263                spliced_bytes: 0,
1264                resumed_bytes: 0,
1265                hints_ignored: self.hints_ignored,
1266            }
1267        } else {
1268            self
1269        }
1270    }
1271}
1272
1273/// Run-scoped range-dedup counters, shared by every concurrent entry via
1274/// `&PullCtx`. Atomics because entries run concurrently under `buffer_unordered`;
1275/// `Relaxed` is enough — the totals are read once, after the pull joins.
1276#[derive(Default)]
1277struct DedupStats {
1278    spliced_bytes: AtomicU64,
1279    hints_ignored: AtomicU64,
1280}
1281
1282/// A pull's byte accounting: `downloaded` is the content bytes this run fetched and
1283/// paid for across the distinct blobs fetched (a blob materialized to several paths
1284/// counts once, #1306; a range-dedup blob counts only the bytes it did not splice
1285/// from disk); `reconstructed` is the total bytes written to disk (every
1286/// materialized copy); `resumed` is the content bytes of `.partial` prefixes that
1287/// earlier runs fetched and this run resumed. `downloaded` is content bytes, not
1288/// exact on-wire bytes: it omits bao proof overhead.
1289#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
1290struct Transfer {
1291    downloaded: u64,
1292    reconstructed: u64,
1293    resumed: u64,
1294}
1295
1296impl Transfer {
1297    /// Combine two tallies (saturating — a pull never reports a wrapped total).
1298    const fn add(self, other: Transfer) -> Transfer {
1299        Transfer {
1300            downloaded: self.downloaded.saturating_add(other.downloaded),
1301            reconstructed: self.reconstructed.saturating_add(other.reconstructed),
1302            resumed: self.resumed.saturating_add(other.resumed),
1303        }
1304    }
1305}
1306
1307/// The run's candidate list: every active registered node. Each entry draws its
1308/// own candidate sample from the whole list ([`entry_candidates`]), so no
1309/// registered node is hidden from every entry by one draw.
1310///
1311/// The peer store's identity-fresh records serve as that list when enough of
1312/// them qualify ([`cached_registry`]), and the run issues no
1313/// `getRegisteredNodes`. Otherwise the registry is read once. A cached list that
1314/// runs out of holders falls back to a live read through the in-run
1315/// rediscovery ([`CliSources`](super::cli_sources)).
1316async fn discover_candidates(
1317    chain: &fetch::ResolvedChain,
1318    common: &ClientFetchArgs,
1319) -> anyhow::Result<Vec<discovery::NodeCandidate>> {
1320    let capacity_bond = chain.capacity_bond.ok_or_else(|| {
1321        anyhow!(
1322            "auto-discovery needs capacity_bond_address (--capacity-bond-address or \
1323             blockchain.capacity_bond_address), or pass --node-id to pull from one node"
1324        )
1325    })?;
1326    if !common.rediscover
1327        && let Some(cached) = cached_registry(&chain.data_dir, fetch::now_secs_cli())
1328    {
1329        return Ok(cached);
1330    }
1331    let registry_cap = common.discovery_cap();
1332    let bootstrap =
1333        discovery::bootstrap_nodes(&chain.rpc_url, capacity_bond, &chain.data_dir, registry_cap)
1334            .await?;
1335    // See `fetch::discover_provider`: the degraded bootstrap paths reach the
1336    // operator through the return value; surface it here at `warn`.
1337    if let Some(warning) = bootstrap.warning() {
1338        tracing::warn!("{warning}");
1339    }
1340    let all = bootstrap.into_peers();
1341    if all.is_empty() {
1342        bail!("no active nodes in the CapacityBond registry at {capacity_bond}");
1343    }
1344    Ok(all)
1345}
1346
1347/// The peer store's identity-fresh records under `data_dir`, or `None` when
1348/// fewer than [`decdn_client::StoreConfig::min_fresh_candidates`] qualify. The
1349/// records are the last live registry read, confirmed within
1350/// [`decdn_client::StoreConfig::identity_refresh_secs`]. The run only reads
1351/// them: identity refreshes on a live registry read alone, so the refresh
1352/// horizon still expires.
1353fn cached_registry(data_dir: &Path, now_secs: u64) -> Option<Vec<NodeCandidate>> {
1354    let store = decdn_client::PeerStore::open(data_dir);
1355    let cfg = decdn_client::StoreConfig::default();
1356    let cached = fetch::identity_fresh_candidates(&store, &cfg, now_secs);
1357    (cached.len() >= cfg.min_fresh_candidates).then_some(cached)
1358}
1359
1360/// One entry's probe candidates: a fresh sample of `registry`, same-region
1361/// nodes first ([`discovery::select_candidates`]). A sample per entry costs no
1362/// extra probe, because each entry probes its own candidates, and a node that
1363/// one draw leaves out can still reach other entries.
1364fn entry_candidates(registry: &[NodeCandidate], region: Option<&str>) -> Vec<NodeCandidate> {
1365    discovery::select_candidates(registry.to_vec(), region, discovery::SELECT_K)
1366}
1367
1368/// Load the buyer's Ethereum signer (vouchers + any openPool/topUp tx) and its
1369/// address, prompting for the keystore password once. Password from
1370/// `$DECDN_KEYSTORE_PASSWORD`, else `--keystore-password-file`, else a TTY
1371/// prompt.
1372fn load_buyer_signer(
1373    chain: &fetch::ResolvedChain,
1374) -> anyhow::Result<(Arc<PrivateKeySigner>, Address)> {
1375    let password = super::chain_ctx::read_keystore_password(
1376        &super::chain_ctx::password_sources(
1377            chain.keystore_password_file.as_deref(),
1378            PasswordUse::Unlock,
1379        ),
1380        "eth keystore password",
1381    )?
1382    .into_secret();
1383    let signer = Arc::new(load_signer(&chain.keystore, &password)?);
1384    let self_address = signer.address();
1385    Ok((signer, self_address))
1386}
1387
1388/// The resolved node selection and buyer signer for a pull run.
1389struct Selection {
1390    /// `Some((node, provider))` pins every entry to one node; `None` discovers per
1391    /// entry against a sample of `registry`.
1392    explicit: Option<FetchTarget>,
1393    /// Every active registered node, resolved once for the run
1394    /// ([`discover_candidates`]).
1395    registry: Option<Vec<NodeCandidate>>,
1396    signer: Arc<PrivateKeySigner>,
1397    self_address: Address,
1398}
1399
1400/// Resolve the node selection and buyer signer for a pull run.
1401///
1402/// The keystore prompt is deferred until AFTER the registry read on the
1403/// discovery path, so a failed discovery never prompts.
1404async fn resolve_selection(
1405    common: &ClientFetchArgs,
1406    chain: &fetch::ResolvedChain,
1407) -> anyhow::Result<Selection> {
1408    // Explicit single node for every entry, or the active set (cached or read
1409    // from `CapacityBond` once), which each entry samples.
1410    let explicit = explicit_target(common)?;
1411    let registry = match explicit {
1412        Some(_) => None,
1413        // A registry read is bounded by `--timeout-ms` (#1349), inside
1414        // `bootstrap_nodes` so a timeout still falls through to the peer store.
1415        // No sampling or probing happens here: each entry samples and probes
1416        // its own candidates (`PullCtx::entry_candidates`).
1417        None => Some(discover_candidates(chain, common).await?),
1418    };
1419    let (signer, self_address) = load_buyer_signer(chain)?;
1420    Ok(Selection {
1421        explicit,
1422        registry,
1423        signer,
1424        self_address,
1425    })
1426}
1427
1428/// Entry point for `decdn bundle pull`.
1429pub async fn bundle_pull(args: &BundlePullArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
1430    // Validate the flag combination first — BEFORE the dry-run short-circuit — so a
1431    // bad combo (e.g. `--provider-address` without `--node-id`) or an unusable
1432    // timeout pair is rejected even for `--dry-run`. This rule does not run at
1433    // parse time, so `bundle_pull` calls it BEFORE the dry-run short-circuit. It
1434    // is cheap and side-effect-free.
1435    args.common.validate()?;
1436
1437    // Compile the `--include`/`--exclude` entry filter BEFORE the dry-run
1438    // short-circuit — same rationale as `validate()`: a malformed glob is
1439    // rejected before any network, chain, or keystore work, and `--dry-run`
1440    // reflects the filtered plan too.
1441    let filter = EntryFilter::compile(&args.include, &args.exclude)?;
1442    let filters_given = !args.include.is_empty() || !args.exclude.is_empty();
1443
1444    // `--select` opens an editor: reject an incompatible combo or a non-terminal
1445    // up front — before any network, chain, or keystore work — so it fails fast
1446    // rather than after paying to fetch the manifest.
1447    check_select_flags(args)?;
1448
1449    // Dry-run short-circuits before any network/chain/keystore activity.
1450    if args.dry_run {
1451        return dry_run(args, &filter, filters_given);
1452    }
1453
1454    // A local manifest is read up front (no network) and filtered here, so a
1455    // bundle that is empty — or emptied by the filter — needs no endpoint or
1456    // keystore password at all.
1457    let local_manifest = match &args.input {
1458        Some(path) => {
1459            let mut m = read_local_manifest(path)?;
1460            let raw_empty = m.entries.is_empty();
1461            let (entries, excluded) = filter.apply_and_warn(m.entries);
1462            m.entries = entries;
1463            if m.entries.is_empty() {
1464                report_nothing_to_fetch(NothingReason::from_filter(filters_given, raw_empty));
1465                warn_leftover_partials(&args.output, &[], None);
1466                return Ok(());
1467            }
1468            Some(Kept {
1469                manifest: m,
1470                excluded,
1471            })
1472        }
1473        None => None,
1474    };
1475
1476    let common = &args.common;
1477    let relays = client_endpoint::resolve_relays(common.relay_url.as_deref(), config_path)?;
1478    let disc = client_endpoint::client_discovery(config_path)?;
1479    let file = load_file_config(config_path)?;
1480    let mut chain = fetch::resolve_chain(common, &file)?;
1481
1482    // Delegated adoption: `--capability`/`--capability-file` names a pool the
1483    // caller does NOT own and a capability authorizing this client's key to
1484    // spend against it (every entry pulls from that one pool). `None` => the
1485    // unchanged self-owned pool path; a delegated grant also zeroes the working
1486    // deposit in `chain`, so its funding recovery step has nothing to add.
1487    let grant = fetch::resolve_delegation_grant(common, &mut chain)?;
1488
1489    // Same guard as `decdn fetch`, for the same reason and before the same
1490    // password prompt: a node's data dir is a buy target only when the
1491    // operator named it (#2082).
1492    // Shared, so a lane's watermark write runs off the runtime thread.
1493    let store = Arc::new(open_client_store_for_buy(
1494        &chain.data_dir,
1495        chain.data_dir_source,
1496        "pull",
1497    )?);
1498    let endpoint = client_endpoint::client_endpoint(&relays, &disc).await?;
1499    // The command's peer-record and watermark writes, off the runtime thread.
1500    let writes = OrderedWrites::default();
1501    // The body runs in `pull_over`, so the endpoint closes on every exit —
1502    // success, an early return, or an error — and its open connections end
1503    // cleanly instead of being aborted on drop.
1504    let result = pull_over(
1505        args,
1506        &chain,
1507        grant,
1508        (&store, &writes),
1509        &endpoint,
1510        &relays,
1511        (&filter, filters_given),
1512        local_manifest,
1513    )
1514    .await;
1515    // On every exit, a Ctrl-C's included: what the entries queued is durable
1516    // before the command returns.
1517    writes.settle().await;
1518    endpoint.close().await;
1519    result
1520}
1521
1522/// The part of [`bundle_pull`] that runs over its open `endpoint`: resolve the
1523/// providers and the buyer signer, obtain the manifest, and pull it. `filter` is
1524/// the compiled `--include`/`--exclude` filter and whether either flag was given.
1525#[allow(clippy::too_many_arguments)]
1526async fn pull_over(
1527    args: &BundlePullArgs,
1528    chain: &fetch::ResolvedChain,
1529    grant: Option<decdn_incentive::CapabilityGrant>,
1530    (store, writes): (&Arc<RedbBuyerPoolStore>, &OrderedWrites),
1531    endpoint: &Endpoint,
1532    relays: &[RelayUrl],
1533    (filter, filters_given): (&EntryFilter, bool),
1534    local_manifest: Option<Kept>,
1535) -> anyhow::Result<()> {
1536    let common = &args.common;
1537    // Selection + the buyer signer, resolved per path (see `resolve_selection`).
1538    let Selection {
1539        explicit,
1540        registry,
1541        signer,
1542        self_address,
1543    } = resolve_selection(common, chain).await?;
1544
1545    // Chain plumbing for the pull loop, built once from the resolved signer.
1546    let rpc = provider::build_provider(&chain.rpc_url, &signer)?;
1547    let contract = PaymentPool::new(chain.payment_pool, rpc.clone());
1548    let voucher_dom = voucher_domain(chain.chain_id, chain.payment_pool);
1549    let slash_dom = slash_judge_domain(chain.chain_id, chain.slash_judge);
1550    let token = contract
1551        .usdc()
1552        .call()
1553        .await
1554        .map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()"))?;
1555
1556    // `--namespace <id>` → big-endian `uint256`; absent => `NO_NAMESPACE`
1557    // (best-effort cache/DHT). Same conversion as `decdn fetch`.
1558    let namespace_id = namespace_id(args.namespace);
1559
1560    let ctx = PullCtx {
1561        endpoint,
1562        store,
1563        contract: &contract,
1564        rpc: &rpc,
1565        signer: &signer,
1566        self_address,
1567        token,
1568        voucher_dom: &voucher_dom,
1569        slash_dom: &slash_dom,
1570        chain,
1571        relays,
1572        explicit,
1573        registry,
1574        common,
1575        namespace_id,
1576        grant,
1577        ledgers: LaneLedgers::new(),
1578        funding: fetch::RunFunding::default(),
1579        recovery: Arc::new(RecoveryGate::new()),
1580        dedup_stats: DedupStats::default(),
1581        open_lock: tokio::sync::Mutex::new(()),
1582        gate: JobGate::new(args.jobs.max(1)),
1583        lane_cap: LaneStreamCap::new(usize::from(args.max_lane_streams)),
1584        health: Arc::new(PeerHealth::default()),
1585        connections: Connections::new(endpoint.clone()),
1586        writes,
1587        // One clock for the whole command: a stuck entry waits while another
1588        // lands bytes.
1589        stop: StopPolicy::new(
1590            std::io::stderr().is_terminal(),
1591            common.give_up_after(),
1592            Arc::new(ProgressClock::new()),
1593        ),
1594        // Silent during the manifest fetch below (a single blob); replaced once
1595        // the kept entries are known and their sizes decide the total-bar mode.
1596        progress: PullProgress::disabled(),
1597    };
1598
1599    pull_manifest(ctx, args, filter, filters_given, local_manifest).await
1600}
1601
1602/// Print the run's wallet-shortfall warning ([`fetch::RunFunding::shortfall`]),
1603/// if it has one, for a run that ends before its summary.
1604fn warn_shortfall<P: Provider + Clone>(ctx: &PullCtx<'_, P>) {
1605    if let Some(shortfall) = ctx.funding.shortfall() {
1606        eprintln!("warning: {shortfall}");
1607    }
1608}
1609
1610/// Obtain the bundle manifest through `ctx`, pull every kept entry, and print the
1611/// run summary.
1612async fn pull_manifest<P: Provider + Clone>(
1613    mut ctx: PullCtx<'_, P>,
1614    args: &BundlePullArgs,
1615    filter: &EntryFilter,
1616    filters_given: bool,
1617    local_manifest: Option<Kept>,
1618) -> anyhow::Result<()> {
1619    // Obtain the manifest: the pre-read local one, or the `--hash` bundle blob
1620    // fetched and filtered here. `None` => filtered to empty (already reported).
1621    // `--select`: let the user trim the (already glob-filtered) list in their
1622    // editor. Everything deselected ends the run (reported) like an empty filter.
1623    let selected = async {
1624        let Some(kept) = obtain_manifest(&ctx, args, filter, filters_given, local_manifest).await?
1625        else {
1626            return Ok(None);
1627        };
1628        maybe_select(args, kept)
1629    }
1630    .await;
1631    // The `--hash` manifest fetch is paid, so a run that ends here still owes
1632    // the wallet-shortfall warning the summary below prints otherwise.
1633    let Some(Kept { manifest, excluded }) = selected.inspect_err(|_| warn_shortfall(&ctx))? else {
1634        warn_shortfall(&ctx);
1635        return Ok(());
1636    };
1637
1638    // The kept manifest is known: enable the multi-bar renderer (silent off a
1639    // terminal or under `--json`). Its total bar meters the run's **download** — the
1640    // bytes actually fetched after shared-chunk dedup — with the whole on-disk
1641    // content size shown alongside in the header, both fixed now from the manifest.
1642    let manifest_fetch_plan = build_fetch_plan(&manifest.entries);
1643    ctx.progress = PullProgress::new(
1644        args.json,
1645        download_bytes(&manifest.entries, &manifest_fetch_plan),
1646        total_content_bytes(&manifest.entries),
1647        &run_label(&args.output),
1648    );
1649
1650    // Loaded once for the whole run: the pre-pass skip decisions above consult it,
1651    // and it seeds the incremental skip-cache flush inside `pull_all` — a run's
1652    // completed files are folded onto this prior and rewritten as the pull
1653    // progresses, so an interrupted pull leaves the files it landed recorded
1654    // rather than losing the whole index. The skip-cache is advisory, so a flush
1655    // failure is logged (inside the flush) and never fails the pull.
1656    let saved = bundle_manifest::load(&args.output);
1657    // Watched from here on: a Ctrl-C during the pull stops it gracefully (see
1658    // `pull_plain`), and the summary below still reports what landed.
1659    let mut interrupt = Interrupt::watch();
1660    let mut run = ctx
1661        .pull_all(
1662            &manifest.entries,
1663            &args.output,
1664            args.overwrite,
1665            saved,
1666            &mut interrupt,
1667        )
1668        .await;
1669    // A funding recovery step that replaced the pool ends the first pass. The
1670    // remaining entries run once more against the new pool: lanes key on the
1671    // pool, so the pass starts with fresh ledgers and funding facts, and each
1672    // entry resumes the partial data it holds (ADR 003 § Funding recovery).
1673    let replaced = replaced_pool(&run);
1674    if let Some(new) = replaced {
1675        eprintln!("note: {new}");
1676        ctx.ledgers = LaneLedgers::new();
1677        ctx.funding = fetch::RunFunding::default();
1678        ctx.recovery.start_next_pass();
1679        let saved = bundle_manifest::load(&args.output);
1680        let second = ctx
1681            .pull_all(
1682                &manifest.entries,
1683                &args.output,
1684                args.overwrite,
1685                saved,
1686                &mut interrupt,
1687            )
1688            .await;
1689        run = merge_passes(run, second);
1690        eprintln!(
1691            "note: pool {} accepts no more funds and may still hold a balance; once its \
1692             dispute window ends, reclaim it with: decdn pool reclaim --pool {}",
1693            new.closed, new.closed
1694        );
1695    }
1696    let PullRun {
1697        outcomes,
1698        mut warnings,
1699        transfer,
1700        interrupted,
1701        stopped,
1702    } = run;
1703    // A second replacement in one command ends it "funding needed".
1704    let stopped = stopped.map(|err| match (replaced, err.downcast_ref::<PoolReplaced>()) {
1705        (Some(_), Some(_)) => err.context(
1706            "funding needed: the pool this pull opened accepts no more funds either; run the \
1707             pull again",
1708        ),
1709        _ => err,
1710    });
1711    ctx.progress.finish();
1712    warn_leftover_partials(&args.output, &manifest.entries, args.hash.as_deref());
1713    warnings.extend(ctx.funding.shortfall());
1714
1715    // Every entry has joined, so the shared dedup counters are now stable.
1716    let dedup = DedupSummary {
1717        spliced_bytes: ctx.dedup_stats.spliced_bytes.load(Ordering::Relaxed),
1718        hints_ignored: ctx.dedup_stats.hints_ignored.load(Ordering::Relaxed),
1719    };
1720    let reported = report(
1721        &outcomes,
1722        &warnings,
1723        transfer,
1724        dedup,
1725        excluded,
1726        &args.output,
1727        args.json,
1728    );
1729    if interrupted {
1730        return Err(Interrupted.into());
1731    }
1732    // A fault that ended the whole pull is its result, so `main` maps it to
1733    // its exit code (75 for a give-up, 1 otherwise).
1734    if let Some(err) = stopped {
1735        return Err(err);
1736    }
1737    reported
1738}
1739
1740/// How often the pull's poll watch ([`warn_when_unpolled`]) ticks.
1741const POLL_WATCH_TICK: std::time::Duration = std::time::Duration::from_secs(1);
1742
1743/// How late a poll-watch tick may fire before the pull logs that its task was
1744/// not polled.
1745const POLL_WATCH_LATE: std::time::Duration = std::time::Duration::from_secs(2);
1746
1747/// Run `drive` beside `flush` until both finish, or until the first Ctrl-C.
1748/// Returns whether a Ctrl-C stopped it.
1749///
1750/// The Ctrl-C drops `drive`: every in-flight fetch stops where it is (its drop
1751/// guard queues what it paid for recording, and its `.partial` is the next
1752/// run's resume prefix). The flush channel's sender drops with `drive`, so
1753/// `flush` still writes every group that settled before it returns. A poll
1754/// watch ([`warn_when_unpolled`]) runs beside `drive` until `drive` ends or
1755/// Ctrl-C stops it.
1756async fn drive_until_interrupted(
1757    drive: impl std::future::Future<Output = ()>,
1758    flush: impl std::future::Future<Output = ()>,
1759    interrupt: &mut Interrupt,
1760) -> bool {
1761    let drive = async {
1762        tokio::select! {
1763            () = drive => false,
1764            () = interrupt.wait() => true,
1765            never = warn_when_unpolled() => match never {},
1766        }
1767    };
1768    tokio::join!(drive, flush).0
1769}
1770
1771/// Log a WARN each time a tick fires more than [`POLL_WATCH_LATE`] late, that
1772/// is, each time the task that polls this future was not polled for a while.
1773/// Never returns.
1774///
1775/// Every entry and lane of a pull runs on one task, so anything that blocks
1776/// that task stops every lane at once, and the nodes see the lanes' streams
1777/// go quiet together (#2211). A tick's lateness is a lower bound on that
1778/// freeze: a freeze that starts just after a tick misses up to one
1779/// [`POLL_WATCH_TICK`], so only a freeze longer than about 3 s is sure to be
1780/// logged. A system suspend shows up the same way.
1781async fn warn_when_unpolled() -> std::convert::Infallible {
1782    let mut tick = tokio::time::interval(POLL_WATCH_TICK);
1783    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1784    loop {
1785        let due = tick.tick().await;
1786        let late = due.elapsed();
1787        if late > POLL_WATCH_LATE {
1788            tracing::warn!(
1789                late_ms = late.as_millis(),
1790                "the bundle task was not polled for at least {late:?}: a blocking call on the \
1791                 pull task froze every lane (or the system was suspended); run with -vv to see \
1792                 which"
1793            );
1794        }
1795    }
1796}
1797
1798/// What [`PullCtx::pull_all`] hands back: every settled entry's outcome, the
1799/// run's byte tally, whether a Ctrl-C stopped it, and the fault that ended it
1800/// early. A run that stopped early leaves out the entries that had not
1801/// settled.
1802struct PullRun {
1803    outcomes: Vec<EntryOutcome>,
1804    /// One line per landed entry whose blob differs in size from its
1805    /// manifest ([`size_warnings`]).
1806    warnings: Vec<String>,
1807    transfer: Transfer,
1808    interrupted: bool,
1809    /// The fault that ended the whole pull ([`ends_the_pull`]), if one did.
1810    stopped: Option<anyhow::Error>,
1811}
1812
1813/// The pool replacement that ended `run`, when a funding recovery step opened
1814/// a new pool and the pass was not interrupted.
1815fn replaced_pool(run: &PullRun) -> Option<PoolReplaced> {
1816    if run.interrupted {
1817        return None;
1818    }
1819    run.stopped
1820        .as_ref()
1821        .and_then(|err| err.downcast_ref::<PoolReplaced>())
1822        .copied()
1823}
1824
1825/// One run's result from its `first` pass, which a pool replacement ended,
1826/// and its `second` pass against the new pool. Both passes walk the same
1827/// entries in the same order, so an entry the first pass landed and the
1828/// second found present keeps the first pass's outcome; every other entry
1829/// takes the second's. The two transfers add up.
1830fn merge_passes(first: PullRun, second: PullRun) -> PullRun {
1831    let outcomes = if first.outcomes.len() == second.outcomes.len() {
1832        first
1833            .outcomes
1834            .into_iter()
1835            .zip(second.outcomes)
1836            .map(|(was, now)| match (&was, &now) {
1837                (
1838                    EntryOutcome::Fetched(_) | EntryOutcome::Linked | EntryOutcome::Deduped(_),
1839                    EntryOutcome::Skipped,
1840                ) => was,
1841                _ => now,
1842            })
1843            .collect()
1844    } else {
1845        second.outcomes
1846    };
1847    let mut warnings = first.warnings;
1848    warnings.extend(second.warnings);
1849    PullRun {
1850        outcomes,
1851        warnings,
1852        transfer: first.transfer.add(second.transfer),
1853        interrupted: second.interrupted,
1854        stopped: second.stopped,
1855    }
1856}
1857
1858/// The bundle-level namespace id (ADR 002) every paid pull in the run carries:
1859/// `--namespace <id>` as a big-endian `uint256`, or `NO_NAMESPACE` when the flag
1860/// was omitted.
1861fn namespace_id(namespace: Option<u64>) -> [u8; 32] {
1862    namespace.map_or(decdn_protocol::client::NO_NAMESPACE, |n| {
1863        alloy::primitives::U256::from(n).to_be_bytes()
1864    })
1865}
1866
1867/// The `--hash` bundle manifest bytes a real pull reads from the output root's
1868/// [`bundle_cache`] instead of paying for a fetch, or `None` to fetch them.
1869/// "Don't pay again": a prior pull of this bundle into the same output dir cached
1870/// the manifest blob, keyed by its hash and self-verifying on read. `--overwrite`
1871/// returns `None` to force a fresh fetch, as it bypasses the file skip-cache. A
1872/// cache file that cannot be read or fails its hash check is fail-open: the error
1873/// prints as a warning and the pull fetches.
1874fn cached_manifest(args: &BundlePullArgs, hash: [u8; 32]) -> Option<Vec<u8>> {
1875    if args.overwrite {
1876        return None;
1877    }
1878    bundle_cache::load(&args.output, hash).unwrap_or_else(|e| {
1879        eprintln!("warning: {e:#}; fetching the bundle manifest");
1880        None
1881    })
1882}
1883
1884/// The kept manifest for the run: the pre-read local one (already filtered up
1885/// front), or the `--hash` bundle blob fetched into memory and filtered here.
1886/// `Ok(None)` means the manifest is empty after filtering — [`report_nothing_to_fetch`]
1887/// was already called, so the caller returns `Ok(())`.
1888async fn obtain_manifest<P: Provider + Clone>(
1889    ctx: &PullCtx<'_, P>,
1890    args: &BundlePullArgs,
1891    filter: &EntryFilter,
1892    filters_given: bool,
1893    local_manifest: Option<Kept>,
1894) -> anyhow::Result<Option<Kept>> {
1895    if let Some(m) = local_manifest {
1896        return Ok(Some(m));
1897    }
1898    let raw = args
1899        .hash
1900        .as_deref()
1901        .ok_or_else(|| anyhow!("no bundle source (expected -i or --hash)"))?;
1902    let hash = fetch::parse_hash(raw)?;
1903    let bytes = if let Some(cached) = cached_manifest(args, hash) {
1904        cached
1905    } else {
1906        let fetched = ctx
1907            .fetch_to_memory(hash, &args.output)
1908            .await
1909            .context("fetch bundle manifest blob")?;
1910        // Advisory: a write failure prints a warning, never fails the pull.
1911        bundle_cache::store(&args.output, hash, &fetched);
1912        fetched
1913    };
1914    let mut m = parse_manifest(&bytes)?;
1915    let raw_empty = m.entries.is_empty();
1916    let (entries, excluded) = filter.apply_and_warn(m.entries);
1917    m.entries = entries;
1918    if m.entries.is_empty() {
1919        report_nothing_to_fetch(NothingReason::from_filter(filters_given, raw_empty));
1920        warn_leftover_partials(&args.output, &[], args.hash.as_deref());
1921        return Ok(None);
1922    }
1923    Ok(Some(Kept {
1924        manifest: m,
1925        excluded,
1926    }))
1927}
1928
1929/// Record one group's run in [`PullCtx::pull_plain`]: forward its recordable
1930/// files to the skip-cache flush, put its outcomes in slot `i`, and return the
1931/// fault that ends the whole pull, if its fetch failed with one.
1932fn settle_group_run(
1933    groups: &mut [SettledGroup],
1934    flush_tx: &tokio::sync::mpsc::UnboundedSender<FlushBatch>,
1935    i: usize,
1936    run: GroupRun,
1937    updates: BTreeMap<String, bundle_manifest::SavedFile>,
1938    warnings: Vec<String>,
1939) -> Option<anyhow::Error> {
1940    if !updates.is_empty() {
1941        // Newly-fetched content bytes drive the byte-cadence flush; a link/skip
1942        // records a file but lands no new bytes.
1943        let fetched_bytes = run
1944            .outcomes
1945            .iter()
1946            .map(|o| match o {
1947                EntryOutcome::Fetched(n) => *n,
1948                _ => 0,
1949            })
1950            .sum();
1951        // Unbounded send: fails only if the flush task is gone, which never
1952        // happens before `drive` drops `flush_tx`.
1953        let _ = flush_tx.send(FlushBatch {
1954            updates,
1955            fetched_bytes,
1956        });
1957    }
1958    if let Some(slot) = groups.get_mut(i) {
1959        *slot = SettledGroup {
1960            outcomes: run.outcomes,
1961            warnings,
1962            bytes: run.bytes,
1963        };
1964    }
1965    run.stop
1966}
1967
1968/// A hash-group's result, kept for the run summary: its per-entry outcomes
1969/// and the content bytes its landed fetch paid for and resumed (see
1970/// [`GroupRun::bytes`]).
1971#[derive(Clone, Default)]
1972struct SettledGroup {
1973    outcomes: Vec<EntryOutcome>,
1974    /// The group's size warnings ([`size_warnings`]).
1975    warnings: Vec<String>,
1976    bytes: Option<EntryBytes>,
1977}
1978
1979/// Run `items` through `run` once, at most `jobs` at a time. A bundle pull
1980/// passes every group: its [`JobGate`] is the `--jobs` cap, and a group waits
1981/// for its slot inside `run`.
1982///
1983/// `settle` sees every result as it lands, with the item's index in `items`,
1984/// and returns the fault that ends the whole run, if the result carries one.
1985/// That fault starts no further item, drops the items in flight, and is what
1986/// this returns. `None` means every item ran and settled.
1987async fn run_groups<G, R, Fut>(
1988    items: Vec<G>,
1989    jobs: usize,
1990    run: impl Fn(G) -> Fut,
1991    mut settle: impl FnMut(usize, R) -> Option<anyhow::Error>,
1992) -> Option<anyhow::Error>
1993where
1994    Fut: std::future::Future<Output = R>,
1995{
1996    let width = jobs.min(items.len()).max(1);
1997    let mut stream = futures_util::stream::iter(items.into_iter().enumerate())
1998        .map(|(i, item)| {
1999            let fut = run(item);
2000            async move { (i, fut.await) }
2001        })
2002        .buffer_unordered(width);
2003    while let Some((i, result)) = stream.next().await {
2004        if let Some(err) = settle(i, result) {
2005            return Some(err);
2006        }
2007    }
2008    None
2009}
2010
2011/// Per-provider cap on concurrent streams to one `(pool, signer, provider)`
2012/// lane. A lane has one shared [`LaneLedgers`] voucher watermark; concurrent
2013/// streams on the same lane draw on it together, and the serving node credits
2014/// each stream's delivered bytes from that shared watermark fairly — a slower
2015/// stream, whose reveal for its own chunk can land below a faster sibling's
2016/// frontier, is paid from the lane headroom the sibling opened rather than
2017/// stalled. So same-lane concurrency is safe; this only bounds how many streams
2018/// touch a given provider at once. `--max-lane-streams` sets the cap (default 4):
2019/// a `Semaphore(N)` admits N concurrent same-lane streams, and N == 1 runs a
2020/// single ordered voucher sequence per lane. Cross-lane parallelism (distinct
2021/// providers) is never bounded here — only by `--jobs`.
2022pub(crate) struct LaneStreamCap {
2023    /// Per-provider streams, created on first use. The `tokio::sync::Mutex`
2024    /// guards the map so the cap is `Sync` and shareable across the entry futures.
2025    map: tokio::sync::Mutex<HashMap<Address, Arc<ProviderStreams>>>,
2026    /// Concurrent-stream permits per provider (at least 1). At 1 a `Semaphore(1)`
2027    /// serializes same-lane streams exactly like a mutex.
2028    n: usize,
2029    /// The id the next lane's [`ExtraPermits`] gets.
2030    next_lane: AtomicU64,
2031}
2032
2033impl LaneStreamCap {
2034    /// A cap admitting `n` concurrent streams per provider (clamped to at least 1).
2035    pub(crate) fn new(n: usize) -> Self {
2036        Self {
2037            map: tokio::sync::Mutex::new(HashMap::new()),
2038            n: n.max(1),
2039            next_lane: AtomicU64::new(0),
2040        }
2041    }
2042
2043    /// The per-provider streams, created on first use.
2044    async fn streams(&self, provider: Address) -> Arc<ProviderStreams> {
2045        let mut map = self.map.lock().await;
2046        Arc::clone(
2047            map.entry(provider)
2048                .or_insert_with(|| Arc::new(ProviderStreams::new(self.n))),
2049        )
2050    }
2051
2052    /// One stream permit for `provider` if one is free now, else `None`. It
2053    /// never waits, so a caller that already holds another provider's permit
2054    /// cannot deadlock against a sibling that holds this one. It does not
2055    /// look at the provider's claims ([`ExtraPermits::grant`]): a lane's first
2056    /// stream takes any free permit, and a lane that finds none backs off and
2057    /// holds nothing.
2058    pub(crate) async fn try_permit(
2059        &self,
2060        provider: Address,
2061    ) -> Option<tokio::sync::OwnedSemaphorePermit> {
2062        Arc::clone(&self.streams(provider).await.semaphore)
2063            .try_acquire_owned()
2064            .ok()
2065    }
2066
2067    /// How a lane to `provider` takes a stream beyond its lease
2068    /// ([`decdn_client::LaneWiden`]): an extra stream for a queued range no
2069    /// idle lane takes, or the stream it starts again on. `grow` takes only
2070    /// a permit of `provider` that is free now and never waits, and `release`
2071    /// gives one back. The lane's first stream holds its permit as its lease,
2072    /// so the extra streams fit in the rest of the cap. At a cap of 3 or more,
2073    /// an extra stream never takes the provider's last free permit, so a
2074    /// sibling entry's first stream to `provider` can still open; the extra
2075    /// stream holds its permit for a whole range. A smaller cap has no room
2076    /// for both, and an extra stream takes any free permit. The acquire gives
2077    /// the lease back when the lane's own worker ends, and the lane starts
2078    /// again only on a permit `grow` takes; that start may take the last
2079    /// permit that no claim holds back.
2080    ///
2081    /// A lane that `grow` refuses claims the provider's next free permit,
2082    /// ahead of every later `grow` ask, and a lane that runs no stream goes
2083    /// ahead of the claims of lanes that run one ([`ProviderStreams::take`],
2084    /// #2341). A lane's first stream ([`Self::try_permit`]) still takes any
2085    /// free permit.
2086    pub(crate) async fn widen(&self, provider: Address) -> decdn_client::LaneWiden {
2087        let extras = Arc::new(self.extra_permits(provider).await);
2088        let released = Arc::clone(&extras);
2089        decdn_client::LaneWiden::new(
2090            move |kind| extras.grant(kind),
2091            move || released.release_one(),
2092        )
2093    }
2094
2095    /// A new lane's grants of `provider`'s permits beside its lease, under a
2096    /// lane id no other lane of this cap has.
2097    async fn extra_permits(&self, provider: Address) -> ExtraPermits {
2098        ExtraPermits {
2099            streams: self.streams(provider).await,
2100            keep: self.extra_keep(),
2101            lane: self.next_lane.fetch_add(1, Ordering::Relaxed),
2102            held: std::sync::Mutex::new(Vec::new()),
2103        }
2104    }
2105
2106    /// The free permits of a provider an extra stream leaves for a sibling
2107    /// entry's first stream: one at a cap of 3 or more, where the lease, an
2108    /// extra stream and the kept permit fit, and none below.
2109    const fn extra_keep(&self) -> usize {
2110        if self.n > 2 { 1 } else { 0 }
2111    }
2112}
2113
2114/// How long a lane's claim on a provider's next free permit holds without a
2115/// new ask. The acquire asks a refused `grow` again within
2116/// [`decdn_client::GROWTH_RETRY`] while it still wants the stream, and each
2117/// ask renews the claim. So a lane that still waits keeps its claim, and the
2118/// claim of a lane that stopped asking, such as one whose own worker took
2119/// the range, ends soon after. A lane whose node refused its extra streams
2120/// is not asked while it waits out that refusal, so its claim lapses too:
2121/// the node would refuse the stream anyway.
2122const CLAIM_TTL: std::time::Duration = decdn_client::GROWTH_RETRY.saturating_mul(3);
2123
2124/// One provider's stream permits and the claims of the lanes that wait for
2125/// one.
2126struct ProviderStreams {
2127    /// The provider's per-lane stream semaphore.
2128    semaphore: Arc<tokio::sync::Semaphore>,
2129    /// The lanes [`Self::take`] refused, at most one claim per lane, in the
2130    /// order they first claimed. A renewal keeps a lane's place.
2131    claims: std::sync::Mutex<Vec<Claim>>,
2132}
2133
2134/// A lane's claim on its provider's next free permit.
2135struct Claim {
2136    /// The claiming lane's [`ExtraPermits::lane`].
2137    lane: u64,
2138    /// The free permits the lane's last ask leaves after its own.
2139    keep: usize,
2140    /// Whether the lane ran no stream at its last ask.
2141    bare: bool,
2142    /// When the lane last asked.
2143    at: tokio::time::Instant,
2144}
2145
2146impl ProviderStreams {
2147    /// `n` permits and no claims.
2148    fn new(n: usize) -> Self {
2149        Self {
2150            semaphore: Arc::new(tokio::sync::Semaphore::new(n)),
2151            claims: std::sync::Mutex::new(Vec::new()),
2152        }
2153    }
2154
2155    /// A permit for `lane` if one is free now that no claim holds back,
2156    /// never waiting. `keep` is the free permits the ask leaves after its
2157    /// own, and `bare` says the lane runs no stream.
2158    ///
2159    /// Each claim ahead of `lane`'s own, or every claim when it has none,
2160    /// holds back one free permit, and the largest `keep` among the ask and
2161    /// those claims stays free after them. A bare ask counts only the bare
2162    /// claims ahead, so a lane that runs no stream goes ahead of a lane that
2163    /// waits for one more: every lane gets its first stream before any lane
2164    /// gets a second. So a lane that asks the instant a permit frees, such
2165    /// as one whose short legs restart beside its extra streams, cannot take
2166    /// it from a lane that asks only once per growth pass (#2341).
2167    ///
2168    /// A refused ask claims the next free permit, or renews the lane's claim
2169    /// in its place. A grant ends the claim. Claims older than [`CLAIM_TTL`]
2170    /// do not count. Every entry is polled on one task and this never
2171    /// awaits, so no sibling takes a permit between the count and the take.
2172    fn take(
2173        &self,
2174        lane: u64,
2175        keep: usize,
2176        bare: bool,
2177    ) -> Option<tokio::sync::OwnedSemaphorePermit> {
2178        let now = tokio::time::Instant::now();
2179        let mut claims = self.claims.lock().unwrap_or_else(PoisonError::into_inner);
2180        claims.retain(|claim| now.saturating_duration_since(claim.at) < CLAIM_TTL);
2181        let own = claims.iter().position(|claim| claim.lane == lane);
2182        let (ahead, kept) = claims
2183            .get(..own.unwrap_or(claims.len()))
2184            .unwrap_or_default()
2185            .iter()
2186            .filter(|claim| !bare || claim.bare)
2187            .fold((0_usize, keep), |(n, kept), claim| {
2188                (n.saturating_add(1), kept.max(claim.keep))
2189            });
2190        if self.semaphore.available_permits() <= ahead.saturating_add(kept) {
2191            let claim = Claim {
2192                lane,
2193                keep,
2194                bare,
2195                at: now,
2196            };
2197            match own.and_then(|i| claims.get_mut(i)) {
2198                Some(renewed) => *renewed = claim,
2199                None => claims.push(claim),
2200            }
2201            return None;
2202        }
2203        match Arc::clone(&self.semaphore).try_acquire_owned() {
2204            Ok(permit) => {
2205                if let Some(i) = own {
2206                    claims.remove(i);
2207                }
2208                Some(permit)
2209            }
2210            Err(tokio::sync::TryAcquireError::NoPermits) => None,
2211            Err(tokio::sync::TryAcquireError::Closed) => {
2212                // Nothing closes the semaphore: a closed one grants no
2213                // stream, and says so.
2214                tracing::warn!("a lane's stream semaphore is closed; it grants no extra stream");
2215                None
2216            }
2217        }
2218    }
2219
2220    /// End `lane`'s claim, if it has one.
2221    fn forget(&self, lane: u64) {
2222        self.claims
2223            .lock()
2224            .unwrap_or_else(PoisonError::into_inner)
2225            .retain(|claim| claim.lane != lane);
2226    }
2227}
2228
2229/// The stream permits one lane holds beside its lease: its extra streams',
2230/// and its own stream's once it starts again without the lease
2231/// ([`LaneStreamCap::widen`]).
2232struct ExtraPermits {
2233    /// The provider's permits and claims.
2234    streams: Arc<ProviderStreams>,
2235    /// The free permits an extra stream leaves for a sibling entry's first
2236    /// stream.
2237    keep: usize,
2238    /// The lane's id in its provider's claims.
2239    lane: u64,
2240    /// The permits granted and not yet given back.
2241    held: std::sync::Mutex<Vec<tokio::sync::OwnedSemaphorePermit>>,
2242}
2243
2244impl ExtraPermits {
2245    /// Take one permit if it is free now and no claim holds it back
2246    /// ([`ProviderStreams::take`]), never waiting, and return whether one
2247    /// was taken. An extra stream leaves `keep` free permits for a sibling
2248    /// entry's first stream; a restart may take the last one. The acquire
2249    /// asks a restart only once the lane's lease is given back, so a lane
2250    /// that asks one and holds no grant runs no stream.
2251    fn grant(&self, kind: decdn_client::GrowFor) -> bool {
2252        let (keep, bare) = match kind {
2253            decdn_client::GrowFor::Restart => (0, self.lock_held().is_empty()),
2254            decdn_client::GrowFor::Extra => (self.keep, false),
2255        };
2256        let Some(permit) = self.streams.take(self.lane, keep, bare) else {
2257            return false;
2258        };
2259        self.lock_held().push(permit);
2260        true
2261    }
2262
2263    /// Give back one held permit. The acquire gives back only what `grant`
2264    /// granted, so a call with none held breaks that rule: it does nothing
2265    /// and says so.
2266    fn release_one(&self) {
2267        let permit = self.lock_held().pop();
2268        debug_assert!(
2269            permit.is_some(),
2270            "a stream was given back that was never granted"
2271        );
2272        if permit.is_none() {
2273            tracing::warn!("a lane gave back a stream permit it does not hold");
2274        }
2275        drop(permit);
2276    }
2277
2278    /// The held permits, locked.
2279    fn lock_held(&self) -> std::sync::MutexGuard<'_, Vec<tokio::sync::OwnedSemaphorePermit>> {
2280        self.held.lock().unwrap_or_else(PoisonError::into_inner)
2281    }
2282}
2283
2284impl Drop for ExtraPermits {
2285    /// An ended lane claims nothing.
2286    fn drop(&mut self) {
2287        self.streams.forget(self.lane);
2288    }
2289}
2290
2291/// The run's `--jobs` cap ([`PullCtx::gate`]): its permits, and the admission
2292/// queue a new group's first permit waits in.
2293///
2294/// A new group waits in `admission` before it waits for a permit, so at most
2295/// one new group queues on `permits` at a time. A group that gave its permit
2296/// back while it waited for donors ([`FetchSlot::ensure`]) queues on `permits`
2297/// directly, so it waits behind at most that one new group, not behind every
2298/// group of the bundle still to start.
2299struct JobGate {
2300    permits: tokio::sync::Semaphore,
2301    admission: tokio::sync::Mutex<()>,
2302}
2303
2304impl JobGate {
2305    /// A gate of `jobs` permits.
2306    fn new(jobs: usize) -> Self {
2307        Self {
2308            permits: tokio::sync::Semaphore::new(jobs),
2309            admission: tokio::sync::Mutex::new(()),
2310        }
2311    }
2312
2313    /// Wait for a permit.
2314    async fn permit(&self) -> anyhow::Result<tokio::sync::SemaphorePermit<'_>> {
2315        self.permits
2316            .acquire()
2317            .await
2318            .map_err(|_| anyhow!("bundle pull concurrency gate closed"))
2319    }
2320}
2321
2322/// One `--jobs` permit of a [`JobGate`], held by one hash group's fetch or by
2323/// the manifest fetch.
2324///
2325/// A range-dedup entry gives it back only while it waits for a sibling to
2326/// register its donor chunks ([`RangeDriver::release_slot`]): a waiter moves
2327/// no bytes, and holding the permit would keep a queued entry from fetching.
2328/// The wait takes a permit again as it ends ([`RangeDriver::retake_slot`]), so
2329/// the splice, the whole-file hash and the materialize after it run under one,
2330/// and each drive takes one again first ([`Self::ensure`]). One entry uses a
2331/// slot sequentially: it never drives twice at once.
2332struct FetchSlot<'g> {
2333    gate: &'g JobGate,
2334    permit: Mutex<Option<tokio::sync::SemaphorePermit<'g>>>,
2335}
2336
2337impl<'g> FetchSlot<'g> {
2338    /// Wait in `gate`'s admission queue, then for a permit.
2339    async fn acquire(gate: &'g JobGate) -> anyhow::Result<Self> {
2340        let admitted = gate.admission.lock().await;
2341        let permit = gate.permit().await?;
2342        drop(admitted);
2343        Ok(Self {
2344            gate,
2345            permit: Mutex::new(Some(permit)),
2346        })
2347    }
2348
2349    /// Give the permit back, if this slot holds it.
2350    fn release(&self) {
2351        drop(
2352            self.permit
2353                .lock()
2354                .unwrap_or_else(PoisonError::into_inner)
2355                .take(),
2356        );
2357    }
2358
2359    /// Hold a permit: wait for one if [`Self::release`] gave it back, behind
2360    /// at most the one new group in admission. A permit that arrives after
2361    /// another already filled the slot goes straight back.
2362    async fn ensure(&self) -> anyhow::Result<()> {
2363        if self.held() {
2364            return Ok(());
2365        }
2366        let permit = self.gate.permit().await?;
2367        let mut slot = self.permit.lock().unwrap_or_else(PoisonError::into_inner);
2368        if slot.is_none() {
2369            *slot = Some(permit);
2370        }
2371        Ok(())
2372    }
2373
2374    /// Whether this slot holds a permit now.
2375    fn held(&self) -> bool {
2376        self.permit
2377            .lock()
2378            .unwrap_or_else(PoisonError::into_inner)
2379            .is_some()
2380    }
2381}
2382
2383/// Shared, by-reference state for the entry fetch loop. Borrowed by every
2384/// in-flight entry future, and every entry must be polled on one task, as
2385/// `buffer_unordered` does: each entry reads its lanes' ledgers and queues
2386/// their watermark writes with no await between
2387/// ([`fetch::queue_face_watermarks`]), and only one task keeps another entry's
2388/// read from falling between them.
2389struct PullCtx<'a, P: Provider + Clone> {
2390    endpoint: &'a Endpoint,
2391    store: &'a Arc<RedbBuyerPoolStore>,
2392    contract: &'a PaymentPool::PaymentPoolInstance<P>,
2393    rpc: &'a P,
2394    signer: &'a Arc<PrivateKeySigner>,
2395    self_address: Address,
2396    /// The pool's settlement token (USDC), read once via `PaymentPool.usdc()`.
2397    token: Address,
2398    voucher_dom: &'a Eip712Domain,
2399    slash_dom: &'a Eip712Domain,
2400    chain: &'a fetch::ResolvedChain,
2401    relays: &'a [RelayUrl],
2402    /// `Some((node_id, provider))` pins every entry to one node (`--node-id`);
2403    /// `None` discovers per entry against a sample of `registry`.
2404    explicit: Option<FetchTarget>,
2405    /// Every active registered node, read once for the run; `None` when
2406    /// `explicit` pins the node.
2407    registry: Option<Vec<NodeCandidate>>,
2408    common: &'a ClientFetchArgs,
2409    /// Delegated capability adopted for every entry (`--capability`), or `None`
2410    /// for the self-owned pool path. When `Some`, every entry presents this
2411    /// owner-signed grant instead of opening/reusing the caller's own pool, and
2412    /// a funding recovery step has no funding path (the delegate owns no pool
2413    /// to fund).
2414    grant: Option<decdn_incentive::CapabilityGrant>,
2415    /// Bundle-level namespace id (ADR 002) applied to every paid pull in the run —
2416    /// `--namespace <id>` as a big-endian `uint256`; `NO_NAMESPACE` when the flag
2417    /// was omitted.
2418    namespace_id: [u8; 32],
2419    /// The run's shared per-lane voucher ledgers (ADR 039): every entry and
2420    /// chunk on a `(pool_id, signer, provider)` lane draws on one monotonic
2421    /// issuer, so concurrent same-lane fetches never race the cumulative
2422    /// watermark.
2423    ledgers: LaneLedgers,
2424    /// The run's funding facts ([`fetch::RunFunding`]): the pool's spend
2425    /// outside the run's lanes, and a wallet too short of USDC to top it up.
2426    funding: fetch::RunFunding,
2427    /// The run's funding recovery gate (ADR 003 § Funding recovery). A bundle
2428    /// pull is one fetch for every entry, so its entries share one progress
2429    /// rule, and so does the pass that runs the remaining entries again
2430    /// against a replaced pool.
2431    recovery: Arc<RecoveryGate>,
2432    /// Run-scoped range-dedup counters (bytes spliced from disk, hints dropped by
2433    /// a fault), accumulated by every entry and reported once the pull finishes.
2434    dedup_stats: DedupStats,
2435    /// Serializes every pool open-or-reuse across the whole bundle. The
2436    /// bundle's every entry shares ONE `PaymentPool` deposit (ADR 003), so
2437    /// distinct providers cannot open concurrently: its on-chain state (deposit,
2438    /// standing USDC allowance) is one resource regardless of which provider an
2439    /// entry is bound for. Taken around the whole open-or-reuse call (which may
2440    /// also perform a low-water top-up) and released before streaming, so
2441    /// delivery itself still runs concurrently once each entry has its context.
2442    open_lock: tokio::sync::Mutex<()>,
2443    /// Global `--jobs` cap (min 1) on hash groups at work, one permit per group
2444    /// ([`FetchSlot`]), taken before the group's file bar: a plain whole-file
2445    /// fetch, or a hint-carrying entry's complement drive, donor splice and any
2446    /// re-fetch. A range-dedup entry gives its permit back while it only waits
2447    /// for a sibling's donor chunks, and takes one again to drive. A byte range
2448    /// a sibling entry already holds is spliced from disk instead of fetched,
2449    /// so it never takes a permit of its own.
2450    gate: JobGate,
2451    /// Per-provider cap on concurrent same-lane streams (`--max-lane-streams`,
2452    /// default 4). Every lane an entry's fetch builds holds one of its
2453    /// provider's permits, taken without waiting ([`CliSources`]). Bounds only
2454    /// per-provider concurrency; `--jobs` still bounds cross-lane parallelism.
2455    lane_cap: LaneStreamCap,
2456    /// The command-wide holder health every entry's fetch records into, so a
2457    /// holder that faults one entry cools for every entry.
2458    health: Arc<PeerHealth>,
2459    /// The command's one connection per node: every entry's lanes, the
2460    /// range-dedup entries' included, open their streams on it.
2461    connections: Connections,
2462    /// The command's blocking state writes, in queue order across every
2463    /// entry ([`OrderedWrites`]).
2464    writes: &'a OrderedWrites,
2465    /// The command's stop policy. Its progress clock is shared by every entry
2466    /// and the manifest fetch, so the pull gives up only once no entry has
2467    /// landed a verified byte for the whole limit.
2468    stop: StopPolicy,
2469    /// The run's multi-bar progress renderer: one per-file bar per active pull
2470    /// above a bottom total bar (silent off a terminal or under `--json`). Set
2471    /// once the kept manifest is known — its entries decide the total-bar mode —
2472    /// so it starts [disabled](PullProgress::disabled) during the manifest fetch.
2473    progress: PullProgress,
2474}
2475
2476impl<P: Provider + Clone> PullCtx<'_, P> {
2477    /// The shared lane deps, assembled from `PullCtx`'s borrowed chain
2478    /// plumbing plus this run's per-fetch budgets — the same shape `decdn fetch`
2479    /// builds, for every fetch of an entry ([`Self::acquire_entry`]).
2480    fn drive_deps(&self, max_blob_bytes: u64) -> anyhow::Result<fetch::DriveFetchDeps<'_, P>> {
2481        Ok(fetch::DriveFetchDeps {
2482            timings: None,
2483            endpoint: self.endpoint,
2484            store: self.store,
2485            contract: self.contract,
2486            rpc: self.rpc,
2487            slash_dom: self.slash_dom,
2488            self_address: self.self_address,
2489            token: self.token,
2490            chain: self.chain,
2491            // Bundle-level `--namespace` (ADR 002): routes any cache-miss origin
2492            // pull to that namespace's authorized origins. Applies uniformly to the
2493            // manifest blob and every entry — all funnel through here.
2494            namespace_id: self.namespace_id,
2495            max_rate_per_mb: self.common.max_rate_per_mb,
2496            max_blob_bytes,
2497            // Same shape as `fetch` (#1134): a node that accepts the connection and
2498            // never answers is as dead as one that stops mid-stream, so the same
2499            // budget bounds both stages. The pull carries no overall wall-clock cap —
2500            // `drive` never consults one — so it completes for any blob size as long
2501            // as the upstream keeps feeding it bytes.
2502            deadlines: PullDeadlines::new(fetch::STALL_WINDOW, fetch::STALL_WINDOW, 0)?,
2503            connections: &self.connections,
2504            writes: self.writes,
2505            funding: &self.funding,
2506        })
2507    }
2508
2509    /// Fetch one whole blob into `staging` through the acquire loop (ADR 039),
2510    /// across the entry's holders ([`Self::entry_targets`]). See
2511    /// [`Self::acquire_entry`] for the fetch itself and what it returns.
2512    ///
2513    /// `slot` is the caller's `--jobs` permit; the fetch runs under it.
2514    async fn fetch_to_staging(
2515        &self,
2516        hash: [u8; 32],
2517        staging: &Path,
2518        total: Option<u64>,
2519        progress: Option<&ProgressCallback>,
2520        slot: &FetchSlot<'_>,
2521    ) -> anyhow::Result<bool> {
2522        slot.ensure().await?;
2523        let targets = self.entry_targets(hash).await?;
2524        self.acquire_entry(&targets, hash, staging, total, None, progress)
2525            .await
2526    }
2527
2528    /// One entry's holders: the pinned `--node-id`, or a fresh probe of the
2529    /// entry's own registry sample (proxy-warming non-holders first when they
2530    /// help, then holders nearest RTT first). An entry resolves them once and
2531    /// every fetch of the entry reuses them. A resolution that fails for any
2532    /// reason but the configuration ([`fetch::holders_or_none`]) starts the
2533    /// entry with no holder, and its acquire loop discovers them.
2534    async fn entry_targets(&self, hash: [u8; 32]) -> anyhow::Result<fetch::ResolvedTargets> {
2535        fetch::holders_or_none(self.resolve_entry_targets(hash).await)
2536    }
2537
2538    /// [`Self::entry_targets`] before a failure is sorted into a configuration
2539    /// fault or an empty start.
2540    async fn resolve_entry_targets(
2541        &self,
2542        hash: [u8; 32],
2543    ) -> anyhow::Result<fetch::ResolvedTargets> {
2544        if self.explicit.is_some() {
2545            return fetch::resolve_target_node(
2546                self.common,
2547                self.chain,
2548                self.endpoint,
2549                self.relays,
2550                hash,
2551                fetch::ProbeOpts {
2552                    timings: None,
2553                    round: fetch::ProbeRound::Stream,
2554                },
2555            )
2556            .await;
2557        }
2558        // The entry's round streams: its fetch starts at the first verified
2559        // holder, and the holders that answer later join it.
2560        let candidates = self.entry_candidates()?;
2561        fetch::probe_and_order(
2562            self.endpoint,
2563            &candidates,
2564            self.relays.first(),
2565            hash,
2566            fetch::ProxyWarmingParams::from_args(self.common),
2567            self.slash_dom,
2568            fetch::ProbeRound::Stream,
2569        )
2570        .await
2571    }
2572
2573    /// Fill `ranges` of blob `hash` (`None` is the whole blob) into the ranged
2574    /// store beside `staging` through the acquire loop, across `targets`' holders.
2575    /// The store is promoted to `staging` once every byte is present.
2576    ///
2577    /// `total` is the entry's first size claim: the manifest's `size` when it
2578    /// gives one. The fetch grows or shrinks it as bytes land, so a manifest
2579    /// size that differs from the blob never fails the entry. With no manifest
2580    /// size (the manifest blob itself, or an unsized entry), the first claim
2581    /// comes from the probe's size hint, or else from a header-only first open.
2582    ///
2583    /// A holder that faults cools in the command-wide [`PeerHealth`] and
2584    /// returns inside the same loop, so a holder that fails one entry is
2585    /// rested for every entry. The fetch ends on done, a fault only the user
2586    /// can fix, a unanimous verdict of the holders, or the command's
2587    /// [`StopPolicy`]. Every lane draws vouchers from the run's shared
2588    /// `LaneLedgers` (ADR 039), serializes its pool open-or-reuse on
2589    /// `open_lock`, and holds one of its provider's stream permits
2590    /// ([`LaneStreamCap`]). Each lane's voucher watermark is persisted before
2591    /// the result returns, and the `.partial` beside `staging` stays for a
2592    /// resume.
2593    ///
2594    /// It returns `true` when the store's finalized file failed its hash and
2595    /// the fetch dropped every byte the store held and fetched the blob again
2596    /// ([`Downloader::refetched_targets`]). A prefix an earlier run left is
2597    /// then fetched and paid again.
2598    async fn acquire_entry(
2599        &self,
2600        targets: &fetch::ResolvedTargets,
2601        hash: [u8; 32],
2602        staging: &Path,
2603        total: Option<u64>,
2604        ranges: Option<&[(u64, u64)]>,
2605        progress: Option<&ProgressCallback>,
2606    ) -> anyhow::Result<bool> {
2607        let max_blob_bytes = self.common.max_blob_mb.saturating_mul(1024 * 1024);
2608        let deps = self.drive_deps(max_blob_bytes)?;
2609        let sources = CliSources::new(
2610            &deps,
2611            self.common,
2612            self.relays,
2613            self.grant.as_ref(),
2614            self.signer,
2615            self.voucher_dom,
2616            Some(&self.open_lock),
2617            Some(&self.ledgers),
2618            Some(&self.lane_cap),
2619            &self.recovery,
2620        );
2621        let holders = sources.holders_from(targets);
2622        // A fetch dropped by Ctrl-C, or by a sibling's command-wide fault,
2623        // queues every lane's vouchers for recording too.
2624        let on_drop = fetch::SettleOnDrop::new(|| sources.persist_watermarks_detached());
2625        let result = async {
2626            // The manifest's size is the entry's first claim; without one, the
2627            // probe's hint or a header-only open gives it.
2628            let (total_bytes, holders) = if let Some(total) = total {
2629                (total, holders)
2630            } else {
2631                let claim = sources
2632                    .first_claim(hash, holders, &self.health, &self.stop)
2633                    .await?;
2634                (claim.total_bytes, claim.holders)
2635            };
2636            // `--max-sources` caps the lanes the entry stripes across at
2637            // once; every other holder waits as a reserve.
2638            let mut downloader = Downloader::new(&sources, sources.funder())
2639                .holders(holders)
2640                .health(Arc::clone(&self.health))
2641                .recovery(Arc::clone(&self.recovery))
2642                .max_lanes(self.common.max_sources)
2643                .max_blob_bytes(max_blob_bytes);
2644            if let Some(slot) = sources.credentials() {
2645                downloader = downloader.credentials(slot);
2646            }
2647            Box::pin(downloader.fetch_to_paths_shared(
2648                &[DownloadTarget {
2649                    hash,
2650                    total_bytes,
2651                    dest: staging,
2652                    ranges,
2653                }],
2654                &self.ledgers,
2655                progress,
2656                &self.stop,
2657            ))
2658            .await
2659            .map(|_paths| downloader.refetched_targets() > 0)
2660        }
2661        .await;
2662        on_drop.disarm();
2663        sources.persist_watermarks().await;
2664        result.map_err(|err| sources.annotate(err))
2665    }
2666
2667    /// This entry's probe candidates: a fresh sample of the run's registry read
2668    /// ([`entry_candidates`]).
2669    fn entry_candidates(&self) -> anyhow::Result<Vec<NodeCandidate>> {
2670        let registry = self
2671            .registry
2672            .as_deref()
2673            .ok_or_else(|| anyhow!("no discovery candidates available"))?;
2674        Ok(entry_candidates(registry, self.chain.region.as_deref()))
2675    }
2676
2677    /// Fetch one blob fully into memory — used only for the bundle manifest
2678    /// itself when named by `--hash` rather than read locally via `-i`.
2679    /// Manifests are small (unlike bundle entries, which stream straight to
2680    /// their destination and never buffer the whole blob), so streaming into a
2681    /// staging file under `out_root` and reading it back is cheap; it also
2682    /// means a manifest fetch runs the same acquire loop, under the same
2683    /// health table and progress clock, as every entry. A manifest has no
2684    /// manifest size, so the fetch takes the first size claim (the probe hint,
2685    /// or a header-only open when there is none). The staging
2686    /// file is removed once read back — a manifest fetch has nothing further
2687    /// to resume once its bytes are safely in memory.
2688    async fn fetch_to_memory(&self, hash: [u8; 32], out_root: &Path) -> anyhow::Result<Vec<u8>> {
2689        let staging = staging_path(out_root, hash)?;
2690        // The manifest blob fetch is silent (no bar): `progress` is disabled here
2691        // anyway, and the per-file bars belong to the entries, not the manifest.
2692        let slot = FetchSlot::acquire(&self.gate).await?;
2693        self.fetch_to_staging(hash, &staging, None, None, &slot)
2694            .await?;
2695        let bytes =
2696            std::fs::read(&staging).with_context(|| format!("read {}", staging.display()))?;
2697        remove_staging_off_runtime(&staging).await;
2698        Ok(bytes)
2699    }
2700
2701    /// Fetch every entry into `out_root`, one unit of work per *distinct* blob
2702    /// hash: entries sharing a hash (one file at two bundle paths) are fetched and
2703    /// reconstructed once, then materialized at each path (#1306) — never fetched,
2704    /// nor *paid for*, twice. Each unit of work runs via `buffer_unordered` (no
2705    /// `tokio::spawn`, by choice — nothing here is `!Send`), every one in flight
2706    /// at once and waiting in start order for its slot of `PullCtx.gate`, the
2707    /// one `--jobs` cap; a group's file bar appears only once it holds a slot.
2708    /// Parallelism comes from concurrent in-flight network I/O, while the run's
2709    /// shared `LaneLedgers` keep same-lane voucher issuance monotonic across
2710    /// every entry.
2711    ///
2712    /// A run-wide [`ChunkIndex`] threads through every group: an entry that
2713    /// carries chunk hints and completes registers its chunks, and a later entry
2714    /// that shares a chunk splices those already-materialized byte ranges from
2715    /// disk instead of paying to fetch them (range-dedup). A donor's finalized
2716    /// staging blob is therefore kept until the whole run finishes, then swept.
2717    async fn pull_all(
2718        &self,
2719        entries: &[ManifestEntry],
2720        out_root: &Path,
2721        overwrite: bool,
2722        saved: SavedManifest,
2723        interrupt: &mut Interrupt,
2724    ) -> PullRun {
2725        // Every entry declares an authoritative whole-file `hash`, so the by-hash
2726        // grouping path (fetch-once + link-duplicates, #1306) covers plain and
2727        // hint-carrying entries alike — the chunk hints only change HOW a group's
2728        // one blob is assembled, never that it is one paid unit per distinct hash.
2729        let index = ChunkIndex::default();
2730        let refs: Vec<&ManifestEntry> = entries.iter().collect();
2731        // Assign every chunk shared by two or more entries to its smallest holder,
2732        // so that shared chunk is fetched once (by that holder) and spliced into
2733        // every other entry — computed once from the whole manifest.
2734        let fetch_plan = build_fetch_plan(entries);
2735        // Pre-pass: classify every entry against the output tree and the saved
2736        // skip-cache before any fetch, so an updated file at an already-present
2737        // path is written rather than silently skipped. The re-hash is this
2738        // command's own work, so the progress clock holds while it runs, and
2739        // the time since the manifest landed (an editor under `--select`
2740        // included) ends when it does.
2741        let hold = self.stop.clock.hold();
2742        let disk = resolve_disk_state(entries, &saved, out_root, overwrite).await;
2743        drop(hold);
2744        for d in &disk.seed {
2745            index.seed_disk(d.hash, &d.source, d.offset, d.len);
2746        }
2747        // `saved` moves on to seed the incremental skip-cache flush: each
2748        // completed group's records fold onto it and rewrite the cache as the
2749        // pull progresses.
2750        let run = self
2751            .pull_plain(
2752                &refs,
2753                out_root,
2754                overwrite,
2755                &index,
2756                &fetch_plan,
2757                &disk,
2758                saved,
2759                interrupt,
2760            )
2761            .await;
2762
2763        // A donor entry's finalized staging blob is the source a recipient splices
2764        // from, so it is kept past its own group's cleanup. With the run over,
2765        // every registered donor source is safe to remove — EXCEPT one a
2766        // `mark_retained` flagged: its blob is paid for but a destination failed to
2767        // materialize, so the finalized `<hex>` is the resume prefix a rerun needs
2768        // (deleting it would force a full re-fetch and re-payment).
2769        //
2770        // Disk cost: a donor's first destination is a hard link to its staging
2771        // blob, so the two share storage until this sweep drops the staging name
2772        // (a copy only when the destination is on another filesystem).
2773        sweep_donor_sources(&index).await;
2774        run
2775    }
2776
2777    /// Fetch every distinct blob once (grouped by hash) and materialize it at each
2778    /// destination path (#1306), routing each group through [`Self::pull_entry`] so a
2779    /// blob whose chunk hints overlap an already-materialized sibling pays only for
2780    /// the complement. `PullCtx.gate` caps the groups at work at `--jobs`; a
2781    /// range-dedup entry waiting for donors holds no slot, so a queued group
2782    /// starts in its place.
2783    // The run-wide context (index, fetch plan, disk pre-pass, flush cache) all
2784    // threads through here; bundling it into a struct would only rename the fields.
2785    #[allow(clippy::too_many_arguments)]
2786    async fn pull_plain<'e>(
2787        &self,
2788        entries: &[&'e ManifestEntry],
2789        out_root: &Path,
2790        overwrite: bool,
2791        index: &ChunkIndex,
2792        fetch_plan: &FetchPlan,
2793        disk: &DiskState,
2794        saved: SavedManifest,
2795        interrupt: &mut Interrupt,
2796    ) -> PullRun {
2797        let groups_by_hash = order_groups_smallest_first(group_by_hash(entries));
2798        let group_count = groups_by_hash.len().max(1);
2799        // Completed groups' skip-cache records flow to the flush task over this
2800        // channel. Unbounded so a `send` from the fetch-driving side never blocks
2801        // (the flush write must never stall a fetch); the messages are one small
2802        // batch per completed group, so the channel stays shallow.
2803        let (flush_tx, flush_rx) = tokio::sync::mpsc::unbounded_channel::<FlushBatch>();
2804
2805        // The fetch-driving side: run every group through `fetch_group` once,
2806        // every one in flight and each waiting there for its `--jobs` slot. As
2807        // each run completes, forward its recordable files (built here while
2808        // the group's entries are in scope) to the flush task and keep its
2809        // outcomes for the run summary. A fault that ends the whole pull
2810        // ([`ends_the_pull`]) drops every group still in flight, so none that
2811        // waits for a slot starts.
2812        let mut groups: Vec<SettledGroup> = vec![SettledGroup::default(); groups_by_hash.len()];
2813        let mut stopped: Option<anyhow::Error> = None;
2814        let drive = async {
2815            let run = |group: HashGroup<'e>| {
2816                // Cheap clone of the group's entry refs so `fetch_group` can consume
2817                // `group` while we still correlate outcomes to entries.
2818                let group_entries: Vec<&'e ManifestEntry> = group.entries.clone();
2819                async move {
2820                    let run = self
2821                        .fetch_group(group, out_root, overwrite, index, fetch_plan, disk)
2822                        .await;
2823                    // Mark this group's blob finished (whatever the outcome) so a
2824                    // tail-reconcile waiter deferring a chunk assigned to it stops
2825                    // waiting and pays for the range itself if the group failed to
2826                    // register that chunk.
2827                    if let Some(en) = group_entries.first()
2828                        && let Ok(whole) = fetch::parse_hash(&en.hash)
2829                    {
2830                        index.mark_finished(whole);
2831                    }
2832                    // `fetch_group` returns one outcome per entry, in order;
2833                    // `build_completed_updates` relies on that to correlate a path
2834                    // to its outcome by position. Assert the invariant so a future
2835                    // divergence is caught in debug builds rather than silently
2836                    // truncating the zip.
2837                    debug_assert_eq!(
2838                        group_entries.len(),
2839                        run.outcomes.len(),
2840                        "fetch_group must return one outcome per entry"
2841                    );
2842                    let updates = build_completed_updates(&group_entries, &run.outcomes, out_root);
2843                    let warnings = size_warnings(&group_entries, &updates);
2844                    (run, updates, warnings)
2845                }
2846            };
2847            stopped = run_groups(
2848                groups_by_hash,
2849                // Every group in flight at once: each waits for its slot of the
2850                // global gate, the one `--jobs` cap, in start order.
2851                group_count,
2852                run,
2853                |i, (run, updates, warnings)| {
2854                    settle_group_run(&mut groups, &flush_tx, i, run, updates, warnings)
2855                },
2856            )
2857            .await;
2858            // Closing the channel tells the flush task to do its final write.
2859            drop(flush_tx);
2860        };
2861        // The flush task: fold each batch onto the prior skip-cache and rewrite it
2862        // atomically at the cadence, plus a final write when the channel closes.
2863        // Running here (joined, not awaited inside `drive`) keeps the blocking
2864        // write off the fetch-driving path — `join!` keeps polling `drive` while
2865        // this side awaits its write.
2866        let flush = flush_task(out_root, saved, flush_rx);
2867
2868        let interrupted = drive_until_interrupted(drive, flush, interrupt).await;
2869        // Byte tally is per-group (a blob pulled once, materialized to N paths),
2870        // so sum it before flattening away the group boundaries.
2871        let transfer = groups
2872            .iter()
2873            .map(|g| group_transfer(&g.outcomes, g.bytes))
2874            .fold(Transfer::default(), Transfer::add);
2875        let warnings = groups
2876            .iter_mut()
2877            .flat_map(|g| std::mem::take(&mut g.warnings))
2878            .collect();
2879        let outcomes = groups.into_iter().flat_map(|g| g.outcomes).collect();
2880        PullRun {
2881            outcomes,
2882            warnings,
2883            transfer,
2884            interrupted,
2885            stopped,
2886        }
2887    }
2888
2889    /// Run [`Self::pull_entry_untimed`] and, when the entry lands, log one `-v`
2890    /// line with its size, the time the whole entry took, and its download rate
2891    /// (#2120, #2189). Returns the entry's [`EntryBytes`] (see
2892    /// [`Self::pull_entry_untimed`]).
2893    #[allow(clippy::too_many_arguments)]
2894    async fn pull_entry(
2895        &self,
2896        hash: [u8; 32],
2897        hints: Option<&[Hint]>,
2898        total: Option<u64>,
2899        staging: &Path,
2900        index: &ChunkIndex,
2901        fetch_plan: &FetchPlan,
2902        file: Option<&pull_progress::FileBar>,
2903        slot: &FetchSlot<'_>,
2904    ) -> anyhow::Result<EntryBytes> {
2905        let started = std::time::Instant::now();
2906        self.pull_entry_untimed(hash, hints, total, staging, index, fetch_plan, file, slot)
2907            .await
2908            .inspect(|&bytes| log_entry_done(hash, staging, bytes, started.elapsed()))
2909    }
2910
2911    /// Reconstruct one entry's blob into `staging` (the finalized per-hash staging
2912    /// file [`Self::fetch_group`] then materializes to each destination), using chunk
2913    /// hints to dedup byte ranges against the run's [`ChunkIndex`] when they help.
2914    ///
2915    /// - No hints (or `total` unknown, or the blob is already finalized at
2916    ///   `staging`), or no chunk overlaps a materialized sibling → the plain
2917    ///   whole-file [`Self::fetch_to_staging`] path.
2918    /// - Otherwise the dedup path: pay through an [`AcquireRangeDriver`] for only
2919    ///   the *complement* (the group-aligned bytes no donor covers) into `staging`'s
2920    ///   `.partial`, then splice each donor range from its sibling's on-disk blob.
2921    ///   Each donor chunk is confirmed present at the recorded source offset by
2922    ///   re-hashing the whole chunk against its hint hash before its bytes are
2923    ///   trusted; a chunk that fails (a lying donor hint, a short read) is added to
2924    ///   a re-fetch list and driven normally. The reassembled `.partial` is then
2925    ///   verified whole against the authoritative `hash`; on a match it is promoted
2926    ///   to `staging`, and on a mismatch (a lying *recipient* hint mis-placed a
2927    ///   chunk) the whole blob is re-driven and re-verified before the entry fails.
2928    ///
2929    /// On success the entry's chunks are registered into `index` so later entries
2930    /// can splice from this blob, and its [`EntryBytes`] are returned. Before
2931    /// either path drives anything, the entry reads the bytes its ranged store
2932    /// already holds from an earlier, interrupted run ([`resumed_spans`]); those
2933    /// are resumed, not paid. The paid count is the whole blob less its resumed
2934    /// bytes on the plain path, the blob less its spliced and resumed bytes on the
2935    /// dedup path, and 0 for an already-finalized staging blob. It is a content
2936    /// count: bao proof overhead is not in it.
2937    #[allow(clippy::too_many_arguments)]
2938    async fn pull_entry_untimed(
2939        &self,
2940        hash: [u8; 32],
2941        hints: Option<&[Hint]>,
2942        total: Option<u64>,
2943        staging: &Path,
2944        index: &ChunkIndex,
2945        fetch_plan: &FetchPlan,
2946        file: Option<&pull_progress::FileBar>,
2947        slot: &FetchSlot<'_>,
2948    ) -> anyhow::Result<EntryBytes> {
2949        // The byte-delivery callback drives the file bar's download and the total
2950        // bar's download meter; the `file` handle also carries the phase transitions
2951        // (discovering / pending / reconstructing) the byte callback cannot express.
2952        let progress = file.and_then(|f| f.callback());
2953        // An already-finalized `<hex>` staging blob — a prior run promoted it, or
2954        // this run finalized it and crashed in the promote-to-materialize window —
2955        // is the complete blob, BLAKE3-verified when it was promoted. Materialize
2956        // straight from it: `fetch_group` copies `staging` to each destination, so
2957        // re-hash it once as a cheap guard and return. It must NEVER be re-driven:
2958        // the ranged store keys resume on the `.ranges` sidecar, which promotion
2959        // deletes, so `open_or_create` on a sidecar-less `<hex>` would `create`
2960        // (truncate) it and re-pay for the whole blob. On a mismatch (a corrupt
2961        // leftover) drop it and fall through to a normal fetch.
2962        if staging.try_exists().unwrap_or(false) {
2963            let staging_buf = staging.to_path_buf();
2964            let verified = tokio::task::spawn_blocking(move || {
2965                hash_partial(&staging_buf).is_ok_and(|got| got == hash)
2966            })
2967            .await
2968            .map_err(|e| anyhow!("staging verify task: {e}"))?;
2969            if verified {
2970                if let (Some(cb), Some(total)) = (progress, total) {
2971                    cb(total, total);
2972                }
2973                index.register(hints, staging);
2974                return Ok(EntryBytes::default());
2975            }
2976            remove_staging_off_runtime(staging).await;
2977        }
2978
2979        // What earlier runs already fetched into this entry's `.partial`: read now,
2980        // before any drive of this run adds to it. `staging` is absent here, so
2981        // the read opens the record alone and never hashes a final file.
2982        let resumed = resumed_spans_off_runtime(staging, hash).await;
2983
2984        // Until the first byte lands, the entry is discovering holders — surfaced so
2985        // a slow or stalling probe (a blob no probed node answers for) does not look
2986        // like a frozen empty bar. The delivery callback switches the row to its
2987        // download counts on the first chunk.
2988        if let Some(f) = file {
2989            f.set_discovering();
2990        }
2991
2992        // Plan the reassembly only when there is something to dedup against: hints
2993        // and a known total. The finalized-staging fast path above already
2994        // returned, so `staging` does not exist here. A chunk already materialized
2995        // (a pre-seeded on-disk donor, or a sibling that already finished) becomes a
2996        // splice `donor`; a chunk this run assigns to another entry becomes
2997        // `deferred` (waited for at the tail); the rest is driven. When neither a
2998        // donor nor a deferral applies, there is nothing to dedup — fall through to
2999        // the plain whole-file fetch.
3000        let plan = match (hints, total) {
3001            (Some(hints), Some(total)) if !hints.is_empty() => {
3002                let guard = index.map.lock().unwrap_or_else(PoisonError::into_inner);
3003                let plan = plan_reassembly(hints, &guard, fetch_plan, hash, total);
3004                drop(guard);
3005                (!plan.donor.is_empty() || !plan.deferred.is_empty()).then_some((plan, total))
3006            }
3007            _ => None,
3008        };
3009
3010        let Some((plan, total)) = plan else {
3011            // No donor overlap and nothing deferred — pay for the whole file, then
3012            // register its chunks so a *later* entry can dedup against it. The
3013            // manifest's `size`, when it gives one, is the entry's first size claim,
3014            // a hint; the paid count is the verified blob's length.
3015            let refetched = self
3016                .fetch_to_staging(hash, staging, total, progress, slot)
3017                .await?;
3018            index.register(hints, staging);
3019            let len = tokio::fs::metadata(staging).await.map_or(0, |m| m.len());
3020            let resumed = if refetched { 0 } else { span_bytes(&resumed) };
3021            return Ok(EntryBytes::whole_blob(len, resumed));
3022        };
3023
3024        // Dedup path. The group's `slot` covers every drive below; the entry
3025        // gives it back only while it waits for a sibling's donor chunks
3026        // ([`RangeDriver::release_slot`]), and each drive takes it again.
3027        slot.ensure().await?;
3028
3029        // Resolve the entry's holders ONCE and reuse them across every drive
3030        // below (complement, donor re-fetch, whole-blob re-drive), so the entry
3031        // probes exactly once.
3032        let targets = self.entry_targets(hash).await?;
3033        let driver = AcquireRangeDriver {
3034            ctx: self,
3035            targets: &targets,
3036            hash,
3037            staging,
3038            total,
3039            progress,
3040            refetched: AtomicBool::new(false),
3041            slot,
3042        };
3043
3044        // On any dedup-path success, true up the file + total progress bars to
3045        // 100%: donor bytes are spliced from disk and never flow through `drive`'s
3046        // progress callback, so a mostly-spliced entry would otherwise leave its
3047        // bars short of the blob's full size. Both bars are monotonic, so a path
3048        // that already reached 100% (a whole-blob re-drive) is unaffected.
3049        let finish_progress = || {
3050            if let Some(cb) = progress {
3051                cb(total, total);
3052            }
3053        };
3054
3055        let outcome = reassemble_dedup(
3056            &driver,
3057            &plan,
3058            total,
3059            &resumed,
3060            hints,
3061            index,
3062            fetch_plan,
3063            file,
3064            &finish_progress,
3065        )
3066        .await?
3067        .refetched_if(driver.refetched.load(Ordering::Relaxed));
3068        self.dedup_stats
3069            .spliced_bytes
3070            .fetch_add(outcome.spliced_bytes, Ordering::Relaxed);
3071        self.dedup_stats
3072            .hints_ignored
3073            .fetch_add(outcome.hints_ignored, Ordering::Relaxed);
3074        Ok(EntryBytes::dedup(total, outcome))
3075    }
3076
3077    /// Fetch the blob shared by one hash-group and write it under `out_root` at
3078    /// each entry's path. The blob is fetched — and paid for — **once**; the first
3079    /// writable destination receives the materialized bytes and every other is a
3080    /// hard link (or copy) of it (#1306). Returns one [`EntryOutcome`] per input
3081    /// entry, in order. Never panics or short-circuits — every failure becomes an
3082    /// [`EntryOutcome::Failed`], and the [`GroupRun`] carries a fetch fault that
3083    /// ends the whole pull ([`ends_the_pull`]).
3084    async fn fetch_group(
3085        &self,
3086        group: HashGroup<'_>,
3087        out_root: &Path,
3088        overwrite: bool,
3089        index: &ChunkIndex,
3090        fetch_plan: &FetchPlan,
3091        disk: &DiskState,
3092    ) -> GroupRun {
3093        // The group's shared hash is carried explicitly; parse it once, and a bad
3094        // hash fails every path in the group. Then wait for the group's `--jobs`
3095        // slot: every group of the run is in flight at once, and this is the cap.
3096        let slot = match fetch::parse_hash(group.hash) {
3097            Ok(h) => FetchSlot::acquire(&self.gate).await.map(|slot| (h, slot)),
3098            Err(e) => Err(e),
3099        };
3100        let (hash, slot) = match slot {
3101            Ok(ok) => ok,
3102            Err(e) => {
3103                return GroupRun::done(
3104                    group
3105                        .entries
3106                        .iter()
3107                        .map(|en| EntryOutcome::failed(&en.path, &e))
3108                        .collect(),
3109                );
3110            }
3111        };
3112
3113        // Every entry in the group shares this blob (same whole-file hash), so its
3114        // chunk decomposition is identical; take the first entry's hints. `None`
3115        // (no `chunks`, an unparseable chunk hash, or chunk sizes that do not sum
3116        // to the whole-file size) drops back to a plain whole-file fetch.
3117        let first = group.entries.first();
3118        let hints = first.and_then(|e| hints_of(e));
3119        // A first entry that carries `chunks` but yields no usable hints had its
3120        // hint set dropped by a fault — an unparseable chunk hash, or chunk sizes
3121        // that do not sum to the whole-file size. Report the dropped hints so the
3122        // outcome is not silent; the whole-file `hash` still fetches the blob.
3123        if let Some(e) = first
3124            && hints.is_none()
3125            && let Some(chunks) = e.chunks.as_ref()
3126        {
3127            self.dedup_stats.hints_ignored.fetch_add(
3128                u64::try_from(chunks.len()).unwrap_or(u64::MAX),
3129                Ordering::Relaxed,
3130            );
3131        }
3132        let total = group.entries.iter().find_map(|e| e.size);
3133        // An entry that registers chunks can serve as a donor, so its finalized
3134        // staging blob must outlive this group's cleanup — the run-end sweep in
3135        // `pull_all` removes it once every recipient has had its chance.
3136        let is_donor = hints.as_ref().is_some_and(|h| !h.is_empty());
3137
3138        let slots = plan_slots(&group.entries, out_root, overwrite, &disk.skip);
3139
3140        // Every destination already present (or failed to resolve) → no fetch, no
3141        // payment. This is the whole point of the group: a duplicate path that is
3142        // already on disk costs nothing. It downloads nothing, so it contributes
3143        // nothing to the total bar's download meter — no credit, no bar.
3144        if !slots.iter().any(|s| matches!(s, Slot::Write { .. })) {
3145            return GroupRun::done(
3146                slots
3147                    .into_iter()
3148                    .map(|s| match s {
3149                        Slot::Failed(o) => o,
3150                        _ => EntryOutcome::Skipped,
3151                    })
3152                    .collect(),
3153            );
3154        }
3155
3156        // Whole-file dedup: an identical file already sits somewhere in the root
3157        // (recorded by a prior bundle). Verify that candidate by re-hashing it,
3158        // then materialize every destination by link/copy — no fetch, no payment.
3159        // A failed re-hash (stale/edited/removed donor) falls through to the
3160        // normal fetch path below; `slots` is untouched until this decision is
3161        // made, so falling through loses nothing. `materialize_group` (inside
3162        // `materialize_from_donor`) already passes a `Slot::Failed` through as its
3163        // own failure and a `Slot::Skip` through as `Skipped`, so handing it the
3164        // whole `slots` vector — not just the `Write` entries — is correct.
3165        if let Some(donor) = disk.whole_file.get(&hash)
3166            && let Some(len) = verified_donor_len(donor, hash).await
3167        {
3168            // A whole-file link downloads nothing, so it adds nothing to the
3169            // total bar's download meter.
3170            return GroupRun::done(materialize_from_donor(slots, donor, len).await);
3171        }
3172
3173        // Staged once per group: the entry's fetch finalizes to
3174        // `out_root/.decdn-partial/<hex>` (#1497), streaming its `<hex>.partial`
3175        // + sidecars there — rather than buffering the blob — which is what lets a
3176        // large entry top up mid-fetch. The staging path derived from `out_root`
3177        // is only created once a group actually has something to write, never for
3178        // an all-skipped group.
3179        let staging = match staging_path(out_root, hash) {
3180            Ok(p) => p,
3181            Err(e) => return GroupRun::done(fail_all(slots, &e)),
3182        };
3183
3184        // One per-file bar for this group's single pull, labeled by its destination
3185        // path(s) — a fetch-once hash group shows one bar for every path it lands at.
3186        // The bar meters the blob's download (pay-now) bytes and shows its
3187        // reconstruct (spliced-from-disk) bytes in parentheses; both come from the
3188        // static plan against the run's fetch plan (an empty donor index — the
3189        // fresh-pull split), matching the header's download total.
3190        let paths: Vec<String> = group.entries.iter().map(|e| e.path.clone()).collect();
3191        let (download_bytes_of_blob, reconstruct_bytes_of_blob) =
3192            blob_download_reconstruct(&group, fetch_plan, &HashMap::new());
3193        let file_bar = self.progress.file_bar(
3194            &pull_progress::file_label(&paths),
3195            download_bytes_of_blob,
3196            reconstruct_bytes_of_blob,
3197        );
3198        let fetched = self
3199            .pull_entry(
3200                hash,
3201                hints.as_deref(),
3202                total,
3203                &staging,
3204                index,
3205                fetch_plan,
3206                Some(&file_bar),
3207                &slot,
3208            )
3209            .await;
3210        file_bar.finish();
3211        let bytes = match fetched {
3212            Ok(bytes) => bytes,
3213            Err(e) => {
3214                // `pull_entry` (whole-file or dedup) leaves the `<hex>.partial` +
3215                // `.ranges` record in place on error: they are what the
3216                // next run resumes from rather than re-paying for bytes already landed
3217                // (same contract as `fetch`'s `<output>.partial` store).
3218                return GroupRun::fetch_failed(slots, e);
3219            }
3220        };
3221
3222        // A failed first write leaves `staging` in place, so the next writable
3223        // path retries from it. Off the runtime: a multi-GB copy on this task
3224        // would stall every other group's streams.
3225        let outcomes = materialize_group(
3226            slots,
3227            |dest| {
3228                let staging = staging.clone();
3229                off_runtime("materialize", move || {
3230                    first_write(&staging, &dest, is_donor)
3231                })
3232            },
3233            link_off_runtime,
3234        )
3235        .await;
3236
3237        // Remove the paid staging blob (and its sidecars) ONLY when every
3238        // destination landed. If any materialize failed (unwritable dir, ENOSPC, a
3239        // link failure), keep it: the blob is fully fetched and paid for, and
3240        // `pull_entry` already finalized `<hex>` — a rerun sees the finalized
3241        // staging file and re-pulls only the still-missing ranges (typically
3242        // none), never the whole blob. Deleting staging here would force a full
3243        // re-fetch — and re-payment — of an unrefunded blob in *every* case;
3244        // `fetch`'s single-blob path gets this free from its own ranged store, so
3245        // the copy-based fan-out must gate it. A blob that moved into its first
3246        // destination leaves no staging blob behind; that destination is recorded
3247        // for the skip cache, so a rerun reuses it as a whole-file donor.
3248        //
3249        // A donor blob (`is_donor`) is a splice source a later entry may still
3250        // read, so it is kept here regardless and swept once at run end by
3251        // `pull_all`.
3252        let any_failed = outcomes
3253            .iter()
3254            .any(|o| matches!(o, EntryOutcome::Failed { .. }));
3255        if !is_donor && !any_failed {
3256            // A non-donor whose every destination landed: its content is safely on
3257            // disk (the blob itself usually moved there), so drop what is left of
3258            // staging now. A failed non-donor keeps its `.partial` resume prefix; a
3259            // donor is kept for splicing and swept at run end.
3260            remove_staging_off_runtime(&staging).await;
3261        } else if is_donor && any_failed {
3262            // A donor whose blob is fully fetched and paid for but whose
3263            // materialize failed: retain its finalized `<hex>` from the run-end
3264            // sweep so a rerun resumes from it instead of re-paying the whole blob.
3265            index.mark_retained(&staging);
3266        }
3267
3268        GroupRun::landed(outcomes, bytes)
3269    }
3270}
3271
3272/// The content bytes one landed entry paid for, spliced from disk, and resumed
3273/// from an earlier run, as [`PullCtx::pull_entry_untimed`] counts them. No byte
3274/// counts in two of them.
3275#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
3276struct EntryBytes {
3277    /// Content bytes this run fetched and paid for (see
3278    /// [`PullCtx::pull_entry_untimed`]).
3279    paid: u64,
3280    /// Bytes the entry spliced from a local donor on disk rather than
3281    /// downloading them itself.
3282    spliced: u64,
3283    /// Bytes an earlier, interrupted run fetched into the entry's `.partial`,
3284    /// which this run resumed rather than fetched again.
3285    resumed: u64,
3286}
3287
3288impl EntryBytes {
3289    /// A whole-blob fetch that landed a `len`-byte blob into a store that
3290    /// already held `resumed` bytes of it.
3291    fn whole_blob(len: u64, resumed: u64) -> Self {
3292        let resumed = resumed.min(len);
3293        Self {
3294            paid: len.saturating_sub(resumed),
3295            spliced: 0,
3296            resumed,
3297        }
3298    }
3299
3300    /// A range-dedup entry of `total` bytes with `outcome`: every byte it did
3301    /// not splice or resume, this run paid for.
3302    const fn dedup(total: u64, outcome: DedupOutcome) -> Self {
3303        Self {
3304            paid: total
3305                .saturating_sub(outcome.spliced_bytes)
3306                .saturating_sub(outcome.resumed_bytes),
3307            spliced: outcome.spliced_bytes,
3308            resumed: outcome.resumed_bytes,
3309        }
3310    }
3311}
3312
3313/// The byte spans of `staging`'s ranged store that earlier runs already
3314/// fetched: the present set of its `.partial.ranges` record, as sorted,
3315/// disjoint `(offset, len)` pairs. The store marks only fetched bytes present,
3316/// never the donor bytes a splice writes, so these spans are bytes an earlier
3317/// run paid for. No record (a fresh entry) gives no spans.
3318///
3319/// The caller reads this before it drives anything, with `staging` absent, so
3320/// the read opens the record alone and never hashes a final file. The spans
3321/// are for reporting only: a record it cannot read logs at `debug` and gives
3322/// no spans, and the fetch that follows surfaces the fault.
3323fn resumed_spans(staging: &Path, hash: [u8; 32]) -> Vec<(u64, u64)> {
3324    let read = || -> anyhow::Result<Vec<(u64, u64)>> {
3325        let (dir, stem) = fetch::ranged_store_location(staging)?;
3326        if !ClientRangedStore::has_record(&dir, &stem)? {
3327            return Ok(Vec::new());
3328        }
3329        Ok(ClientRangedStore::open(&dir, &stem, hash)?.present_byte_ranges())
3330    };
3331    read().unwrap_or_else(|e| {
3332        tracing::debug!(
3333            "could not read the resume record beside {}: {e:#}",
3334            staging.display()
3335        );
3336        Vec::new()
3337    })
3338}
3339
3340/// [`resumed_spans`] on the blocking pool. A failed join gives no spans.
3341async fn resumed_spans_off_runtime(staging: &Path, hash: [u8; 32]) -> Vec<(u64, u64)> {
3342    let staging = staging.to_path_buf();
3343    tokio::task::spawn_blocking(move || resumed_spans(&staging, hash))
3344        .await
3345        .unwrap_or_else(|e| {
3346            tracing::debug!("resume record task: {e}");
3347            Vec::new()
3348        })
3349}
3350
3351/// The total length of `(offset, len)` spans, saturating. The caller passes
3352/// disjoint spans, so no byte counts twice.
3353fn span_bytes(spans: &[(u64, u64)]) -> u64 {
3354    spans
3355        .iter()
3356        .fold(0u64, |acc, &(_, len)| acc.saturating_add(len))
3357}
3358
3359/// Log a finished entry's line at `-v` (#2120), so a run's slow entries can be
3360/// told from its fast ones. See [`entry_done_line`]. A staging file it cannot
3361/// stat logs its size as 0, after a debug line naming the path and the error.
3362fn log_entry_done(hash: [u8; 32], staging: &Path, bytes: EntryBytes, elapsed: std::time::Duration) {
3363    let size = std::fs::metadata(staging).map_or_else(
3364        |e| {
3365            tracing::debug!(
3366                "could not stat {} for its entry line: {e}",
3367                staging.display()
3368            );
3369            0
3370        },
3371        |m| m.len(),
3372    );
3373    tracing::info!("{}", entry_done_line(hash, size, bytes, elapsed));
3374}
3375
3376/// A finished entry's size, the time it took, and its rate over the bytes this
3377/// run paid for (see [`PullCtx::pull_entry_untimed`]). When the entry resumed
3378/// bytes from an earlier run or spliced bytes from disk, the line reports each
3379/// non-zero one apart from the paid bytes (#2189, #2236). The time covers the
3380/// whole entry: probing, every drive, any splice and the whole-file check.
3381fn entry_done_line(
3382    hash: [u8; 32],
3383    size: u64,
3384    bytes: EntryBytes,
3385    elapsed: std::time::Duration,
3386) -> String {
3387    let secs = elapsed.as_secs_f64();
3388    let rate = if secs > 0.0 {
3389        fetch::fmt_rate(fetch::bytes_as_f64(bytes.paid) / secs)
3390    } else {
3391        fetch::fmt_rate(0.0)
3392    };
3393    let detail = if bytes.spliced == 0 && bytes.resumed == 0 {
3394        rate
3395    } else {
3396        let mut parts = vec![format!(
3397            "{} downloaded at {rate}",
3398            indicatif::HumanBytes(bytes.paid)
3399        )];
3400        if bytes.resumed > 0 {
3401            parts.push(format!("{} resumed", indicatif::HumanBytes(bytes.resumed)));
3402        }
3403        if bytes.spliced > 0 {
3404            parts.push(format!(
3405                "{} spliced from disk",
3406                indicatif::HumanBytes(bytes.spliced)
3407            ));
3408        }
3409        parts.join(", ")
3410    };
3411    format!(
3412        "bundle pull: {}: {} in {secs:.1}s ({detail})",
3413        blake3::Hash::from_bytes(hash).to_hex(),
3414        indicatif::HumanBytes(size),
3415    )
3416}
3417
3418/// The range-drive step [`reassemble_dedup`] performs against one entry: drive
3419/// exactly `ranges` into the entry's `.partial` (bao-verified against the
3420/// whole-file hash), or let the ranged store finalize `staging` when they complete
3421/// it. Abstracted from the reassembly control flow so that flow — which must treat
3422/// a drive that finalized the blob as done, rather than open a now-renamed
3423/// `.partial` — is unit-testable without a live endpoint or pool.
3424trait RangeDriver {
3425    /// The entry's authoritative whole-file BLAKE3 hash.
3426    fn hash(&self) -> [u8; 32];
3427
3428    /// The entry's finalized staging path (`<out_root>/.decdn-partial/<hex>`); its
3429    /// `.partial` is what the splice writes into.
3430    fn staging(&self) -> &Path;
3431
3432    /// Drive `ranges` for the entry. On return the entry's `staging` is either
3433    /// finalized (the store completed and renamed `<hex>.partial` -> `<hex>`) or
3434    /// still a `.partial` the caller splices into.
3435    fn drive<'a>(
3436        &'a self,
3437        ranges: &'a [(u64, u64)],
3438    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + 'a>>;
3439
3440    /// Give back the entry's `--jobs` slot while it only waits for a sibling's
3441    /// donor chunks. A driver that holds no slot keeps the default, which does
3442    /// nothing.
3443    fn release_slot(&self) {}
3444
3445    /// Take the entry's `--jobs` slot again as a donor wait ends
3446    /// ([`Self::release_slot`]). The default does nothing.
3447    fn retake_slot(
3448        &self,
3449    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + '_>> {
3450        Box::pin(async { Ok(()) })
3451    }
3452}
3453
3454/// The production [`RangeDriver`]: each drive fills its ranges through the
3455/// acquire loop ([`PullCtx::acquire_entry`]) across the entry's holders, which
3456/// the entry resolved once.
3457///
3458/// Every drive of the entry (the pay-now complement, a donor re-fetch, each
3459/// deferred fallback, the self-heal re-drive) runs the same loop as a
3460/// whole-file entry: it stripes the ranges across the holders, a holder that
3461/// faults cools in the command-wide [`PeerHealth`] and returns, and the drive
3462/// ends on done, a fault only the user can fix, a unanimous verdict of the
3463/// holders, or the command's [`StopPolicy`]. The manifest's size is the
3464/// drive's first claim, a hint: a leg that verifies the final chunk proves
3465/// the true size. The spliced donor bytes are never marked present in the
3466/// store, so a drive promotes the entry only once fetched bytes fill the
3467/// whole store: a resumed store that already held the rest, or the self-heal
3468/// re-drive of the whole blob.
3469struct AcquireRangeDriver<'a, 'g, P: Provider + Clone> {
3470    ctx: &'a PullCtx<'a, P>,
3471    targets: &'a fetch::ResolvedTargets,
3472    hash: [u8; 32],
3473    staging: &'a Path,
3474    /// The manifest's size: the entry's first size claim.
3475    total: u64,
3476    progress: Option<&'a ProgressCallback>,
3477    /// Set once a drive's finalize failed its hash and the drive fetched the
3478    /// whole blob again ([`PullCtx::acquire_entry`]).
3479    refetched: AtomicBool,
3480    /// The entry's `--jobs` slot: held for each drive, given back while the
3481    /// entry waits for donors.
3482    slot: &'a FetchSlot<'g>,
3483}
3484
3485impl<P: Provider + Clone> RangeDriver for AcquireRangeDriver<'_, '_, P> {
3486    fn hash(&self) -> [u8; 32] {
3487        self.hash
3488    }
3489
3490    fn staging(&self) -> &Path {
3491        self.staging
3492    }
3493
3494    fn drive<'a>(
3495        &'a self,
3496        ranges: &'a [(u64, u64)],
3497    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + 'a>> {
3498        Box::pin(async move {
3499            self.slot.ensure().await?;
3500            let refetched = self
3501                .ctx
3502                .acquire_entry(
3503                    self.targets,
3504                    self.hash,
3505                    self.staging,
3506                    Some(self.total),
3507                    Some(ranges),
3508                    self.progress,
3509                )
3510                .await?;
3511            if refetched {
3512                self.refetched.store(true, Ordering::Relaxed);
3513            }
3514            Ok(())
3515        })
3516    }
3517
3518    fn release_slot(&self) {
3519        self.slot.release();
3520    }
3521
3522    fn retake_slot(
3523        &self,
3524    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + '_>> {
3525        Box::pin(self.slot.ensure())
3526    }
3527}
3528
3529/// Ensure `staging`'s ranged store exists with its `.partial` at the whole-file
3530/// size, so a splice can seek and write into it.
3531///
3532/// A drive normally creates the store; an entry with nothing to drive (every
3533/// chunk is a donor or deferred) has no drive, so this creates it. It always
3534/// makes a real store with its `.ranges` record, never a bare `.partial`: a
3535/// later drive into the same entry (a donor re-fetch, a deferred fallback, the
3536/// self-heal re-drive) calls `open_or_create`, which keys resume on the
3537/// `.ranges` record and truncates a `.partial` that lacks one, which wipes
3538/// every byte already spliced into it. A store that already has its record (a drive
3539/// made it, or a resumed run) is reopened as is. The data file is then extended
3540/// to `total`, sparsely, because the whole-file hash reads its full length.
3541///
3542/// It runs on the blocking pool: reopening a store can hash a final file.
3543async fn ensure_partial_off_runtime(
3544    staging: &Path,
3545    hash: [u8; 32],
3546    total: u64,
3547) -> anyhow::Result<PathBuf> {
3548    let staging = staging.to_path_buf();
3549    tokio::task::spawn_blocking(move || ensure_partial(&staging, hash, total))
3550        .await
3551        .map_err(|e| anyhow!("ensure partial task: {e}"))?
3552}
3553
3554/// [`ensure_partial_off_runtime`]'s body, on the blocking pool.
3555fn ensure_partial(staging: &Path, hash: [u8; 32], total: u64) -> anyhow::Result<PathBuf> {
3556    let (dir, stem) = fetch::ranged_store_location(staging)?;
3557    ClientRangedStore::open_or_create(&dir, &stem, hash, total)
3558        .with_context(|| format!("open the ranged store for {}", staging.display()))?;
3559    let partial = partial_path(staging);
3560    let f = std::fs::OpenOptions::new()
3561        .write(true)
3562        .open(&partial)
3563        .with_context(|| format!("open {}", partial.display()))?;
3564    if f.metadata()
3565        .with_context(|| format!("stat {}", partial.display()))?
3566        .len()
3567        < total
3568    {
3569        f.set_len(total)
3570            .with_context(|| format!("size {}", partial.display()))?;
3571    }
3572    Ok(partial)
3573}
3574
3575/// Reassemble one dedup entry's blob into `staging`: drive the pay-now ranges,
3576/// splice the donor ranges from disk, reconcile the deferred (sibling-assigned)
3577/// ranges at the tail, verify the whole-file BLAKE3, and promote — using `driver`
3578/// for every paid range drive.
3579///
3580/// The authoritative gate throughout is the whole-file BLAKE3; a bad or lying hint
3581/// only ever costs a re-download, never a corrupt output or a spuriously-failed
3582/// entry. After EVERY range drive (pay-now, a donor re-fetch, a deferred fallback,
3583/// and the whole-blob re-drive) the store may have completed — `drive` then
3584/// finalized and renamed `<hex>.partial` -> `<hex>`. Each such point checks
3585/// `staging.try_exists()` and returns success rather than opening a `.partial` that
3586/// no longer exists: a resumed run whose prior `.partial` already held every range
3587/// hits this on the very FIRST pay-now drive.
3588///
3589/// The deferred ranges are chunks this run assigned to a smaller sibling
3590/// ([`build_fetch_plan`]). They are NOT driven up front; the tail loop waits on the
3591/// [`ChunkIndex`] progress signal and splices each one as its assigned fetcher
3592/// registers it — so the shared bytes are fetched once, by that sibling, and copied
3593/// here from disk. The wait is never a fixed timeout (the deferred bytes are exactly
3594/// what the sibling is still downloading): a deferred range is driven-and-paid here
3595/// only if its assigned fetcher FINISHES without registering the chunk (a failed
3596/// fetcher), which keeps the run live and never worse than fetching it directly.
3597///
3598/// `finish_progress` trues the file + total bars up to 100% on success: donor bytes
3599/// are spliced from disk and never flow through `drive`'s progress callback, so a
3600/// mostly-spliced entry would otherwise leave its bars short.
3601///
3602/// `resumed` is the byte spans the entry's store held from an earlier run before
3603/// this run drove anything ([`resumed_spans`]).
3604///
3605/// Returns the entry's [`DedupOutcome`] — bytes actually spliced from disk, bytes
3606/// resumed from an earlier run, and hints dropped by a fault — for the run-level
3607/// report. A byte both resumed and spliced counts as spliced. A resume that
3608/// finalized on the first pay-now drive spliced nothing this run; a self-heal
3609/// whole-blob re-drive discards every splice, so it reports zero spliced bytes and
3610/// counts all its donors as ignored.
3611#[allow(clippy::too_many_arguments)]
3612async fn reassemble_dedup(
3613    driver: &dyn RangeDriver,
3614    plan: &ReassemblePlan,
3615    total: u64,
3616    resumed: &[(u64, u64)],
3617    hints: Option<&[Hint]>,
3618    index: &ChunkIndex,
3619    fetch_plan: &FetchPlan,
3620    file: Option<&pull_progress::FileBar>,
3621    finish_progress: &dyn Fn(),
3622) -> anyhow::Result<DedupOutcome> {
3623    let hash = driver.hash();
3624    let staging = driver.staging();
3625
3626    // Pay only for the pay-now ranges (this entry's unique + self-assigned chunks
3627    // and the partial groups at spliced-run ends); donors are spliced and deferred
3628    // ranges wait.
3629    // An entry with nothing to drive (every chunk is a donor or deferred) skips the
3630    // drive entirely and splices into a freshly-sized `.partial`.
3631    if !plan.drive.is_empty() {
3632        driver.drive(&plan.drive).await?;
3633        // A resumed run may already hold every range in `.partial`, so this first
3634        // drive can COMPLETE the store: `drive` then ran its whole-file hash
3635        // against `hash` and renamed `<hex>.partial` -> `<hex>`. The blob is
3636        // finalized and verified; splicing would open a `.partial` that no longer
3637        // exists. Register the donor chunks and return. No splice ran this run,
3638        // so every resumed byte counts as resumed.
3639        if staging.try_exists()? {
3640            finish_progress();
3641            index.register(hints, staging);
3642            return Ok(DedupOutcome::of(&[], resumed, total, 0));
3643        }
3644    }
3645
3646    let partial = ensure_partial_off_runtime(staging, hash, total).await?;
3647
3648    // Verify + splice each initially-available donor range off the executor. A donor
3649    // whose chunk no longer hashes to its hint (a lying donor hint, or a short read)
3650    // is re-fetched normally rather than trusted.
3651    // Each re-fetched donor is a dropped hint.
3652    let mut ledger = SpliceLedger::default();
3653    let refetch = splice_off_runtime(&partial, plan.donor.clone(), &mut ledger).await?;
3654    let mut hints_ignored = u64::try_from(refetch.len()).unwrap_or(u64::MAX);
3655    if !refetch.is_empty() {
3656        driver.drive(&refetch).await?;
3657        ledger.driven.extend_from_slice(&refetch);
3658        if staging.try_exists()? {
3659            finish_progress();
3660            index.register(hints, staging);
3661            return Ok(DedupOutcome::of(
3662                &ledger.spliced_spans(total),
3663                resumed,
3664                total,
3665                hints_ignored,
3666            ));
3667        }
3668    }
3669
3670    // Tail reconcile: splice each deferred (sibling-assigned) range as its fetcher
3671    // registers it, waiting on the index's progress signal. A deferred chunk whose
3672    // assigned fetcher finishes WITHOUT producing it (a failed fetcher) is driven
3673    // and paid here instead, so the run never hangs.
3674    let tail_ignored = reconcile_deferred(
3675        driver,
3676        &partial,
3677        &plan.deferred,
3678        index,
3679        fetch_plan,
3680        file,
3681        &mut ledger,
3682    )
3683    .await?;
3684    hints_ignored = hints_ignored.saturating_add(tail_ignored);
3685    let mut spliced = ledger.spliced_spans(total);
3686    if staging.try_exists()? {
3687        finish_progress();
3688        index.register(hints, staging);
3689        return Ok(DedupOutcome::of(&spliced, resumed, total, hints_ignored));
3690    }
3691
3692    // The authoritative check: the whole reassembled blob must hash to `hash`. This
3693    // hash of the full blob is the slow tail of a mostly-spliced entry, so it drives
3694    // the file row's `reconstructing…` bar with its running byte count — otherwise
3695    // the row would sit frozen while a multi-GB blob verifies.
3696    let reporter = file.and_then(pull_progress::FileBar::reconstruct_reporter);
3697    let partial_for_hash = partial.clone();
3698    let got = tokio::task::spawn_blocking(move || {
3699        hash_partial_with_progress(&partial_for_hash, reporter.as_deref())
3700    })
3701    .await
3702    .map_err(|e| anyhow!("whole-file hash task: {e}"))??;
3703    if got != hash {
3704        // A lying recipient hint placed a chunk at the wrong offset. Drop the
3705        // spliced ranges by re-driving the whole blob (the ranged store fetches
3706        // exactly the bytes the splice wrote, bao-verified against `hash`) and
3707        // re-verify. Every donor is discarded, so nothing was saved and all of them
3708        // count as ignored. The resumed bytes stay present in the store, so the
3709        // re-drive does not fetch them again.
3710        spliced = Vec::new();
3711        hints_ignored =
3712            u64::try_from(plan.donor.len().saturating_add(plan.deferred.len())).unwrap_or(u64::MAX);
3713        tracing::warn!(
3714            "bundle pull: entry {} failed its whole-file hash after range-dedup; \
3715             re-fetching the whole blob",
3716            blake3::Hash::from_bytes(hash).to_hex()
3717        );
3718        driver.drive(&[(0, total)]).await?;
3719        // The whole-blob re-drive covers `[0, total)`, so `drive` finalized it: its
3720        // whole-file hash verified the bytes against `hash` and renamed `.partial` ->
3721        // `staging`. The blob is verified — do not re-hash a `.partial` that no
3722        // longer exists; register the donor chunks and return.
3723        if staging.try_exists()? {
3724            finish_progress();
3725            index.register(hints, staging);
3726            return Ok(DedupOutcome::of(&spliced, resumed, total, hints_ignored));
3727        }
3728        let partial_for_hash = partial.clone();
3729        let got = tokio::task::spawn_blocking(move || hash_partial(&partial_for_hash))
3730            .await
3731            .map_err(|e| anyhow!("whole-file hash task: {e}"))??;
3732        if got != hash {
3733            bail!(
3734                "reconstructed blob {} does not match its whole-file hash after a full re-fetch",
3735                blake3::Hash::from_bytes(hash).to_hex()
3736            );
3737        }
3738    }
3739
3740    // Promote the verified `.partial` to the plain staging file and clean up the
3741    // ranged-store sidecars, then register this blob's chunks as donors.
3742    let partial_for_promote = partial.clone();
3743    let staging_for_promote = staging.to_path_buf();
3744    tokio::task::spawn_blocking(move || {
3745        promote_partial(&partial_for_promote, &staging_for_promote)
3746    })
3747    .await
3748    .map_err(|e| anyhow!("promote task: {e}"))??;
3749    finish_progress();
3750    index.register(hints, staging);
3751    Ok(DedupOutcome::of(&spliced, resumed, total, hints_ignored))
3752}
3753
3754/// Tail reconcile for one entry's deferred (sibling-assigned) chunks: splice each
3755/// as its assigned fetcher registers it in `index`, waiting on the index progress
3756/// signal between scans rather than polling on a clock. A deferred chunk whose
3757/// assigned fetcher FINISHES without registering it (a failed fetcher, or one with
3758/// no recorded assignee) — or one registered under a length that disagrees with the
3759/// hint — is driven and paid here instead, over the whole groups its span touches,
3760/// so the entry always completes and the run never hangs. Records each splice and
3761/// each driven span in `ledger`, and returns the count of deferred chunks that fell
3762/// back to a paid drive.
3763async fn reconcile_deferred(
3764    driver: &dyn RangeDriver,
3765    partial: &Path,
3766    deferred: &[DeferredChunk],
3767    index: &ChunkIndex,
3768    fetch_plan: &FetchPlan,
3769    file: Option<&pull_progress::FileBar>,
3770    ledger: &mut SpliceLedger,
3771) -> anyhow::Result<u64> {
3772    let mut waiting: Vec<DeferredChunk> = deferred.to_vec();
3773    let mut ignored = 0u64;
3774    if waiting.is_empty() {
3775        return Ok(0);
3776    }
3777    let notified = index.progress.notified();
3778    tokio::pin!(notified);
3779    loop {
3780        // Register as a waiter BEFORE scanning so a registration/finish between the
3781        // scan and the await is not lost (`notify_waiters` stores no permit).
3782        notified.as_mut().enable();
3783
3784        let mut ready: Vec<DonorRange> = Vec::new();
3785        let mut fallback: Vec<(u64, u64)> = Vec::new();
3786        let mut still: Vec<DeferredChunk> = Vec::new();
3787        {
3788            let guard = index.map.lock().unwrap_or_else(PoisonError::into_inner);
3789            for c in &waiting {
3790                let h = &c.hint;
3791                // A trustworthy donor for this chunk is available now — splice it.
3792                // A registered length that disagrees with the hint is untrusted.
3793                if let Some(m) = guard.get(&h.hash).filter(|m| m.len == h.len) {
3794                    ready.push(DonorRange::new(h, m, c.dst, c.refetch));
3795                    continue;
3796                }
3797                // Otherwise keep waiting ONLY while the assigned fetcher is still
3798                // running and might yet register a good donor. Stop waiting — and pay
3799                // for the interior here — once that fetcher has finished without
3800                // producing it, or a registered donor's length disagreed (present but
3801                // untrusted), or there is no recorded assignee at all.
3802                let present_but_untrusted = guard.contains_key(&h.hash);
3803                let fetcher_running = fetch_plan
3804                    .assigned
3805                    .get(&h.hash)
3806                    .copied()
3807                    .is_some_and(|a| !index.is_finished(a));
3808                if fetcher_running && !present_but_untrusted {
3809                    still.push(*c);
3810                } else {
3811                    fallback.push(c.refetch);
3812                    ignored = ignored.saturating_add(1);
3813                }
3814            }
3815        }
3816
3817        if !ready.is_empty() {
3818            let refetch = splice_off_runtime(partial, ready, ledger).await?;
3819            ignored = ignored.saturating_add(u64::try_from(refetch.len()).unwrap_or(u64::MAX));
3820            fallback.extend(refetch);
3821        }
3822
3823        if !fallback.is_empty() {
3824            driver.drive(&fallback).await?;
3825            ledger.driven.extend_from_slice(&fallback);
3826            // A fallback drive may complete the store (`drive` renames
3827            // `.partial` -> `<hex>`); no more splicing is then possible or needed.
3828            if driver.staging().try_exists()? {
3829                return Ok(ignored);
3830            }
3831        }
3832
3833        waiting = still;
3834        if waiting.is_empty() {
3835            return Ok(ignored);
3836        }
3837
3838        // Nothing to do this pass but wait on a sibling — surface it on the file row
3839        // (its download is done; the total bar shows the run is still moving).
3840        if let Some(f) = file {
3841            f.set_pending();
3842        }
3843        // Block until the next registration or group-finish, then rescan. A
3844        // waiter moves no bytes, so its `--jobs` slot goes to a queued entry
3845        // meanwhile; a fallback drive takes one again.
3846        driver.release_slot();
3847        notified.as_mut().await;
3848        driver.retake_slot().await?;
3849        notified.set(index.progress.notified());
3850    }
3851}
3852
3853/// The run-end sweep of donor staging blobs: remove every registered donor
3854/// source EXCEPT one [`ChunkIndex::mark_retained`] flagged (its blob is paid for
3855/// but a destination failed to materialize, so its finalized `<hex>` is the resume
3856/// prefix a rerun needs — deleting it would force a full re-fetch and re-payment).
3857/// The removal runs on the blocking pool (see [`remove_staging_off_runtime`]).
3858async fn sweep_donor_sources(index: &ChunkIndex) {
3859    let doomed: Vec<PathBuf> = index
3860        .sources()
3861        .into_iter()
3862        .filter(|source| !index.retained(source))
3863        .collect();
3864    if let Err(e) = tokio::task::spawn_blocking(move || {
3865        for source in &doomed {
3866            remove_staging(source);
3867        }
3868    })
3869    .await
3870    {
3871        tracing::warn!("donor staging sweep task: {e}");
3872    }
3873}
3874
3875/// Copy the already-fetched, already-verified blob at `staging` to `dest`,
3876/// returning the byte count. `staging` is read-only here (not consumed) — a
3877/// retry after a failed first write calls this again for the next writable
3878/// path — so the copy goes through a unique temp file beside `dest` and an
3879/// atomic rename, the same source-must-survive shape [`link_or_copy_atomic`]
3880/// uses for every later duplicate — built from `fetch::temp_in_parent`, the
3881/// staging primitive the bundle cache and manifest writers share.
3882fn materialize(staging: &Path, dest: &Path) -> anyhow::Result<u64> {
3883    if let Some(parent) = dest.parent() {
3884        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
3885    }
3886    let mut tmp =
3887        fetch::temp_in_parent(dest).with_context(|| format!("stage {}", dest.display()))?;
3888    let mut src =
3889        std::fs::File::open(staging).with_context(|| format!("open {}", staging.display()))?;
3890    let written = std::io::copy(&mut src, tmp.as_file_mut())
3891        .with_context(|| format!("copy {} -> {}", staging.display(), dest.display()))?;
3892    tmp.as_file()
3893        .sync_all()
3894        .with_context(|| format!("sync staged copy for {}", dest.display()))?;
3895    tmp.persist(dest)
3896        .map_err(|e| e.error)
3897        .with_context(|| format!("write {}", dest.display()))?;
3898    Ok(written)
3899}
3900
3901/// Land a group's finalized `staging` blob at its first destination, returning
3902/// its byte count: a non-donor moves ([`move_into_place`]); a donor — a splice
3903/// source the run-end sweep still owns — is hard-linked (a copy only across
3904/// filesystems), so its staging name survives for later recipients.
3905fn first_write(staging: &Path, dest: &Path, is_donor: bool) -> anyhow::Result<u64> {
3906    if !is_donor {
3907        return move_into_place(staging, dest);
3908    }
3909    let len = std::fs::metadata(staging)
3910        .with_context(|| format!("stat {}", staging.display()))?
3911        .len();
3912    link_or_copy_atomic(staging, dest).map(|()| len)
3913}
3914
3915/// Move the finalized `staging` blob to `dest`, returning its byte count. A
3916/// non-donor blob has no reader after its first destination lands, so a rename
3917/// replaces the full copy [`materialize`] makes — no second write of the blob and
3918/// no second copy on disk. `staging` is already durable: the ranged store's
3919/// finalize, and the dedup splice before promotion, sync it. A rename that fails
3920/// (a destination on another filesystem, `EXDEV`) falls back to [`materialize`],
3921/// which leaves `staging` in place for the next writable path.
3922fn move_into_place(staging: &Path, dest: &Path) -> anyhow::Result<u64> {
3923    if let Some(parent) = dest.parent() {
3924        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
3925    }
3926    let len = std::fs::metadata(staging)
3927        .with_context(|| format!("stat {}", staging.display()))?
3928        .len();
3929    match std::fs::rename(staging, dest) {
3930        Ok(()) => Ok(len),
3931        Err(_) => materialize(staging, dest),
3932    }
3933}
3934
3935/// One chunk of a hint-carrying entry, resolved to an absolute byte placement in
3936/// the whole file: `offset` is the running sum of prior chunk sizes and `len` the
3937/// chunk's own size, so `[offset, offset + len)` is the chunk's span.
3938#[derive(Debug, Clone, Copy)]
3939struct Hint {
3940    /// The chunk's BLAKE3 content address.
3941    hash: [u8; 32],
3942    /// Byte offset of the chunk within the whole file.
3943    offset: u64,
3944    /// Chunk length in bytes.
3945    len: u64,
3946}
3947
3948/// Where one chunk's verified bytes are already materialized on disk this run.
3949struct MaterializedRange {
3950    /// The finalized staging file (a completed entry's whole-file blob) that
3951    /// contains the chunk.
3952    source: PathBuf,
3953    /// Byte offset of the chunk within `source`.
3954    offset: u64,
3955    /// Chunk length in bytes.
3956    len: u64,
3957}
3958
3959/// In-run index from a chunk's BLAKE3 hash to where its verified bytes live on
3960/// disk. Shared across the run's concurrent entries behind a mutex; an entry
3961/// registers its chunks only after it fully completes and its staging blob is
3962/// finalized, so a donor is always fully written before a recipient reads it.
3963#[derive(Default)]
3964struct ChunkIndex {
3965    /// First-writer-wins map; a chunk registered by one completed entry serves
3966    /// every later entry that shares it.
3967    map: std::sync::Mutex<HashMap<[u8; 32], MaterializedRange>>,
3968    /// Donor staging blobs the run-end sweep in [`PullCtx::pull_all`] must NOT
3969    /// delete: a donor group whose blob is fully fetched and registered but
3970    /// whose materialize to disk failed (ENOSPC, an unwritable dest). Its
3971    /// finalized `<hex>` is the resume prefix a rerun needs — deleting it would
3972    /// force a full re-fetch and re-payment of an unrefunded blob.
3973    retain: std::sync::Mutex<HashSet<PathBuf>>,
3974    /// Output-file donor sources seeded from disk before the run. Excluded from
3975    /// [`Self::sources`] so the run-end staging sweep never deletes a
3976    /// materialized output.
3977    seeded: std::sync::Mutex<HashSet<PathBuf>>,
3978    /// Whole-file hashes of groups whose fetch has FINISHED this run, success or
3979    /// failure. A tail-reconcile waiter watches this: once the entry a deferred
3980    /// chunk is assigned to has finished without registering that chunk, the waiter
3981    /// stops waiting and drives the range itself (the assigned fetcher failed). It
3982    /// only ever grows.
3983    finished: std::sync::Mutex<HashSet<[u8; 32]>>,
3984    /// Pinged whenever a donor is registered/seeded or a group finishes, so a
3985    /// tail-reconcile waiter re-checks its deferred chunks without polling.
3986    progress: tokio::sync::Notify,
3987}
3988
3989impl ChunkIndex {
3990    /// Register a completed entry's chunks, all pointing at its finalized
3991    /// `source` blob. First writer wins, so a chunk shared by several entries
3992    /// keeps the first donor. A `None` hint list (a plain entry) registers
3993    /// nothing.
3994    fn register(&self, hints: Option<&[Hint]>, source: &Path) {
3995        let Some(hints) = hints else {
3996            return;
3997        };
3998        {
3999            let mut map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
4000            for h in hints {
4001                map.entry(h.hash).or_insert_with(|| MaterializedRange {
4002                    source: source.to_path_buf(),
4003                    offset: h.offset,
4004                    len: h.len,
4005                });
4006            }
4007        }
4008        // New donors may unblock a tail-reconcile waiter.
4009        self.progress.notify_waiters();
4010    }
4011
4012    /// The distinct donor staging blobs registered this run, for the run-end
4013    /// sweep in [`PullCtx::pull_all`]. Excludes any [`Self::seed_disk`] source —
4014    /// an on-disk OUTPUT file the sweep must never delete.
4015    fn sources(&self) -> Vec<PathBuf> {
4016        // Acquire `map` before `seeded` — the single lock order every site follows
4017        // (`register`/`seed_disk` touch `map` first), so no pair of these methods
4018        // can ever invert and deadlock.
4019        let map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
4020        let seeded = self.seeded.lock().unwrap_or_else(PoisonError::into_inner);
4021        let mut seen: HashSet<&Path> = HashSet::new();
4022        let mut out = Vec::new();
4023        for m in map.values() {
4024            if !seeded.contains(&m.source) && seen.insert(m.source.as_path()) {
4025                out.push(m.source.clone());
4026            }
4027        }
4028        out
4029    }
4030
4031    /// Seed a donor whose bytes live in an on-disk OUTPUT file (a skipped
4032    /// entry's current file, or a changed entry's old copy still present until
4033    /// its atomic materialize). First-writer-wins, like [`Self::register`]; the
4034    /// source is recorded as seeded so the sweep never removes it. The existing
4035    /// per-chunk re-hash guard verifies the bytes before any splice trusts them,
4036    /// so a stale offset or edited/removed file simply falls back to a fetch.
4037    fn seed_disk(&self, hash: [u8; 32], source: &Path, offset: u64, len: u64) {
4038        {
4039            let mut map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
4040            map.entry(hash).or_insert_with(|| MaterializedRange {
4041                source: source.to_path_buf(),
4042                offset,
4043                len,
4044            });
4045        }
4046        {
4047            let mut seeded = self.seeded.lock().unwrap_or_else(PoisonError::into_inner);
4048            seeded.insert(source.to_path_buf());
4049        }
4050        // A pre-seeded on-disk donor may unblock a tail-reconcile waiter.
4051        self.progress.notify_waiters();
4052    }
4053
4054    /// Record that a group's fetch has finished this run (success or failure) and
4055    /// wake any tail-reconcile waiter. A waiter watching a deferred chunk assigned
4056    /// to `whole` stops waiting once `whole` is finished but the chunk never
4057    /// registered — the assigned fetcher failed to produce it, so the waiter drives
4058    /// the range itself.
4059    fn mark_finished(&self, whole: [u8; 32]) {
4060        {
4061            let mut finished = self.finished.lock().unwrap_or_else(PoisonError::into_inner);
4062            finished.insert(whole);
4063        }
4064        self.progress.notify_waiters();
4065    }
4066
4067    /// Whether the group with whole-file hash `whole` has finished fetching this
4068    /// run (see [`Self::mark_finished`]).
4069    fn is_finished(&self, whole: [u8; 32]) -> bool {
4070        self.finished
4071            .lock()
4072            .unwrap_or_else(PoisonError::into_inner)
4073            .contains(&whole)
4074    }
4075
4076    /// Mark a donor `source` as a resume prefix the run-end sweep must keep: its
4077    /// blob is fully fetched and paid for, but at least one destination failed to
4078    /// materialize, so a rerun needs the finalized `<hex>` rather than re-paying.
4079    fn mark_retained(&self, source: &Path) {
4080        let mut retain = self.retain.lock().unwrap_or_else(PoisonError::into_inner);
4081        retain.insert(source.to_path_buf());
4082    }
4083
4084    /// Whether `source` was retained by [`Self::mark_retained`] and so must
4085    /// survive the run-end sweep.
4086    fn retained(&self, source: &Path) -> bool {
4087        let retain = self.retain.lock().unwrap_or_else(PoisonError::into_inner);
4088        retain.contains(source)
4089    }
4090}
4091
4092/// One splice source for the dedup path: the part of one chunk of the recipient
4093/// blob that its spliced run covers, and the materialized donor that holds it.
4094#[derive(Debug, Clone)]
4095struct DonorRange {
4096    /// `(offset, len)` in the recipient blob to write: the chunk's span inside its
4097    /// spliced run ([`plan_reassembly`]). It need not sit on chunk-group
4098    /// boundaries — adjacent spliced chunks tile a run whose ends do, so the runs
4099    /// and the ranged store's complement (fetched on group boundaries) tile the
4100    /// blob without overlap.
4101    dst: (u64, u64),
4102    /// The whole chunk groups `dst` touches, capped at the blob end — what a paid
4103    /// re-fetch drives when this donor cannot be trusted. It may take in a
4104    /// neighbour's bytes of a shared boundary group; the drive writes verified
4105    /// bytes over them.
4106    refetch: (u64, u64),
4107    /// The donor blob to read from.
4108    source: PathBuf,
4109    /// Source offset of the `dst` span within `source`.
4110    src_offset: u64,
4111    /// The whole chunk's hash — the `dst` span is trusted only after the
4112    /// whole chunk at `[chunk_src_offset, chunk_src_offset + chunk_len)` in
4113    /// `source` re-hashes to this.
4114    chunk_hash: [u8; 32],
4115    /// Whole chunk start in `source` (for the verification re-hash).
4116    chunk_src_offset: u64,
4117    /// Whole chunk length in bytes (for the verification re-hash).
4118    chunk_len: u64,
4119}
4120
4121/// Turn an entry's optional chunk list into placed [`Hint`]s, or `None` when the
4122/// entry should be fetched as a plain whole-file blob: no `chunks`, no whole-file
4123/// `size` to validate against, an unparseable chunk hash, or chunk sizes that do
4124/// not sum to the whole-file `size` (a malformed hint set, logged at debug). The
4125/// whole-file `hash` is authoritative in every case, so ignoring hints only ever
4126/// costs dedup, never correctness.
4127fn hints_of(entry: &ManifestEntry) -> Option<Vec<Hint>> {
4128    let chunks = entry.chunks.as_ref()?;
4129    let total = entry.size?;
4130    let mut hints = Vec::with_capacity(chunks.len());
4131    let mut offset = 0u64;
4132    for c in chunks {
4133        let hash = fetch::parse_hash(&c.hash).ok()?;
4134        hints.push(Hint {
4135            hash,
4136            offset,
4137            len: c.size,
4138        });
4139        offset = offset.checked_add(c.size)?;
4140    }
4141    if offset != total {
4142        tracing::debug!(
4143            "bundle entry {:?}: chunk sizes sum to {offset}, expected whole-file size {total}; \
4144             ignoring range-dedup hints",
4145            entry.path
4146        );
4147        return None;
4148    }
4149    Some(hints)
4150}
4151
4152impl DonorRange {
4153    /// The splice of chunk `h`'s span `dst` from the materialized donor `m`,
4154    /// which holds the whole chunk at `m.offset`; `refetch` is `dst`'s
4155    /// [`outward_groups`].
4156    fn new(h: &Hint, m: &MaterializedRange, dst: (u64, u64), refetch: (u64, u64)) -> Self {
4157        Self {
4158            dst,
4159            refetch,
4160            source: m.source.clone(),
4161            src_offset: m.offset.saturating_add(dst.0.saturating_sub(h.offset)),
4162            chunk_hash: h.hash,
4163            chunk_src_offset: m.offset,
4164            chunk_len: m.len,
4165        }
4166    }
4167}
4168
4169/// A deferred chunk of a [`ReassemblePlan`]: a chunk this run assigns to another
4170/// entry, spliced over `dst` once that entry registers it, or driven over
4171/// `refetch` (see [`DonorRange`]) if it never does.
4172#[derive(Debug, Clone, Copy)]
4173struct DeferredChunk {
4174    hint: Hint,
4175    dst: (u64, u64),
4176    refetch: (u64, u64),
4177}
4178
4179/// The whole chunk groups the byte span `(offset, len)` touches, as
4180/// `(offset, len)`, capped at `total`.
4181fn outward_groups((offset, len): (u64, u64), total: u64) -> (u64, u64) {
4182    let group = CHUNK_GROUP_BYTES;
4183    let start = (offset / group).saturating_mul(group);
4184    let end = offset
4185        .saturating_add(len)
4186        .div_ceil(group)
4187        .saturating_mul(group)
4188        .min(total);
4189    (start, end.saturating_sub(start))
4190}
4191
4192/// The run-level shared-chunk fetch plan: which entry pays to fetch each chunk that
4193/// appears in two or more entry positions across the whole manifest. A chunk in one
4194/// position only is absent — its sole entry pays for it as part of its complement.
4195/// Built once per run, so every entry derives the same assignment with no
4196/// coordination.
4197#[derive(Debug, Default)]
4198struct FetchPlan {
4199    /// Shared chunk hash → the whole-file hash of the entry assigned to fetch it.
4200    /// The assignee is the smallest containing entry by whole-file size, tie-broken
4201    /// by whole-file hash bytes, so the fetcher finishes soonest and every waiter is
4202    /// a larger entry whose own tail comes later.
4203    assigned: HashMap<[u8; 32], [u8; 32]>,
4204}
4205
4206/// Build the run-level [`FetchPlan`]: assign every chunk shared by two or more entry
4207/// positions to its smallest containing entry (tie-break by whole-file hash), so a
4208/// shared chunk is fetched and paid for exactly once per run and spliced into every
4209/// other entry that names it. An entry without usable hints or an unparseable
4210/// whole-file hash takes no part. An entry with no declared `size` ranks as
4211/// `u64::MAX`, so a sized sharer — the only kind that can be spliced from anyway —
4212/// always wins the assignment.
4213fn build_fetch_plan(entries: &[ManifestEntry]) -> FetchPlan {
4214    struct Occurrence {
4215        count: u32,
4216        best_size: u64,
4217        best_whole: [u8; 32],
4218    }
4219    let mut seen: HashMap<[u8; 32], Occurrence> = HashMap::new();
4220    for e in entries {
4221        let Ok(whole) = fetch::parse_hash(&e.hash) else {
4222            continue;
4223        };
4224        let Some(hints) = hints_of(e) else {
4225            continue;
4226        };
4227        let size = e.size.unwrap_or(u64::MAX);
4228        for h in &hints {
4229            seen.entry(h.hash)
4230                .and_modify(|o| {
4231                    o.count = o.count.saturating_add(1);
4232                    if (size, whole) < (o.best_size, o.best_whole) {
4233                        o.best_size = size;
4234                        o.best_whole = whole;
4235                    }
4236                })
4237                .or_insert(Occurrence {
4238                    count: 1,
4239                    best_size: size,
4240                    best_whole: whole,
4241                });
4242        }
4243    }
4244    let assigned = seen
4245        .into_iter()
4246        .filter(|(_, o)| o.count >= 2)
4247        .map(|(hash, o)| (hash, o.best_whole))
4248        .collect();
4249    FetchPlan { assigned }
4250}
4251
4252/// One entry's reassembly plan, computed from the live [`ChunkIndex`] snapshot and
4253/// the run-level [`FetchPlan`]. The three range sets tile the blob:
4254///
4255/// - `donor`: chunks already materialized somewhere (a pre-seeded on-disk file, or
4256///   a sibling that already finished) — spliced immediately, no fetch.
4257/// - `deferred`: chunks assigned to a DIFFERENT entry and not yet available —
4258///   waited for and spliced at the tail once the assigned fetcher registers them
4259///   (or driven as a fallback if that fetcher finishes without producing them).
4260/// - `drive`: everything else — this entry's unique and self-assigned chunks, plus
4261///   the partial chunk groups at the edges of each spliced run — driven and paid
4262///   for now.
4263///
4264/// Donor and deferred chunks that touch form one spliced run, and only the run's
4265/// two ends round inward to chunk-group boundaries. A boundary between two
4266/// spliced chunks therefore costs nothing, wherever it falls: its group is
4267/// spliced from both sides rather than driven as a separate 16 KiB request.
4268///
4269/// With an empty [`FetchPlan`] (no shared chunks) `deferred` is empty and the plan
4270/// reduces to a donor/complement split.
4271struct ReassemblePlan {
4272    donor: Vec<DonorRange>,
4273    deferred: Vec<DeferredChunk>,
4274    drive: Vec<(u64, u64)>,
4275}
4276
4277/// Build an entry's [`ReassemblePlan`] for the entry whose whole-file hash is
4278/// `whole`. A chunk currently in `index` under its hinted length becomes a
4279/// `donor` (spliced now); a chunk the [`FetchPlan`] assigns to another entry, not
4280/// yet available, becomes `deferred`. Their spans merge into runs
4281/// ([`spliced_runs`]); a chunk with no bytes inside a run is dropped to the
4282/// drive, and everything outside the runs is driven. `total` is the entry's
4283/// whole-file size.
4284fn plan_reassembly(
4285    hints: &[Hint],
4286    index: &HashMap<[u8; 32], MaterializedRange>,
4287    fetch_plan: &FetchPlan,
4288    whole: [u8; 32],
4289    total: u64,
4290) -> ReassemblePlan {
4291    // Each candidate chunk, with its donor when one is materialized now.
4292    let mut candidates: Vec<(&Hint, Option<&MaterializedRange>)> = Vec::new();
4293    for h in hints {
4294        match index.get(&h.hash) {
4295            // A donor whose claimed chunk length disagrees with the hint is one
4296            // side's manifest lying about the chunk — never spliced from.
4297            Some(m) if m.len == h.len => candidates.push((h, Some(m))),
4298            _ => {
4299                let assigned_elsewhere = fetch_plan
4300                    .assigned
4301                    .get(&h.hash)
4302                    .is_some_and(|owner| *owner != whole);
4303                if assigned_elsewhere {
4304                    candidates.push((h, None));
4305                }
4306            }
4307        }
4308    }
4309    let spans: Vec<(u64, u64)> = candidates.iter().map(|(h, _)| (h.offset, h.len)).collect();
4310    let runs = spliced_runs(&spans, total);
4311
4312    let mut donor = Vec::new();
4313    let mut deferred = Vec::new();
4314    for (h, m) in candidates {
4315        let Some(dst) = span_in_runs(&runs, h.offset, h.len) else {
4316            continue;
4317        };
4318        match m {
4319            Some(m) => donor.push(DonorRange::new(h, m, dst, outward_groups(dst, total))),
4320            None => deferred.push(DeferredChunk {
4321                hint: *h,
4322                dst,
4323                refetch: outward_groups(dst, total),
4324            }),
4325        }
4326    }
4327    let covered: Vec<(u64, u64)> = runs.iter().map(|&(s, e)| (s, e - s)).collect();
4328    ReassemblePlan {
4329        drive: complement_runs(&covered, total),
4330        donor,
4331        deferred,
4332    }
4333}
4334
4335/// Merge the spliceable chunk spans `(offset, len)` into sorted, disjoint
4336/// `(start, end)` runs — spans that overlap or touch join one run — then round
4337/// each run inward to chunk-group boundaries, the blob end counting as one. A
4338/// run with no whole group left is dropped. The ranged store fetches whole
4339/// groups, so only a run's two ends can leave a partial group to drive.
4340fn spliced_runs(spans: &[(u64, u64)], total: u64) -> Vec<(u64, u64)> {
4341    let group = CHUNK_GROUP_BYTES;
4342    coalesce_runs(spans, total)
4343        .into_iter()
4344        .filter_map(|(start, end)| {
4345            let start = start.div_ceil(group).saturating_mul(group);
4346            let end = if end == total {
4347                end
4348            } else {
4349                (end / group).saturating_mul(group)
4350            };
4351            (start < end).then_some((start, end))
4352        })
4353        .collect()
4354}
4355
4356/// The part of the span `(offset, len)` inside the sorted, disjoint `runs`, as
4357/// `(offset, len)`, or `None` when no run covers any of it. A span from the same
4358/// set the runs were built from meets at most one run.
4359fn span_in_runs(runs: &[(u64, u64)], offset: u64, len: u64) -> Option<(u64, u64)> {
4360    let end = offset.saturating_add(len);
4361    let i = runs.partition_point(|&(_, run_end)| run_end <= offset);
4362    let &(run_start, run_end) = runs.get(i)?;
4363    let (start, end) = (offset.max(run_start), end.min(run_end));
4364    (start < end).then_some((start, end - start))
4365}
4366
4367/// The `.partial` data file the ranged store keeps beside a finalized `staging`
4368/// blob — `<hex>.partial` — where driven ranges and spliced donor bytes land
4369/// before promotion.
4370fn partial_path(staging: &Path) -> PathBuf {
4371    staging.with_extension("partial")
4372}
4373
4374/// Verify and splice every donor range into `partial`, returning the donors that
4375/// could NOT be trusted (a donor whose chunk no longer hashes to its hint, or an
4376/// unreadable source): the caller re-fetches and pays for each one's
4377/// [`DonorRange::refetch`] span.
4378///
4379/// A donor's bytes are trusted only after the WHOLE chunk at its recorded source
4380/// offset re-hashes to the chunk hash — a donor's own chunk placement is an
4381/// unverified manifest claim until then. The trusted `dst` span is then written
4382/// at the recipient offset; the whole-file BLAKE3 the caller runs next is the
4383/// authoritative backstop against a lying recipient placement. A span that lands
4384/// in a group the ranged store already holds (a resumed run, or a neighbour's
4385/// re-fetch) writes the same verified bytes; if a lying placement wrote others,
4386/// the store's finalize verify drops that group and a drive fetches it again.
4387fn splice_donors(partial: &Path, donors: &[DonorRange]) -> anyhow::Result<Vec<DonorRange>> {
4388    use std::io::{Seek, SeekFrom};
4389
4390    let mut refetch = Vec::new();
4391    let mut out = std::fs::OpenOptions::new()
4392        .write(true)
4393        .open(partial)
4394        .with_context(|| format!("open {}", partial.display()))?;
4395    for d in donors {
4396        let (dst_offset, len) = d.dst;
4397        // An unreadable donor source is not trusted — pay to fetch it.
4398        let Ok(mut src) = std::fs::File::open(&d.source) else {
4399            refetch.push(d.clone());
4400            continue;
4401        };
4402        if !chunk_verified(&mut src, d.chunk_src_offset, d.chunk_len, d.chunk_hash) {
4403            refetch.push(d.clone());
4404            continue;
4405        }
4406        // The chunk is confirmed present at `chunk_src_offset`; copy its `dst`
4407        // span into the recipient's `.partial`. A seek/copy error here (a
4408        // truncated or racing donor file, an I/O fault) is not fatal: the donor
4409        // bytes are simply not trusted, so queue the donor for a paid,
4410        // bao-verified re-fetch that overwrites every group it touches — nothing
4411        // torn by a partial copy survives, and the whole-file BLAKE3 the caller
4412        // runs next is the authoritative backstop.
4413        let copied = src
4414            .seek(SeekFrom::Start(d.src_offset))
4415            .and_then(|_| out.seek(SeekFrom::Start(dst_offset)))
4416            .and_then(|_| copy_exact(&mut src, &mut out, len));
4417        if copied.is_err() {
4418            refetch.push(d.clone());
4419        }
4420    }
4421    out.sync_all()
4422        .with_context(|| format!("sync {}", partial.display()))?;
4423    Ok(refetch)
4424}
4425
4426/// [`splice_donors`] on the blocking pool. Records the `dst` span of each donor
4427/// it spliced in `ledger`, and returns the [`DonorRange::refetch`] span of each
4428/// untrusted donor, which the caller drives and pays for.
4429async fn splice_off_runtime(
4430    partial: &Path,
4431    donors: Vec<DonorRange>,
4432    ledger: &mut SpliceLedger,
4433) -> anyhow::Result<Vec<(u64, u64)>> {
4434    let partial = partial.to_path_buf();
4435    let planned: Vec<((u64, u64), (u64, u64))> =
4436        donors.iter().map(|d| (d.dst, d.refetch)).collect();
4437    let failed = off_runtime("donor splice", move || splice_donors(&partial, &donors)).await?;
4438    let failed_dst: HashSet<(u64, u64)> = failed.iter().map(|d| d.dst).collect();
4439    ledger.spliced.extend(
4440        planned
4441            .iter()
4442            .filter(|(dst, _)| !failed_dst.contains(dst))
4443            .map(|&(dst, _)| dst),
4444    );
4445    Ok(failed.iter().map(|d| d.refetch).collect())
4446}
4447
4448/// The byte spans one entry's reassembly spliced from disk and the spans it
4449/// drove to replace an untrusted or missing donor. A driven span is widened to
4450/// whole chunk groups, so it can cover part of a neighbour's spliced span, and
4451/// the drive then writes and pays for those bytes.
4452#[derive(Debug, Default)]
4453struct SpliceLedger {
4454    /// The `dst` span of each donor that spliced cleanly.
4455    spliced: Vec<(u64, u64)>,
4456    /// Each re-fetch or fallback span that a drive covered.
4457    driven: Vec<(u64, u64)>,
4458}
4459
4460impl SpliceLedger {
4461    /// The spans served from a splice: the spliced spans less every driven
4462    /// span, as sorted, disjoint `(offset, len)` pairs.
4463    fn spliced_spans(&self, total: u64) -> Vec<(u64, u64)> {
4464        uncovered_runs(&self.spliced, &self.driven, total)
4465    }
4466}
4467
4468/// The parts of `spans` that no `cover` span covers, over `[0, total)`, as
4469/// sorted, disjoint `(offset, len)` pairs. Both inputs are `(offset, len)`
4470/// spans, in any order, and may overlap.
4471fn uncovered_runs(spans: &[(u64, u64)], cover: &[(u64, u64)], total: u64) -> Vec<(u64, u64)> {
4472    let kept = complement_runs(cover, total);
4473    let mut kept = kept
4474        .iter()
4475        .map(|&(offset, len)| (offset, offset.saturating_add(len)));
4476    let mut current = kept.next();
4477    let mut out = Vec::new();
4478    for (start, end) in coalesce_runs(spans, total) {
4479        while let Some((k_start, k_end)) = current {
4480            if k_end <= start {
4481                current = kept.next();
4482                continue;
4483            }
4484            if k_start >= end {
4485                break;
4486            }
4487            let (from, to) = (start.max(k_start), end.min(k_end));
4488            if to > from {
4489                out.push((from, to - from));
4490            }
4491            if k_end <= end {
4492                current = kept.next();
4493            } else {
4494                break;
4495            }
4496        }
4497    }
4498    out
4499}
4500
4501/// Whether the `len` bytes at `offset` in `src` hash to `expected`. Any read
4502/// failure (a short source, an I/O error) returns `false`: the bytes are simply
4503/// not trusted, and the caller re-fetches them rather than splicing.
4504fn chunk_verified(src: &mut std::fs::File, offset: u64, len: u64, expected: [u8; 32]) -> bool {
4505    use std::io::{Read, Seek, SeekFrom};
4506    if src.seek(SeekFrom::Start(offset)).is_err() {
4507        return false;
4508    }
4509    let mut hasher = blake3::Hasher::new();
4510    let mut remaining = len;
4511    let mut buf = vec![0u8; 1 << 20];
4512    while remaining > 0 {
4513        let want = usize::try_from(remaining.min(1 << 20)).unwrap_or(1 << 20);
4514        let Some(slice) = buf.get_mut(..want) else {
4515            return false;
4516        };
4517        if src.read_exact(slice).is_err() {
4518            return false;
4519        }
4520        hasher.update(slice);
4521        remaining = remaining.saturating_sub(u64::try_from(want).unwrap_or(remaining));
4522    }
4523    *hasher.finalize().as_bytes() == expected
4524}
4525
4526/// Copy exactly `len` bytes from `src` to `out`, both already positioned, in
4527/// bounded chunks so a large range never loads into memory at once.
4528fn copy_exact(src: &mut std::fs::File, out: &mut std::fs::File, len: u64) -> std::io::Result<()> {
4529    use std::io::{Read, Write};
4530    let mut remaining = len;
4531    let mut buf = vec![0u8; 1 << 20];
4532    while remaining > 0 {
4533        let want = usize::try_from(remaining.min(1 << 20)).unwrap_or(1 << 20);
4534        let slice = buf
4535            .get_mut(..want)
4536            .ok_or_else(|| std::io::Error::other("copy buffer slice"))?;
4537        src.read_exact(slice)?;
4538        out.write_all(slice)?;
4539        remaining = remaining.saturating_sub(u64::try_from(want).unwrap_or(remaining));
4540    }
4541    Ok(())
4542}
4543
4544/// BLAKE3 of the whole `partial` data file, streamed so an arbitrarily large blob
4545/// never loads into memory at once.
4546fn hash_partial(partial: &Path) -> anyhow::Result<[u8; 32]> {
4547    hash_partial_with_progress(partial, None)
4548}
4549
4550/// BLAKE3 of the whole `partial` data file, streamed, reporting the cumulative bytes
4551/// hashed to `progress` after each read — so a caller can advance a progress bar
4552/// through a multi-GB verify instead of showing a frozen row.
4553fn hash_partial_with_progress(
4554    partial: &Path,
4555    progress: Option<&(dyn Fn(u64) + Send + Sync)>,
4556) -> anyhow::Result<[u8; 32]> {
4557    use std::io::Read;
4558    let mut f =
4559        std::fs::File::open(partial).with_context(|| format!("open {}", partial.display()))?;
4560    let mut hasher = blake3::Hasher::new();
4561    let mut buf = vec![0u8; 1 << 20];
4562    let mut hashed_bytes = 0u64;
4563    loop {
4564        let n = f
4565            .read(&mut buf)
4566            .with_context(|| format!("read {}", partial.display()))?;
4567        if n == 0 {
4568            break;
4569        }
4570        let slice = buf
4571            .get(..n)
4572            .ok_or_else(|| anyhow!("short read buffer slice"))?;
4573        hasher.update(slice);
4574        if let Some(cb) = progress {
4575            hashed_bytes = hashed_bytes.saturating_add(u64::try_from(n).unwrap_or(0));
4576            cb(hashed_bytes);
4577        }
4578    }
4579    Ok(*hasher.finalize().as_bytes())
4580}
4581
4582/// Promote a verified `.partial` to the plain `staging` blob (an atomic
4583/// same-directory rename) and best-effort remove the ranged-store sidecars the
4584/// dedup path no longer needs. `staging` is then the finalized blob `fetch_group`
4585/// materializes at each destination — the same shape the whole-file path's
4586/// ranged store leaves on its own finalize.
4587fn promote_partial(partial: &Path, staging: &Path) -> anyhow::Result<()> {
4588    std::fs::rename(partial, staging)
4589        .with_context(|| format!("promote {} -> {}", partial.display(), staging.display()))?;
4590    let sidecar = staging.with_extension("partial.ranges");
4591    if let Err(e) = std::fs::remove_file(&sidecar)
4592        && e.kind() != std::io::ErrorKind::NotFound
4593    {
4594        tracing::warn!(
4595            "failed to remove staging sidecar {}: {e}",
4596            sidecar.display()
4597        );
4598    }
4599    Ok(())
4600}
4601
4602/// Reserved subdirectory of `out_root` holding per-hash staging files and their
4603/// sidecars. Manifest entry paths that resolve inside it are rejected in
4604/// [`plan_slots`], so a manifest can never collide with a staging file — nor
4605/// trick `remove_staging` into deleting a materialized output.
4606const STAGING_DIR: &str = ".decdn-partial";
4607
4608/// Per-hash staging file an entry's fetch finalizes to before `fetch_group`
4609/// materializes it at the manifest's destination path(s) —
4610/// `<out_root>/.decdn-partial/<hex>` (#1497). This is the *plain* final path:
4611/// the entry's ranged store owns the `.partial` suffix and the `.ranges`
4612/// record itself, streaming into `<hex>.partial` beside this and renaming to
4613/// `<hex>` on `finalize`. Those sidecars are what survives
4614/// an interrupted pull — a rerun resumes from them rather than re-paying for
4615/// bytes already landed. Creates the staging directory (idempotent, and only
4616/// ever called once a group has real work to do — an all-skipped group never
4617/// creates one) but not the file itself; the ranged store does that.
4618fn staging_path(out_root: &Path, hash: [u8; 32]) -> anyhow::Result<PathBuf> {
4619    let dir = out_root.join(STAGING_DIR);
4620    std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
4621    let name = blake3::Hash::from_bytes(hash).to_hex().to_string();
4622    Ok(dir.join(name))
4623}
4624
4625/// The `(offset, len)` runs of `[0, total)` NOT covered by `donor_aligned`.
4626///
4627/// `donor_aligned` holds chunk-group-aligned ranges already available from a
4628/// donor (a sibling entry's already-pulled bytes) — possibly unsorted and
4629/// possibly overlapping. This sorts and coalesces them first, then emits the
4630/// gaps between (and around) the coalesced runs as the complement to drive:
4631/// the bytes a donor does NOT cover, which still need a paid pull.
4632///
4633/// A donor range past `total`, or one that overlaps `total`, is clamped to
4634/// `total` — `total` is the whole blob's authoritative length. `total == 0`
4635/// always returns an empty complement (there is nothing to cover).
4636pub(crate) fn complement_runs(donor_aligned: &[(u64, u64)], total: u64) -> Vec<(u64, u64)> {
4637    if total == 0 {
4638        return Vec::new();
4639    }
4640    let coalesced = coalesce_runs(donor_aligned, total);
4641
4642    // Walk the coalesced donor runs, emitting the gap before each one and,
4643    // at the end, the gap after the last one up to `total`.
4644    let mut gaps = Vec::with_capacity(coalesced.len() + 1);
4645    let mut cursor = 0u64;
4646    for (start, end) in coalesced {
4647        if start > cursor {
4648            gaps.push((cursor, start - cursor));
4649        }
4650        cursor = cursor.max(end);
4651    }
4652    if cursor < total {
4653        gaps.push((cursor, total - cursor));
4654    }
4655    gaps
4656}
4657
4658/// Sort and coalesce `(offset, len)` byte ranges into disjoint `(start, end)`
4659/// runs, clamped to `[0, total)`: ranges that overlap or touch join one run, and
4660/// an empty or out-of-range one is dropped.
4661fn coalesce_runs(ranges: &[(u64, u64)], total: u64) -> Vec<(u64, u64)> {
4662    // Clamp each range to `[0, total)` and drop empty/out-of-range ones, then
4663    // sort by start so overlapping/adjacent runs coalesce in one pass.
4664    let mut runs: Vec<(u64, u64)> = ranges
4665        .iter()
4666        .filter_map(|&(offset, len)| {
4667            let start = offset.min(total);
4668            let end = offset.checked_add(len).unwrap_or(total).min(total);
4669            (end > start).then_some((start, end))
4670        })
4671        .collect();
4672    runs.sort_unstable_by_key(|&(start, _)| start);
4673
4674    let mut coalesced: Vec<(u64, u64)> = Vec::with_capacity(runs.len());
4675    for (start, end) in runs {
4676        match coalesced.last_mut() {
4677            Some((_, last_end)) if start <= *last_end => {
4678                *last_end = (*last_end).max(end);
4679            }
4680            _ => coalesced.push((start, end)),
4681        }
4682    }
4683    coalesced
4684}
4685
4686/// [`remove_staging`] on the blocking pool: unlinking a multi-GB blob can take
4687/// long enough on some filesystems to stall the task every group shares. A
4688/// failed join only leaves harmless clutter, the same as a failed removal.
4689async fn remove_staging_off_runtime(staging: &Path) {
4690    let staging = staging.to_path_buf();
4691    if let Err(e) = tokio::task::spawn_blocking(move || remove_staging(&staging)).await {
4692        tracing::warn!("staging removal task: {e}");
4693    }
4694}
4695
4696/// Best-effort cleanup of a finalized staging blob whose content is safely
4697/// elsewhere (materialized to disk, or read into memory) and so has nothing left
4698/// to resume. Removes the plain `<hex>` finalized blob itself and, best-effort,
4699/// any leftover `<hex>.partial{,.ranges}` sidecars: the ranged store's
4700/// `finalize` normally clears those on success, but this is the belt to that
4701/// brace and never runs on an errored entry (whose sidecars are the resume
4702/// prefix a rerun needs). A leftover here is harmless clutter, not a
4703/// correctness issue, so a removal failure is reported and swallowed rather
4704/// than propagated.
4705fn remove_staging(staging: &Path) {
4706    let paths_to_remove = [
4707        staging.to_path_buf(),
4708        staging.with_extension("partial"),
4709        staging.with_extension("partial.ranges"),
4710    ];
4711    for path in paths_to_remove {
4712        if let Err(e) = std::fs::remove_file(&path)
4713            && e.kind() != std::io::ErrorKind::NotFound
4714        {
4715            tracing::warn!("failed to remove staging file {}: {e}", path.display());
4716        }
4717    }
4718}
4719
4720/// Fail every `Write` slot with `e`, preserving `Failed`/`Skip` classification —
4721/// the shared tail of `fetch_group`'s two pre-materialize failure paths (a
4722/// staging-dir create error, or the fetch itself failing).
4723fn fail_all(slots: Vec<Slot<'_>>, e: &anyhow::Error) -> Vec<EntryOutcome> {
4724    slots
4725        .into_iter()
4726        .map(|s| match s {
4727            Slot::Failed(o) => o,
4728            Slot::Skip => EntryOutcome::Skipped,
4729            Slot::Write { label, .. } => EntryOutcome::failed(label, e),
4730        })
4731        .collect()
4732}
4733
4734impl EntryOutcome {
4735    fn failed(path: &str, err: &anyhow::Error) -> Self {
4736        Self::Failed {
4737            path: path.to_string(),
4738            // Sanitize here, at the single construction site: a per-entry fetch
4739            // failure can wrap a chain-RPC error whose source carries the
4740            // `rpc_url` (API key in path/query). This outcome is both printed to
4741            // stderr and serialized into the `--json` report, neither of which
4742            // passes through `main()`'s sanitizer (issue #954).
4743            err: sanitize_err_chain(err),
4744        }
4745    }
4746}
4747
4748/// `--node-id` (with its clap-required `--provider-address`) → a pinned target
4749/// for every entry; otherwise `None` (discover per entry).
4750fn explicit_target(common: &ClientFetchArgs) -> anyhow::Result<Option<(PublicKey, Address)>> {
4751    let Some(raw) = &common.node_id else {
4752        return Ok(None);
4753    };
4754    let node_id =
4755        PublicKey::from_str(raw).map_err(|e| anyhow!("invalid --node-id {raw:?}: {e}"))?;
4756    let provider_raw = common
4757        .provider_address
4758        .as_deref()
4759        .ok_or_else(|| anyhow!("--provider-address is required with --node-id"))?;
4760    let provider = chain_ctx::parse_address(provider_raw, "--provider-address")?;
4761    Ok(Some((node_id, provider)))
4762}
4763
4764/// Read + parse a local bundle manifest file.
4765fn read_local_manifest(path: &Path) -> anyhow::Result<Manifest> {
4766    let bytes =
4767        std::fs::read(path).with_context(|| format!("read bundle file {}", path.display()))?;
4768    parse_manifest(&bytes)
4769}
4770
4771/// Parse + version-gate bundle manifest bytes.
4772fn parse_manifest(bytes: &[u8]) -> anyhow::Result<Manifest> {
4773    let manifest: Manifest =
4774        serde_json::from_slice(bytes).context("parse bundle JSON (expected v1 manifest)")?;
4775    if manifest.version != 1 {
4776        bail!(
4777            "unsupported bundle version {} (this build supports v1)",
4778            manifest.version
4779        );
4780    }
4781    Ok(manifest)
4782}
4783
4784/// Resolve a bundle entry's POSIX relative `path` to a filesystem path under
4785/// `root`, rejecting anything that isn't a sequence of plain filename
4786/// components — absolute paths, `.`/`..`, drive prefixes, empty components
4787/// (leading/trailing/double slash). With `..` rejected, the result cannot
4788/// escape `root`. (`appendix-bundles.md` § Path-safety rules.)
4789fn safe_join(root: &Path, rel: &str) -> anyhow::Result<PathBuf> {
4790    if rel.is_empty() {
4791        bail!("empty entry path");
4792    }
4793    let mut out = root.to_path_buf();
4794    for seg in rel.split('/') {
4795        // Each `/`-split segment must be exactly one `Normal` path component.
4796        // This rejects "", ".", "..", "/", and a Windows drive prefix uniformly
4797        // and cross-platform.
4798        let mut comps = Path::new(seg).components();
4799        match (comps.next(), comps.next()) {
4800            (Some(Component::Normal(s)), None) => out.push(s),
4801            _ => bail!("rejected unsafe component {seg:?} in entry path {rel:?}"),
4802        }
4803    }
4804    Ok(out)
4805}
4806
4807/// The skip-cache record for one entry, built from its FINAL on-disk state, or
4808/// `None` when this run has nothing safe to record for it. A record is produced
4809/// only when a regular file is present at the entry's path with a usable mtime —
4810/// the gates that keep a symlink, a non-regular file, a missing file, or an
4811/// unreadable mtime out of the later fast-skip path. `materialize` renames new
4812/// content into place only on success, so a present regular file at the path is
4813/// this run's landed bytes; pairing the entry's hash with them is sound.
4814fn record_one(out_root: &Path, en: &ManifestEntry) -> Option<bundle_manifest::SavedFile> {
4815    let dest = safe_join(out_root, &en.path).ok()?;
4816    // `symlink_metadata` does not follow links: only a regular file this run
4817    // landed is recorded, so a symlink or non-regular file at `dest` is never
4818    // written into the skip-cache (its later fast-skip would trust a target
4819    // this run never verified).
4820    let meta = std::fs::symlink_metadata(&dest).ok()?;
4821    if !meta.is_file() {
4822        return None; // only record regular files
4823    }
4824    let mtime = SavedMtime::of(&meta)?; // no usable mtime → omit the unverifiable gate
4825    let chunks = en.chunks.as_ref().map(|cs| {
4826        cs.iter()
4827            .map(|c| bundle_manifest::SavedChunk {
4828                hash: c.hash.clone(),
4829                size: c.size,
4830            })
4831            .collect()
4832    });
4833    Some(bundle_manifest::SavedFile {
4834        hash: en.hash.clone(),
4835        size: meta.len(),
4836        mtime,
4837        chunks,
4838    })
4839}
4840
4841/// One line for each entry whose landed file differs in size from its
4842/// manifest's `size`: `<path>: manifest says X bytes, the blob is Y bytes`. A
4843/// manifest size is only the fetch's first claim, so such an entry succeeds;
4844/// the line tells the user the manifest is stale. `updates` holds the landed
4845/// files' records ([`build_completed_updates`]).
4846fn size_warnings(
4847    entries: &[&ManifestEntry],
4848    updates: &BTreeMap<String, bundle_manifest::SavedFile>,
4849) -> Vec<String> {
4850    entries
4851        .iter()
4852        .filter_map(|en| {
4853            let manifest = en.size?;
4854            let landed = updates.get(&en.path)?.size;
4855            (landed != manifest).then(|| {
4856                format!(
4857                    "{}: manifest says {manifest} bytes, the blob is {landed} bytes",
4858                    en.path
4859                )
4860            })
4861        })
4862        .collect()
4863}
4864
4865/// Build the skip-cache updates for a batch of `entries` paired with THIS run's
4866/// `outcomes` for them, in order — `fetch_group` returns one outcome per entry,
4867/// in order, so position correlates a path to its outcome. Only an entry whose
4868/// outcome is a success this run (`Fetched`, `Linked`, or `Skipped`) is
4869/// eligible; a `Failed` entry is omitted, because its fetch left the OLD bytes in
4870/// place (materialize renames new content only on success) and pairing the *new*
4871/// manifest hash with them would let a later mtime/hash fast path wrongly treat
4872/// the stale file as up to date and never re-fetch it.
4873///
4874/// Correlating by position — rather than "every entry not in the failed set" —
4875/// is what makes this safe to call MID-RUN: an entry whose group has not
4876/// completed yet is simply absent from `outcomes`, so it is never recorded from
4877/// bytes this run has not landed. A batch may be one completed group or the whole
4878/// run's outcomes; the result is the same records either way.
4879fn build_completed_updates(
4880    entries: &[&ManifestEntry],
4881    outcomes: &[EntryOutcome],
4882    out_root: &Path,
4883) -> BTreeMap<String, bundle_manifest::SavedFile> {
4884    let mut updates = BTreeMap::new();
4885    for (en, outcome) in entries.iter().zip(outcomes) {
4886        let recordable = matches!(
4887            outcome,
4888            EntryOutcome::Fetched(_) | EntryOutcome::Linked | EntryOutcome::Skipped
4889        );
4890        if recordable && let Some(rec) = record_one(out_root, en) {
4891            updates.insert(en.path.clone(), rec);
4892        }
4893    }
4894    updates
4895}
4896
4897/// Completed files may accumulate up to this many before the skip-cache is
4898/// rewritten, bounding how much a later run re-hashes to recover after an
4899/// interruption (and how often a many-file pull rewrites the cache).
4900const FLUSH_FILES: usize = 64;
4901/// Newly-fetched bytes may land up to this many before the skip-cache is
4902/// rewritten, so a long single-file transfer still checkpoints its completed
4903/// siblings without waiting for [`FLUSH_FILES`].
4904const FLUSH_BYTES: u64 = 1 << 30; // 1 GiB
4905
4906/// One completed group's contribution to the skip-cache flush: the records to
4907/// fold in, and the new content bytes it fetched (which drive the byte cadence —
4908/// a link or skip records a file but lands no new bytes).
4909struct FlushBatch {
4910    updates: BTreeMap<String, bundle_manifest::SavedFile>,
4911    fetched_bytes: u64,
4912}
4913
4914/// Fold each completed-group [`FlushBatch`] onto `acc` and rewrite the skip-cache
4915/// atomically whenever ≥[`FLUSH_FILES`] files or ≥[`FLUSH_BYTES`] new bytes have
4916/// accumulated since the last write, plus one final write when the channel closes
4917/// so the last partial batch is always persisted. This is the sole writer of the
4918/// skip-cache for the run: a single consumer, so its rewrites never race.
4919///
4920/// The skip-cache is advisory — a flush failure is logged and never propagated.
4921/// The blocking filesystem write runs on a blocking task with owned bytes, so the
4922/// caller's `join!` keeps driving fetches while a flush is in flight.
4923async fn flush_task(
4924    out_root: &Path,
4925    mut acc: SavedManifest,
4926    mut rx: tokio::sync::mpsc::UnboundedReceiver<FlushBatch>,
4927) {
4928    let mut files_since = 0usize;
4929    let mut bytes_since = 0u64;
4930    let mut dirty = false;
4931    while let Some(batch) = rx.recv().await {
4932        files_since += batch.updates.len();
4933        bytes_since = bytes_since.saturating_add(batch.fetched_bytes);
4934        bundle_manifest::merge(&mut acc, batch.updates);
4935        dirty = true;
4936        if (files_since >= FLUSH_FILES || bytes_since >= FLUSH_BYTES)
4937            && flush_now(out_root, &acc).await
4938        {
4939            // Only clear on a successful write. A failed cadence flush keeps
4940            // `dirty` set and the counters over threshold, so the next batch
4941            // retries and the final write below still runs at close — the last
4942            // accumulated updates are never dropped by a transient write error.
4943            files_since = 0;
4944            bytes_since = 0;
4945            dirty = false;
4946        }
4947    }
4948    // Persist whatever landed since the last successful flush (or the only batch of
4949    // a small run). `dirty` stays false only when the last cadence flush already
4950    // wrote everything, so a clean run adds no redundant final write.
4951    if dirty {
4952        flush_now(out_root, &acc).await;
4953    }
4954}
4955
4956/// Serialize `acc` (fast, in-memory) and write it atomically on a blocking task.
4957/// Returns whether the write succeeded so the caller can retry a failed cadence
4958/// flush at close. Advisory: every failure is logged, never returned as an error.
4959async fn flush_now(out_root: &Path, acc: &SavedManifest) -> bool {
4960    let bytes = match bundle_manifest::serialize(acc) {
4961        Ok(b) => b,
4962        Err(e) => {
4963            tracing::warn!(
4964                "failed to serialize {}: {e}",
4965                bundle_manifest::SAVED_MANIFEST_NAME
4966            );
4967            return false;
4968        }
4969    };
4970    let root = out_root.to_path_buf();
4971    match tokio::task::spawn_blocking(move || bundle_manifest::write_bytes(&root, &bytes)).await {
4972        Ok(Ok(())) => true,
4973        Ok(Err(e)) => {
4974            tracing::warn!(
4975                "failed to write {}: {e}",
4976                bundle_manifest::SAVED_MANIFEST_NAME
4977            );
4978            false
4979        }
4980        Err(e) => {
4981            tracing::warn!("skip-cache flush task panicked: {e}");
4982            false
4983        }
4984    }
4985}
4986
4987/// Why a pull ended with no entries to fetch — picks the message
4988/// [`report_nothing_to_fetch`] prints, so the operator learns the actual cause
4989/// (an empty bundle, globs that matched nothing, or a fully deselected list)
4990/// rather than mistaking one for another.
4991#[derive(Clone, Copy)]
4992enum NothingReason {
4993    /// The bundle manifest itself is empty.
4994    EmptyBundle,
4995    /// `--include`/`--exclude` removed every entry.
4996    Filtered,
4997    /// The user deselected every entry in the `--select` editor.
4998    Deselected,
4999}
5000
5001impl NothingReason {
5002    /// The reason for an empty result of a filter pass: [`Filtered`] when a filter
5003    /// was given and the bundle was non-empty before it, else [`EmptyBundle`].
5004    ///
5005    /// [`Filtered`]: NothingReason::Filtered
5006    /// [`EmptyBundle`]: NothingReason::EmptyBundle
5007    const fn from_filter(filters_given: bool, raw_empty: bool) -> Self {
5008        if filters_given && !raw_empty {
5009            Self::Filtered
5010        } else {
5011            Self::EmptyBundle
5012        }
5013    }
5014}
5015
5016fn report_nothing_to_fetch(reason: NothingReason) {
5017    let msg = match reason {
5018        NothingReason::EmptyBundle => "bundle has no entries; nothing to fetch",
5019        NothingReason::Filtered => {
5020            "no bundle entries match the include/exclude filters; nothing to fetch"
5021        }
5022        NothingReason::Deselected => "every file was deselected; nothing to fetch",
5023    };
5024    println!("{msg}");
5025}
5026
5027/// The manifest a dry run can read with no network: the `-i` file, or the
5028/// `--hash` bundle's copy in the output root's [`bundle_cache`]. `None` when
5029/// `--hash` has no cached copy. Errors on a malformed `--hash`, an unreadable
5030/// `-i` file, a cache file that cannot be read or fails its hash check (naming
5031/// its path), or bytes that do not parse as a v1 manifest. The cache is read even under
5032/// `--overwrite`, which makes a real pull fetch the manifest again: the cache
5033/// is content-addressed, so its bytes are exactly what that fetch returns.
5034fn dry_run_manifest(args: &BundlePullArgs) -> anyhow::Result<Option<Manifest>> {
5035    match (&args.input, &args.hash) {
5036        (Some(path), _) => read_local_manifest(path).map(Some),
5037        (None, Some(h)) => {
5038            let hash = fetch::parse_hash(h)?;
5039            bundle_cache::load(&args.output, hash)?
5040                .map(|bytes| {
5041                    parse_manifest(&bytes).with_context(|| {
5042                        format!(
5043                            "cached bundle manifest {}",
5044                            bundle_cache::cache_path(&args.output, hash).display()
5045                        )
5046                    })
5047                })
5048                .transpose()
5049        }
5050        (None, None) => bail!("no bundle source (expected -i or --hash)"),
5051    }
5052}
5053
5054/// Print the would-fetch plan and exit (no network/chain/keystore activity).
5055/// The plan lists the entries of the manifest [`dry_run_manifest`] reads: the
5056/// `-i` file, or a `--hash` bundle's cached manifest. The `--include`/`--exclude`
5057/// `filter` applies to those entries, so the plan is the entry set a real run
5058/// starts from and the unmatched-pattern warnings are the ones it prints. A real
5059/// run then skips files already present in the output root (unless
5060/// `--overwrite`) and warns on leftover partials; the dry run does neither. A
5061/// `--hash` bundle with no cached manifest reports only the bundle and the
5062/// output root, with `entries: null` under `--json`.
5063fn dry_run(args: &BundlePullArgs, filter: &EntryFilter, filters_given: bool) -> anyhow::Result<()> {
5064    let out = args.output.display();
5065    let Some(mut manifest) = dry_run_manifest(args)? else {
5066        let h = args.hash.as_deref().unwrap_or_default();
5067        if args.json {
5068            let plan = serde_json::json!({
5069                "output": out.to_string(),
5070                "hash": h,
5071                "count": null,
5072                "entries": null,
5073            });
5074            println!("{plan}");
5075            return Ok(());
5076        }
5077        let filter_note = if filters_given {
5078            "; the include/exclude patterns are matched against them then"
5079        } else {
5080            ""
5081        };
5082        println!(
5083            "--dry-run with --hash: would fetch bundle {h} then its entries into {out} \
5084             (no cached manifest: entries are listed once a pull into this directory \
5085             caches it{filter_note})"
5086        );
5087        return Ok(());
5088    };
5089    let raw_empty = manifest.entries.is_empty();
5090    manifest.entries = filter.apply_and_warn(manifest.entries).0;
5091    if args.json {
5092        // The `--json` plan stays machine-readable — an empty set is
5093        // `count: 0` with an empty `entries` array, no prose line. A `--hash`
5094        // bundle with no cached manifest gives `count`/`entries` of `null`.
5095        let plan = serde_json::json!({
5096            "output": args.output.display().to_string(),
5097            "count": manifest.entries.len(),
5098            "entries": manifest.entries.iter().map(|e| serde_json::json!({
5099                "path": e.path, "hash": e.hash, "size": e.size,
5100                "chunks": e.chunks.as_ref().map(Vec::len),
5101            })).collect::<Vec<_>>(),
5102        });
5103        println!("{plan}");
5104    } else if manifest.entries.is_empty() {
5105        // Match the real run's empty-result message rather than printing a
5106        // "would fetch 0 entr(ies)" plan, so `--dry-run` and a live pull
5107        // agree on what an emptied set looks like.
5108        report_nothing_to_fetch(NothingReason::from_filter(filters_given, raw_empty));
5109    } else {
5110        println!(
5111            "would fetch {} entr(ies) into {out}:",
5112            manifest.entries.len()
5113        );
5114        for e in &manifest.entries {
5115            let chunks = e
5116                .chunks
5117                .as_ref()
5118                .map(|c| format!(", {} chunks", c.len()))
5119                .unwrap_or_default();
5120            match e.size {
5121                Some(n) => println!("  {} ({n} bytes{chunks})", e.path),
5122                None => println!("  {}{}", e.path, chunks),
5123            }
5124        }
5125    }
5126    Ok(())
5127}
5128
5129/// Summarize outcomes; return an error if any entry failed (after reporting all).
5130/// Each of `warnings` ([`size_warnings`]) prints one line on stderr and never
5131/// changes the result. `excluded` is the count of entries the run does not pull
5132/// ([`PullReport`]).
5133fn report(
5134    outcomes: &[EntryOutcome],
5135    warnings: &[String],
5136    transfer: Transfer,
5137    dedup: DedupSummary,
5138    excluded: u64,
5139    output: &Path,
5140    json: bool,
5141) -> anyhow::Result<()> {
5142    for o in outcomes {
5143        if let EntryOutcome::Failed { path, err } = o {
5144            // A per-entry failure is a command result the user needs, not
5145            // routing narration: keep it on stderr (unconditional, and clear
5146            // of the `--json` report on stdout) rather than behind logging.
5147            eprintln!("failed: {path}: {err}");
5148        }
5149    }
5150    for line in warnings {
5151        eprintln!("warning: {line}");
5152    }
5153
5154    let rep = pull_report(outcomes, transfer, dedup, excluded, output);
5155    let (reused, reused_bytes, failed) = (rep.reused, rep.reused_bytes, rep.failed);
5156    if json {
5157        let line = serde_json::to_string(&rep).map_err(|e| anyhow!("serialize report: {e}"))?;
5158        println!("{line}");
5159    } else {
5160        println!("{}", counts_line(&rep));
5161        // `downloaded X → reconstructed Y` only when dedup made them differ;
5162        // otherwise a single `downloaded X`.
5163        println!("{}", transfer_line(transfer));
5164        // The bytes earlier, interrupted runs fetched and this run resumed, shown
5165        // only when a run resumed any: they are in neither total above.
5166        if transfer.resumed > 0 {
5167            println!(
5168                "resumed {} from earlier partials",
5169                human_bytes(transfer.resumed)
5170            );
5171        }
5172        // The whole-file dedup outcome, shown only when it mattered: a run that
5173        // materialized any destination from an on-disk donor instead of fetching.
5174        if reused > 0 {
5175            println!(
5176                "whole-file dedup: reused {} from disk ({reused} file(s))",
5177                human_bytes(reused_bytes)
5178            );
5179        }
5180        // The range-dedup outcome, shown only when a hint mattered: a run that
5181        // spliced bytes from disk, or one whose hints were dropped by a fault. A
5182        // plain pull with no usable hints stays silent (both are zero), so a
5183        // hint-carrying bundle whose hints all lie (spliced 0, some ignored) no
5184        // longer prints the same summary as an unhinted one.
5185        if dedup.spliced_bytes > 0 || dedup.hints_ignored > 0 {
5186            println!(
5187                "range-dedup: spliced {} from disk, {} hint(s) ignored",
5188                human_bytes(dedup.spliced_bytes),
5189                dedup.hints_ignored
5190            );
5191        }
5192    }
5193
5194    if failed > 0 {
5195        bail!("{failed} entr(ies) failed to fetch");
5196    }
5197    Ok(())
5198}
5199
5200/// The run's [`PullReport`]: the entry counts tallied from `outcomes`, beside
5201/// the byte tallies and the `excluded` count the caller already holds.
5202fn pull_report(
5203    outcomes: &[EntryOutcome],
5204    transfer: Transfer,
5205    dedup: DedupSummary,
5206    excluded: u64,
5207    output: &Path,
5208) -> PullReport {
5209    let mut rep = PullReport {
5210        output: output.display().to_string(),
5211        fetched: 0,
5212        linked: 0,
5213        skipped: 0,
5214        excluded,
5215        failed: 0,
5216        downloaded: transfer.downloaded,
5217        reconstructed: transfer.reconstructed,
5218        spliced_bytes: dedup.spliced_bytes,
5219        hints_ignored: dedup.hints_ignored,
5220        resumed_bytes: transfer.resumed,
5221        reused: 0,
5222        reused_bytes: 0,
5223    };
5224    for o in outcomes {
5225        match o {
5226            EntryOutcome::Fetched(_) => rep.fetched += 1,
5227            EntryOutcome::Linked => rep.linked += 1,
5228            EntryOutcome::Skipped => rep.skipped += 1,
5229            EntryOutcome::Deduped(n) => {
5230                rep.reused += 1;
5231                rep.reused_bytes = rep.reused_bytes.saturating_add(*n);
5232            }
5233            EntryOutcome::Failed { .. } => rep.failed += 1,
5234        }
5235    }
5236    rep
5237}
5238
5239/// The summary's first line: where the run pulled to, and every entry count.
5240fn counts_line(rep: &PullReport) -> String {
5241    format!(
5242        "pulled into {} ({} fetched, {} linked, {} skipped, {} reused, {} excluded, {} failed)",
5243        rep.output, rep.fetched, rep.linked, rep.skipped, rep.reused, rep.excluded, rep.failed
5244    )
5245}
5246
5247/// Scan `<out_root>/.decdn-partial` for leftover per-hash staging files —
5248/// `<hex>`, `<hex>.partial`, `<hex>.partial.ranges` — whose hash is not in
5249/// `keep`, and total the disk space they take ([`allocated_bytes`]). Other
5250/// names (a record's temporary file) are not counted. A missing or unreadable
5251/// directory holds nothing.
5252fn leftover_partials(out_root: &Path, keep: &HashSet<[u8; 32]>) -> Leftovers {
5253    let Ok(dir) = std::fs::read_dir(out_root.join(STAGING_DIR)) else {
5254        return Leftovers::default();
5255    };
5256    let mut blobs: HashSet<[u8; 32]> = HashSet::new();
5257    let mut bytes = 0u64;
5258    for entry in dir.flatten() {
5259        let name = entry.file_name();
5260        let Some(name) = name.to_str() else {
5261            continue;
5262        };
5263        let stem = name
5264            .strip_suffix(".partial.ranges")
5265            .or_else(|| name.strip_suffix(".partial"))
5266            .unwrap_or(name);
5267        let Ok(hash) = blake3::Hash::from_hex(stem) else {
5268            continue;
5269        };
5270        let hash = *hash.as_bytes();
5271        if keep.contains(&hash) {
5272            continue;
5273        }
5274        let Ok(meta) = entry.metadata() else {
5275            continue;
5276        };
5277        if meta.is_file() {
5278            blobs.insert(hash);
5279            bytes = bytes.saturating_add(allocated_bytes(&meta));
5280        }
5281    }
5282    Leftovers {
5283        blobs: u64::try_from(blobs.len()).unwrap_or(u64::MAX),
5284        bytes,
5285    }
5286}
5287
5288/// The disk space a file takes. A range-dedup `.partial` is sized to the whole
5289/// blob before any byte lands, so its length can be far above the space that
5290/// deleting it frees. On Unix the allocated blocks give that space; elsewhere
5291/// the length is the best figure.
5292fn allocated_bytes(meta: &std::fs::Metadata) -> u64 {
5293    #[cfg(unix)]
5294    {
5295        use std::os::unix::fs::MetadataExt;
5296        meta.blocks().saturating_mul(512)
5297    }
5298    #[cfg(not(unix))]
5299    {
5300        meta.len()
5301    }
5302}
5303
5304/// Staging files in the staging directory that a run did not use: how many
5305/// distinct blobs, and the disk space their files take.
5306#[derive(Debug, Default, PartialEq, Eq)]
5307struct Leftovers {
5308    blobs: u64,
5309    bytes: u64,
5310}
5311
5312/// The warning line for `leftovers` in `dir`, without the `warning: ` prefix,
5313/// or `None` when there are none.
5314fn leftover_warning(dir: &Path, leftovers: &Leftovers) -> Option<String> {
5315    let size = human_bytes(leftovers.bytes);
5316    let dir = dir.display();
5317    match leftovers.blobs {
5318        0 => None,
5319        1 => Some(format!(
5320            "1 partial download ({size} on disk) that this run did not use remains in \
5321             {dir}; delete it to reclaim the space"
5322        )),
5323        n => Some(format!(
5324            "{n} partial downloads ({size} on disk) that this run did not use remain in \
5325             {dir}; delete them to reclaim the space"
5326        )),
5327    }
5328}
5329
5330/// Warn on stderr about leftover staging files of blobs this run did not use
5331/// ([`leftover_partials`]): a partial of an entry an earlier run started,
5332/// which this run's filter or selection left out, or one another bundle
5333/// pulled into the same output directory left. They stay for a later run that
5334/// selects the entry again, so the warning only names them and never deletes
5335/// them. `entries` are the run's selected entries (none when the filter or
5336/// `--select` emptied the run), and `bundle_hash` is the `--hash` manifest
5337/// blob, which is also this run's own.
5338fn warn_leftover_partials(out_root: &Path, entries: &[ManifestEntry], bundle_hash: Option<&str>) {
5339    let keep: HashSet<[u8; 32]> = entries
5340        .iter()
5341        .map(|e| e.hash.as_str())
5342        .chain(bundle_hash)
5343        .filter_map(|h| fetch::parse_hash(h).ok())
5344        .collect();
5345    let leftovers = leftover_partials(out_root, &keep);
5346    if let Some(line) = leftover_warning(&out_root.join(STAGING_DIR), &leftovers) {
5347        eprintln!("warning: {line}");
5348    }
5349}
5350
5351/// Format a byte count as a short decimal-unit label (`13.8 GB`). SI (1000-based)
5352/// units match how file and model sizes are usually quoted.
5353fn human_bytes(n: u64) -> String {
5354    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
5355    if n < 1000 {
5356        return format!("{n} B");
5357    }
5358    // Precision loss is irrelevant here: the result is a one-decimal human label.
5359    #[allow(
5360        clippy::cast_precision_loss,
5361        reason = "display-only size label; exact integer value is not needed"
5362    )]
5363    let mut value = n as f64;
5364    let mut unit = 0usize;
5365    while value >= 1000.0 && unit + 1 < UNITS.len() {
5366        value /= 1000.0;
5367        unit += 1;
5368    }
5369    let label = UNITS.get(unit).copied().unwrap_or("B");
5370    format!("{value:.1} {label}")
5371}
5372
5373/// The end-of-pull transfer line. When dedup saved a transfer the two totals
5374/// differ and both are shown with an arrow; otherwise a single `downloaded X`
5375/// (the `→ reconstructed` half is omitted rather than repeating the same figure).
5376fn transfer_line(t: Transfer) -> String {
5377    if t.downloaded == t.reconstructed {
5378        format!("downloaded {}", human_bytes(t.downloaded))
5379    } else {
5380        format!(
5381            "downloaded {} → reconstructed {}",
5382            human_bytes(t.downloaded),
5383            human_bytes(t.reconstructed)
5384        )
5385    }
5386}
5387
5388/// The [`Transfer`] for one whole-file hash-group: the blob is either paid for
5389/// once (`downloaded` = the content bytes its fetch paid for this run, from
5390/// `bytes`, else the size of the single `Fetched`; `resumed` = the bytes it
5391/// resumed from an earlier run) or reused from an on-disk donor with no download
5392/// (`Deduped`, contributing 0 to `downloaded`) — a group never mixes the two,
5393/// since [`PullCtx::fetch_group`] takes one path or the other. Every materialized
5394/// copy — the canonical (`Fetched` or `Deduped`) plus each `Linked` duplicate
5395/// path — is a full file on disk (`reconstructed` = size × copies). A group with
5396/// nothing written (all skipped or failed) contributes nothing.
5397fn group_transfer(outcomes: &[EntryOutcome], bytes: Option<EntryBytes>) -> Transfer {
5398    let paid_size = outcomes.iter().find_map(|o| match o {
5399        EntryOutcome::Fetched(n) => Some(*n),
5400        _ => None,
5401    });
5402    let reused_size = outcomes.iter().find_map(|o| match o {
5403        EntryOutcome::Deduped(n) => Some(*n),
5404        _ => None,
5405    });
5406    match paid_size.or(reused_size) {
5407        Some(n) => {
5408            let copies = outcomes
5409                .iter()
5410                .filter(|o| {
5411                    matches!(
5412                        o,
5413                        EntryOutcome::Fetched(_) | EntryOutcome::Linked | EntryOutcome::Deduped(_)
5414                    )
5415                })
5416                .count();
5417            let copies = u64::try_from(copies).unwrap_or(u64::MAX);
5418            Transfer {
5419                downloaded: paid_size.map_or(0, |size| bytes.map_or(size, |b| b.paid)),
5420                reconstructed: n.saturating_mul(copies),
5421                resumed: paid_size.and(bytes).map_or(0, |b| b.resumed),
5422            }
5423        }
5424        None => Transfer::default(),
5425    }
5426}
5427
5428#[cfg(test)]
5429#[allow(
5430    clippy::unwrap_used,
5431    clippy::expect_used,
5432    clippy::indexing_slicing,
5433    clippy::panic
5434)]
5435mod tests;