Skip to main content

wavekat_asr/
download.rs

1//! Generic HuggingFace download helper with byte-level progress.
2//!
3//! This module is backend-agnostic: any backend whose model weights live
4//! on HuggingFace Hub can call [`download_files_with_progress`] to fetch
5//! them into a caller-chosen directory while a `FnMut` closure receives
6//! [`DownloadProgress`] updates as bytes arrive.
7//!
8//! Today only the [`crate::backends::sherpa_onnx`] backend uses this —
9//! `sherpa_onnx::download_preset_with_progress` is a thin wrapper that
10//! turns a `ModelPreset` into a `(repo_id, &[&str])` call into here. A
11//! future Whisper / Qwen3-ASR / etc. backend can do the same.
12//!
13//! Paired with [`is_repo_cached`], a pure-filesystem probe that uses
14//! the same `(repo_id, files)` pair to answer "are these already on
15//! disk?" without touching the network — for consumers that need to
16//! decide between rendering a "Download" affordance and a "Ready"
17//! state in a UI hot path.
18//!
19//! For networks where `huggingface.co` is unreachable,
20//! [`download_pinned_to_cache`] fetches a pinned snapshot from an
21//! ordered list of [`DownloadSource`]s (HuggingFace, or any mirror that
22//! serves the same `resolve` URL layout), verifies each file's SHA-256,
23//! and writes the result into the same cache `hf-hub` reads.
24//!
25//! Gated behind the `download` Cargo feature; the `sherpa-onnx` feature
26//! turns it on transitively.
27
28use std::path::{Path, PathBuf};
29
30use crate::AsrError;
31
32/// Byte-level progress for one file inside a multi-file download.
33///
34/// Passed to the callback of [`download_files_with_progress`]. Fires at
35/// the start of each file (`bytes_done = 0`), repeatedly during streaming
36/// as bytes arrive, and once on completion (`bytes_done == bytes_total`).
37#[derive(Debug, Clone)]
38pub struct DownloadProgress {
39    /// Filename inside the HuggingFace repo.
40    pub file: String,
41    /// 1-indexed position of `file` within the batch, paired with
42    /// [`file_count`](Self::file_count) so a UI can render
43    /// "file 2 of 4".
44    pub file_index: usize,
45    /// Total files in the batch.
46    pub file_count: usize,
47    /// Bytes downloaded so far for the current file. Resets per file.
48    pub bytes_done: u64,
49    /// Total bytes for the current file, reported by HuggingFace before
50    /// streaming begins. `None` only for the very first call before
51    /// metadata is known.
52    pub bytes_total: Option<u64>,
53}
54
55/// Download every file in `files` from `repo_id` on HuggingFace Hub into
56/// `dest_dir`, reporting byte progress as it goes.
57///
58/// On success, `dest_dir` contains every filename in `files` and is
59/// directly loadable by a backend that consumes the files from a flat
60/// directory. `dest_dir` is created if missing; existing files with the
61/// same name are overwritten so a partial previous run retries cleanly.
62///
63/// `on_progress` runs synchronously on the calling thread inside the
64/// hf-hub download loop — keep it cheap (channel send, atomic store) and
65/// don't block on it.
66///
67/// hf-hub's own cache (controlled by the `HF_HOME` env var) still gets
68/// populated as a side effect; callers that only want the files in
69/// `dest_dir` can ignore it.
70pub fn download_files_with_progress<F>(
71    repo_id: &str,
72    files: &[&str],
73    dest_dir: &Path,
74    mut on_progress: F,
75) -> Result<(), AsrError>
76where
77    F: FnMut(DownloadProgress),
78{
79    use hf_hub::api::sync::Api;
80
81    std::fs::create_dir_all(dest_dir).map_err(|e| {
82        AsrError::Backend(format!(
83            "creating model directory {}: {e}",
84            dest_dir.display()
85        ))
86    })?;
87
88    let file_count = files.len();
89
90    let api = Api::new().map_err(|e| AsrError::Backend(format!("hf-hub init failed: {e}")))?;
91    let repo = api.model(repo_id.to_string());
92
93    for (idx, name) in files.iter().enumerate() {
94        let file_index = idx + 1;
95
96        // Fresh adapter per file: it borrows `on_progress` for the
97        // duration of one `download_with_progress` call, so the next
98        // iteration can re-borrow it for the next file.
99        let adapter = CallbackProgress {
100            file: (*name).to_string(),
101            file_index,
102            file_count,
103            bytes_done: 0,
104            bytes_total: None,
105            on_progress: &mut on_progress,
106        };
107
108        tracing::debug!(
109            repo_id,
110            file = name,
111            file_index,
112            file_count,
113            "fetching from HuggingFace with progress"
114        );
115
116        let src = repo
117            .download_with_progress(name, adapter)
118            .map_err(|e| AsrError::Backend(format!("hf-hub download of {name} failed: {e}")))?;
119
120        let dest = dest_dir.join(name);
121        // copy, not rename — the hf-hub blob is shared with its cache;
122        // the user gets their own copy under `dest_dir` so they can move
123        // / delete it without breaking the cache.
124        std::fs::copy(&src, &dest).map_err(|e| {
125            AsrError::Backend(format!(
126                "copying {} to {}: {e}",
127                src.display(),
128                dest.display()
129            ))
130        })?;
131    }
132
133    Ok(())
134}
135
136/// Bridge between hf-hub's `Progress` trait and our
137/// `FnMut(DownloadProgress)` callback. Borrows the user's closure so a
138/// single closure can be reused across every file in the batch.
139struct CallbackProgress<'a, F: FnMut(DownloadProgress)> {
140    file: String,
141    file_index: usize,
142    file_count: usize,
143    bytes_done: u64,
144    bytes_total: Option<u64>,
145    on_progress: &'a mut F,
146}
147
148impl<F: FnMut(DownloadProgress)> CallbackProgress<'_, F> {
149    fn emit(&mut self) {
150        (self.on_progress)(DownloadProgress {
151            file: self.file.clone(),
152            file_index: self.file_index,
153            file_count: self.file_count,
154            bytes_done: self.bytes_done,
155            bytes_total: self.bytes_total,
156        });
157    }
158}
159
160impl<F: FnMut(DownloadProgress)> hf_hub::api::Progress for CallbackProgress<'_, F> {
161    fn init(&mut self, size: usize, _filename: &str) {
162        self.bytes_total = Some(size as u64);
163        self.bytes_done = 0;
164        self.emit();
165    }
166
167    fn update(&mut self, size: usize) {
168        // hf-hub passes the chunk size, not the cumulative total.
169        self.bytes_done = self.bytes_done.saturating_add(size as u64);
170        self.emit();
171    }
172
173    fn finish(&mut self) {
174        // Make sure the last emitted value is the file's full size;
175        // some backends short the final `update` call.
176        if let Some(total) = self.bytes_total {
177            if self.bytes_done < total {
178                self.bytes_done = total;
179                self.emit();
180            }
181        }
182    }
183}
184
185// ----- Pinned downloads from any HF-layout host ---------------------------
186
187/// One file of a pinned model snapshot: its name inside the repo plus
188/// the exact size and SHA-256 it must have.
189///
190/// Pinning makes a download verifiable independent of where the bytes
191/// came from, which is what lets [`download_pinned_to_cache`] fall back
192/// to a mirror without trusting it.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub struct PinnedFile {
195    /// Filename inside the repo.
196    pub name: &'static str,
197    /// Lowercase hex SHA-256 of the file's contents.
198    pub sha256: &'static str,
199    /// Exact size in bytes.
200    pub size: u64,
201}
202
203/// A host that serves repo files in the HuggingFace `resolve` layout:
204/// `{base_url}/{repo_id}/resolve/{revision}/{file}`.
205///
206/// HuggingFace itself is one such host; a mirror (a community one, or a
207/// static bucket populated with the same paths) is another. Useful for
208/// networks where `huggingface.co` is unreachable.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct DownloadSource {
211    base_url: String,
212}
213
214impl DownloadSource {
215    /// Base URL of HuggingFace Hub.
216    pub const HUGGING_FACE_URL: &'static str = "https://huggingface.co";
217
218    /// HuggingFace Hub itself.
219    pub fn hugging_face() -> Self {
220        Self::mirror(Self::HUGGING_FACE_URL)
221    }
222
223    /// Any other host serving the HuggingFace `resolve` layout under
224    /// `base_url`. A trailing slash is ignored.
225    pub fn mirror(base_url: impl Into<String>) -> Self {
226        let base_url = base_url.into().trim_end_matches('/').to_string();
227        Self { base_url }
228    }
229
230    /// Base URL, without a trailing slash.
231    pub fn base_url(&self) -> &str {
232        &self.base_url
233    }
234
235    /// Full URL of `file` at `revision` in `repo_id` on this host.
236    pub fn file_url(&self, repo_id: &str, revision: &str, file: &str) -> String {
237        format!("{}/{repo_id}/resolve/{revision}/{file}", self.base_url)
238    }
239}
240
241/// How long to wait for a TCP/TLS connection before giving up on a
242/// source. Short on purpose: a blocked host should fall through to the
243/// next source quickly instead of stalling the download.
244const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
245/// How long to wait for response headers once connected.
246const RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
247
248/// Download a pinned snapshot of `repo_id` at `revision` into the local
249/// HuggingFace Hub cache, trying `sources` in order and verifying every
250/// file's size and SHA-256.
251///
252/// Files land where `hf-hub` itself would look for them
253/// (`<cache>/models--<owner>--<repo>/snapshots/<revision>/<file>`, with
254/// `refs/main` pointing at `revision`), so after this returns
255/// [`is_repo_cached`] reports `true` and a backend that resolves its
256/// files through `hf-hub` finds them without touching the network.
257///
258/// Source fallback is sticky: when a source fails (connection error,
259/// HTTP error, or a file that doesn't match its pin) the current file is
260/// retried from the next source, and later files start from that source
261/// too — a host that refused one connection is unlikely to accept the
262/// next. Files already present at their pinned size are skipped, so an
263/// interrupted run resumes at file granularity.
264///
265/// `on_progress` fires as in [`download_files_with_progress`];
266/// `bytes_total` is always the pinned size.
267pub fn download_pinned_to_cache<F>(
268    repo_id: &str,
269    revision: &str,
270    files: &[PinnedFile],
271    sources: &[DownloadSource],
272    on_progress: F,
273) -> Result<PathBuf, AsrError>
274where
275    F: FnMut(DownloadProgress),
276{
277    let cache_root = hf_hub_cache_root()
278        .ok_or_else(|| AsrError::Backend("cannot locate the HuggingFace cache directory".into()))?;
279    download_pinned_into(&cache_root, repo_id, revision, files, sources, on_progress)
280}
281
282/// [`download_pinned_to_cache`] against an explicit cache root, so tests
283/// can use a temp directory.
284fn download_pinned_into<F>(
285    cache_root: &Path,
286    repo_id: &str,
287    revision: &str,
288    files: &[PinnedFile],
289    sources: &[DownloadSource],
290    mut on_progress: F,
291) -> Result<PathBuf, AsrError>
292where
293    F: FnMut(DownloadProgress),
294{
295    let repo_dir = cache_root.join(repo_folder_name(repo_id));
296    let snapshot = repo_dir.join("snapshots").join(revision);
297    let file_count = files.len();
298    let agent = http_agent();
299    let mut source_idx = 0;
300    let mut failures: Vec<String> = Vec::new();
301
302    for (idx, file) in files.iter().enumerate() {
303        let dest = snapshot.join(file.name);
304        let emit = |on_progress: &mut F, bytes_done: u64| {
305            on_progress(DownloadProgress {
306                file: file.name.to_string(),
307                file_index: idx + 1,
308                file_count,
309                bytes_done,
310                bytes_total: Some(file.size),
311            })
312        };
313
314        if std::fs::metadata(&dest).is_ok_and(|m| m.is_file() && m.len() == file.size) {
315            emit(&mut on_progress, file.size);
316            continue;
317        }
318
319        loop {
320            let Some(source) = sources.get(source_idx) else {
321                return Err(AsrError::Backend(if failures.is_empty() {
322                    "no download source configured".to_string()
323                } else {
324                    format!(
325                        "download of {} failed from every source: {}",
326                        file.name,
327                        failures.join("; ")
328                    )
329                }));
330            };
331            let url = source.file_url(repo_id, revision, file.name);
332            tracing::debug!(
333                url,
334                file_index = idx + 1,
335                file_count,
336                "fetching pinned file"
337            );
338            match fetch_verified(&agent, &url, &dest, file, |done| {
339                emit(&mut on_progress, done)
340            }) {
341                Ok(()) => break,
342                Err(FetchError::Local(err)) => return Err(err),
343                Err(FetchError::Remote(msg)) => {
344                    tracing::warn!(url, error = %msg, "download source failed, trying the next one");
345                    failures.push(format!("{}: {msg}", source.base_url()));
346                    source_idx += 1;
347                }
348            }
349        }
350    }
351
352    let refs = repo_dir.join("refs");
353    std::fs::create_dir_all(&refs)?;
354    std::fs::write(refs.join("main"), revision)?;
355    Ok(snapshot)
356}
357
358/// Why fetching one file failed: the source's fault (try another one)
359/// or ours (a disk error that no other source would fix).
360#[derive(Debug)]
361enum FetchError {
362    Remote(String),
363    Local(AsrError),
364}
365
366impl From<std::io::Error> for FetchError {
367    fn from(err: std::io::Error) -> Self {
368        FetchError::Local(AsrError::Io(err))
369    }
370}
371
372/// Stream `url` into `dest` via a `.part` file, hashing as it goes, and
373/// move it into place only if size and SHA-256 match `file`.
374fn fetch_verified(
375    agent: &ureq::Agent,
376    url: &str,
377    dest: &Path,
378    file: &PinnedFile,
379    mut on_bytes: impl FnMut(u64),
380) -> Result<(), FetchError> {
381    use sha2::{Digest, Sha256};
382    use std::io::{Read, Write};
383
384    if let Some(parent) = dest.parent() {
385        std::fs::create_dir_all(parent)?;
386    }
387    let part = dest.with_file_name(format!(
388        "{}.part",
389        dest.file_name()
390            .and_then(|n| n.to_str())
391            .unwrap_or("download")
392    ));
393
394    on_bytes(0);
395    let mut response = agent
396        .get(url)
397        .call()
398        .map_err(|e| FetchError::Remote(e.to_string()))?;
399    let mut reader = response.body_mut().as_reader();
400
401    let result = (|| {
402        let mut out = std::fs::File::create(&part)?;
403        let mut hasher = Sha256::new();
404        let mut buf = vec![0u8; 64 * 1024];
405        let mut done: u64 = 0;
406        loop {
407            let n = reader
408                .read(&mut buf)
409                .map_err(|e| FetchError::Remote(e.to_string()))?;
410            if n == 0 {
411                break;
412            }
413            done += n as u64;
414            if done > file.size {
415                return Err(FetchError::Remote(format!(
416                    "{} is larger than expected ({} bytes)",
417                    file.name, file.size
418                )));
419            }
420            hasher.update(&buf[..n]);
421            out.write_all(&buf[..n])?;
422            on_bytes(done);
423        }
424        out.flush()?;
425        drop(out);
426        if done != file.size {
427            return Err(FetchError::Remote(format!(
428                "{} is incomplete ({done} of {} bytes)",
429                file.name, file.size
430            )));
431        }
432        let digest = to_hex(&hasher.finalize());
433        if !digest.eq_ignore_ascii_case(file.sha256) {
434            return Err(FetchError::Remote(format!(
435                "{} failed its checksum (got {digest})",
436                file.name
437            )));
438        }
439        // Rename can't replace an existing file on every platform; a
440        // wrong-sized leftover is the only thing that could be there.
441        match std::fs::remove_file(dest) {
442            Ok(()) => {}
443            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
444            Err(e) => return Err(e.into()),
445        }
446        std::fs::rename(&part, dest)?;
447        Ok(())
448    })();
449    if result.is_err() {
450        let _ = std::fs::remove_file(&part);
451    }
452    result
453}
454
455fn http_agent() -> ureq::Agent {
456    use ureq::tls::{TlsConfig, TlsProvider};
457    ureq::Agent::config_builder()
458        .tls_config(
459            TlsConfig::builder()
460                .provider(TlsProvider::NativeTls)
461                .build(),
462        )
463        .timeout_connect(Some(CONNECT_TIMEOUT))
464        .timeout_recv_response(Some(RESPONSE_TIMEOUT))
465        .build()
466        .into()
467}
468
469fn to_hex(bytes: &[u8]) -> String {
470    use std::fmt::Write;
471    bytes
472        .iter()
473        .fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
474            let _ = write!(s, "{b:02x}");
475            s
476        })
477}
478
479/// `hf-hub`'s per-repo cache directory name: `models--<owner>--<repo>`.
480fn repo_folder_name(repo_id: &str) -> String {
481    format!("models--{}", repo_id.replace('/', "--"))
482}
483
484// ----- Offline cache probe -----------------------------------------------
485
486/// Returns `true` iff every file in `files` is already present in the
487/// local HuggingFace Hub cache for `repo_id`. Pure filesystem — no
488/// network metadata fetch, no download — so safe to call in a UI hot
489/// path that needs to decide between rendering a "Download" affordance
490/// and a "Ready" indicator.
491///
492/// The cache root is resolved the same way `hf-hub` 0.5 does:
493/// `$HF_HOME/hub` if `HF_HOME` is set, otherwise
494/// `$HOME/.cache/huggingface/hub`. Returns `false` if neither can be
495/// determined.
496///
497/// Pair with [`download_files_with_progress`] — the same `(repo_id,
498/// files)` pair you pass there can be passed here to ask "do I still
499/// need to download?".
500///
501/// # Why this exists
502///
503/// `hf-hub` 0.5 has no `HF_HUB_OFFLINE` support and no public
504/// "lookup-only" download API, so a naïve "try to construct the
505/// backend and see if it succeeds" probe will silently kick off a
506/// large download whenever the cache is cold. Consumers that want to
507/// stay offline during a UI render need a pure-filesystem alternative;
508/// this is it.
509pub fn is_repo_cached(repo_id: &str, files: &[&str]) -> bool {
510    let Some(cache_root) = hf_hub_cache_root() else {
511        return false;
512    };
513    snapshot_satisfies_files(&cache_root, repo_id, files)
514}
515
516/// Resolve the HF Hub on-disk cache root the same way `hf-hub` 0.5
517/// does. Honors `HF_HOME` (appending `hub`), then falls back to
518/// `$HOME/.cache/huggingface/hub`. Returns `None` only when neither
519/// env var is set — pathological for any normal Unix or Windows
520/// environment.
521fn hf_hub_cache_root() -> Option<PathBuf> {
522    if let Some(home) = std::env::var_os("HF_HOME") {
523        let mut p = PathBuf::from(home);
524        p.push("hub");
525        return Some(p);
526    }
527    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
528    let mut p = PathBuf::from(home);
529    p.extend([".cache", "huggingface", "hub"]);
530    Some(p)
531}
532
533/// Inner probe: walk `<cache_root>/models--<owner>--<repo>/snapshots/`
534/// and return `true` iff some snapshot dir contains every file in
535/// `files`. Factored out from [`is_repo_cached`] so unit tests can
536/// drive it against a temp directory without touching the user's
537/// real HF cache or polluting env vars.
538fn snapshot_satisfies_files(cache_root: &Path, repo_id: &str, files: &[&str]) -> bool {
539    let repo_dir = cache_root.join(repo_folder_name(repo_id));
540    let snapshots = repo_dir.join("snapshots");
541    let Ok(entries) = std::fs::read_dir(&snapshots) else {
542        return false;
543    };
544    for entry in entries.flatten() {
545        let snap = entry.path();
546        if !snap.is_dir() {
547            continue;
548        }
549        if files.iter().all(|f| snap.join(f).exists()) {
550            return true;
551        }
552    }
553    false
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    /// Exercises the hf-hub `Progress` bridge directly so we can assert
561    /// the callback contract without a real network download.
562    #[test]
563    fn callback_progress_reports_init_update_and_finish() {
564        use hf_hub::api::Progress;
565
566        let events = std::cell::RefCell::new(Vec::<DownloadProgress>::new());
567        let mut on_progress = |p: DownloadProgress| events.borrow_mut().push(p);
568
569        let mut adapter = CallbackProgress {
570            file: "encoder.onnx".to_string(),
571            file_index: 1,
572            file_count: 4,
573            bytes_done: 0,
574            bytes_total: None,
575            on_progress: &mut on_progress,
576        };
577
578        adapter.init(1000, "encoder.onnx");
579        adapter.update(400);
580        adapter.update(600);
581        adapter.finish();
582
583        let got = events.into_inner();
584        // 1 init + 2 updates = 3 emissions. `finish` should NOT add a
585        // 4th since bytes_done already equals bytes_total.
586        assert_eq!(got.len(), 3);
587
588        assert_eq!(got[0].bytes_done, 0);
589        assert_eq!(got[0].bytes_total, Some(1000));
590        assert_eq!(got[0].file_index, 1);
591        assert_eq!(got[0].file_count, 4);
592        assert_eq!(got[0].file, "encoder.onnx");
593
594        assert_eq!(got[1].bytes_done, 400);
595        assert_eq!(got[2].bytes_done, 1000);
596    }
597
598    /// If hf-hub's last `update` short-counts (we've seen this on retried
599    /// downloads), `finish` should synthesize a final 100% event so the UI
600    /// can't get stuck at 99%.
601    #[test]
602    fn callback_progress_finish_synthesizes_completion() {
603        use hf_hub::api::Progress;
604
605        let events = std::cell::RefCell::new(Vec::<DownloadProgress>::new());
606        let mut on_progress = |p: DownloadProgress| events.borrow_mut().push(p);
607
608        let mut adapter = CallbackProgress {
609            file: "tokens.txt".to_string(),
610            file_index: 4,
611            file_count: 4,
612            bytes_done: 0,
613            bytes_total: None,
614            on_progress: &mut on_progress,
615        };
616
617        adapter.init(500, "tokens.txt");
618        adapter.update(400); // short
619        adapter.finish(); // should push a synthetic 500/500
620
621        let got = events.into_inner();
622        assert_eq!(got.len(), 3);
623        assert_eq!(got.last().unwrap().bytes_done, 500);
624        assert_eq!(got.last().unwrap().bytes_total, Some(500));
625    }
626
627    /// Walk the on-disk layout `hf-hub` writes
628    /// (`<cache_root>/models--<owner>--<repo>/snapshots/<commit>/<file>`)
629    /// and verify the probe (a) returns false for a missing repo,
630    /// partial files, or empty snapshot dirs and (b) returns true
631    /// once every requested file is present in at least one snapshot.
632    /// Pure-fs so it runs in CI without a network or a real model.
633    #[test]
634    fn snapshot_probe_matches_hf_hub_layout() {
635        let root = unique_temp_dir("snapshot-probe");
636        let _cleanup = scopeguard_remove(root.clone());
637        std::fs::create_dir_all(&root).unwrap();
638
639        let repo_id = "csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20";
640        let files = ["encoder.onnx", "decoder.onnx", "tokens.txt"];
641
642        assert!(!snapshot_satisfies_files(&root, repo_id, &files));
643
644        let repo_dir = root.join(format!("models--{}", repo_id.replace('/', "--")));
645        let snap_a = repo_dir.join("snapshots").join("abc123");
646        std::fs::create_dir_all(&snap_a).unwrap();
647        assert!(!snapshot_satisfies_files(&root, repo_id, &files));
648
649        std::fs::write(snap_a.join("encoder.onnx"), b"").unwrap();
650        std::fs::write(snap_a.join("decoder.onnx"), b"").unwrap();
651        assert!(!snapshot_satisfies_files(&root, repo_id, &files));
652
653        std::fs::write(snap_a.join("tokens.txt"), b"").unwrap();
654        assert!(snapshot_satisfies_files(&root, repo_id, &files));
655
656        // A second, half-complete snapshot must not invalidate the
657        // first complete one.
658        let snap_b = repo_dir.join("snapshots").join("def456");
659        std::fs::create_dir_all(&snap_b).unwrap();
660        std::fs::write(snap_b.join("encoder.onnx"), b"").unwrap();
661        assert!(snapshot_satisfies_files(&root, repo_id, &files));
662    }
663
664    /// Slash-bearing repo ids must be flattened to the `<owner>--<repo>`
665    /// form `hf-hub` writes; without that the probe would silently look
666    /// in a nonexistent directory and always report `false`.
667    #[test]
668    fn snapshot_probe_flattens_slashes_in_repo_id() {
669        let root = unique_temp_dir("slash-flatten");
670        let _cleanup = scopeguard_remove(root.clone());
671        let repo_id = "owner/repo";
672        let snap = root
673            .join("models--owner--repo")
674            .join("snapshots")
675            .join("c0");
676        std::fs::create_dir_all(&snap).unwrap();
677        std::fs::write(snap.join("model.onnx"), b"").unwrap();
678        assert!(snapshot_satisfies_files(&root, repo_id, &["model.onnx"]));
679    }
680
681    // ----- pinned downloads ------------------------------------------
682
683    /// Minimal HTTP/1.1 server: serves `routes` (path → (status, body))
684    /// until the test ends, one request per connection. Returns its base
685    /// URL and a log of the paths it was asked for.
686    fn serve(
687        routes: Vec<(&'static str, u16, Vec<u8>)>,
688    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
689        use std::io::{BufRead, BufReader, Write};
690        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
691        let base = format!("http://{}", listener.local_addr().unwrap());
692        let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
693        let log_t = log.clone();
694        std::thread::spawn(move || {
695            for stream in listener.incoming() {
696                let Ok(mut stream) = stream else { return };
697                let mut reader = BufReader::new(stream.try_clone().unwrap());
698                let mut line = String::new();
699                reader.read_line(&mut line).unwrap();
700                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
701                loop {
702                    let mut h = String::new();
703                    if reader.read_line(&mut h).unwrap() <= 2 {
704                        break;
705                    }
706                }
707                log_t.lock().unwrap().push(path.clone());
708                let (status, body) = routes
709                    .iter()
710                    .find(|(p, _, _)| *p == path)
711                    .map(|(_, s, b)| (*s, b.clone()))
712                    .unwrap_or((404, b"not found".to_vec()));
713                let head = format!(
714                    "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
715                    body.len()
716                );
717                let _ = stream.write_all(head.as_bytes());
718                let _ = stream.write_all(&body);
719            }
720        });
721        (base, log)
722    }
723
724    /// A base URL nothing listens on — connecting fails immediately,
725    /// like a blocked host that actively refuses.
726    fn refused_base() -> String {
727        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
728        let addr = listener.local_addr().unwrap();
729        drop(listener);
730        format!("http://{addr}")
731    }
732
733    const REPO: &str = "owner/model";
734    const REV: &str = "0123456789abcdef0123456789abcdef01234567";
735
736    fn pinned(name: &'static str, body: &[u8]) -> PinnedFile {
737        use sha2::{Digest, Sha256};
738        let sha = to_hex(&Sha256::digest(body));
739        PinnedFile {
740            name,
741            sha256: Box::leak(sha.into_boxed_str()),
742            size: body.len() as u64,
743        }
744    }
745
746    fn route(name: &str) -> &'static str {
747        Box::leak(format!("/{REPO}/resolve/{REV}/{name}").into_boxed_str())
748    }
749
750    #[test]
751    fn source_file_url_uses_resolve_layout_and_trims_slash() {
752        let src = DownloadSource::mirror("https://example.com/models/");
753        assert_eq!(src.base_url(), "https://example.com/models");
754        assert_eq!(
755            src.file_url("owner/model", "abc", "encoder.onnx"),
756            "https://example.com/models/owner/model/resolve/abc/encoder.onnx"
757        );
758        assert_eq!(
759            DownloadSource::hugging_face().file_url("a/b", "main", "t.txt"),
760            "https://huggingface.co/a/b/resolve/main/t.txt"
761        );
762    }
763
764    /// The case that motivated this: the first host refuses the
765    /// connection, so every file comes from the next one — and lands
766    /// where `hf-hub` and the cache probe look.
767    #[test]
768    fn falls_back_to_next_source_and_populates_hf_cache() {
769        let root = unique_temp_dir("pinned-fallback");
770        let _cleanup = scopeguard_remove(root.clone());
771        let files = [
772            pinned("encoder.onnx", b"enc-bytes"),
773            pinned("tokens.txt", b"a 0\nb 1\n"),
774        ];
775        let (mirror, log) = serve(vec![
776            (route("encoder.onnx"), 200, b"enc-bytes".to_vec()),
777            (route("tokens.txt"), 200, b"a 0\nb 1\n".to_vec()),
778        ]);
779        let sources = [
780            DownloadSource::mirror(refused_base()),
781            DownloadSource::mirror(mirror),
782        ];
783
784        let mut events = Vec::new();
785        let snap =
786            download_pinned_into(&root, REPO, REV, &files, &sources, |p| events.push(p)).unwrap();
787
788        assert_eq!(snap, root.join("models--owner--model/snapshots").join(REV));
789        assert_eq!(
790            std::fs::read(snap.join("encoder.onnx")).unwrap(),
791            b"enc-bytes"
792        );
793        assert_eq!(
794            std::fs::read_to_string(root.join("models--owner--model/refs/main")).unwrap(),
795            REV
796        );
797        assert!(snapshot_satisfies_files(
798            &root,
799            REPO,
800            &["encoder.onnx", "tokens.txt"]
801        ));
802        assert!(!snap.join("encoder.onnx.part").exists());
803        // Sticky: the refused host isn't retried for the second file.
804        assert_eq!(log.lock().unwrap().len(), 2);
805        let last = events.last().unwrap();
806        assert_eq!((last.file_index, last.file_count), (2, 2));
807        assert_eq!(last.bytes_done, 8);
808        assert_eq!(last.bytes_total, Some(8));
809    }
810
811    /// A source that serves the wrong bytes is not trusted: the file is
812    /// rejected and the next source is used.
813    #[test]
814    fn checksum_mismatch_falls_through_to_next_source() {
815        let root = unique_temp_dir("pinned-mismatch");
816        let _cleanup = scopeguard_remove(root.clone());
817        let files = [pinned("model.onnx", b"genuine!")];
818        let (bad, _) = serve(vec![(route("model.onnx"), 200, b"tampered".to_vec())]);
819        let (good, _) = serve(vec![(route("model.onnx"), 200, b"genuine!".to_vec())]);
820        let sources = [DownloadSource::mirror(bad), DownloadSource::mirror(good)];
821
822        let snap = download_pinned_into(&root, REPO, REV, &files, &sources, |_| {}).unwrap();
823        assert_eq!(std::fs::read(snap.join("model.onnx")).unwrap(), b"genuine!");
824    }
825
826    /// With no good source left, nothing is left behind that the cache
827    /// probe would mistake for a finished download.
828    #[test]
829    fn fails_when_every_source_fails_and_leaves_no_file() {
830        let root = unique_temp_dir("pinned-allfail");
831        let _cleanup = scopeguard_remove(root.clone());
832        let files = [pinned("model.onnx", b"genuine!")];
833        let (bad, _) = serve(vec![(route("model.onnx"), 200, b"tampered".to_vec())]);
834        let (missing, _) = serve(vec![]);
835        let sources = [DownloadSource::mirror(bad), DownloadSource::mirror(missing)];
836
837        let err = download_pinned_into(&root, REPO, REV, &files, &sources, |_| {}).unwrap_err();
838        let msg = err.to_string();
839        assert!(msg.contains("checksum"), "{msg}");
840        assert!(msg.contains("404"), "{msg}");
841        let snap = root.join("models--owner--model/snapshots").join(REV);
842        assert!(!snap.join("model.onnx").exists());
843        assert!(!snap.join("model.onnx.part").exists());
844        assert!(!root.join("models--owner--model/refs/main").exists());
845    }
846
847    #[test]
848    fn truncated_body_is_rejected() {
849        let root = unique_temp_dir("pinned-short");
850        let _cleanup = scopeguard_remove(root.clone());
851        let mut file = pinned("model.onnx", b"full body");
852        file.size = 100;
853        let (host, _) = serve(vec![(route("model.onnx"), 200, b"full body".to_vec())]);
854        let err = download_pinned_into(
855            &root,
856            REPO,
857            REV,
858            &[file],
859            &[DownloadSource::mirror(host)],
860            |_| {},
861        )
862        .unwrap_err();
863        assert!(err.to_string().contains("incomplete"), "{err}");
864    }
865
866    /// Files already at their pinned size are not fetched again, so a
867    /// re-run after an interruption only downloads what's missing.
868    #[test]
869    fn skips_files_already_present_at_pinned_size() {
870        let root = unique_temp_dir("pinned-resume");
871        let _cleanup = scopeguard_remove(root.clone());
872        let files = [pinned("a.onnx", b"aaaa"), pinned("b.txt", b"bb")];
873        let snap = root.join("models--owner--model/snapshots").join(REV);
874        std::fs::create_dir_all(&snap).unwrap();
875        std::fs::write(snap.join("a.onnx"), b"aaaa").unwrap();
876        let (host, log) = serve(vec![(route("b.txt"), 200, b"bb".to_vec())]);
877
878        download_pinned_into(
879            &root,
880            REPO,
881            REV,
882            &files,
883            &[DownloadSource::mirror(host)],
884            |_| {},
885        )
886        .unwrap();
887        assert_eq!(*log.lock().unwrap(), vec![route("b.txt").to_string()]);
888    }
889
890    #[test]
891    fn no_sources_is_an_error() {
892        let root = unique_temp_dir("pinned-nosource");
893        let _cleanup = scopeguard_remove(root.clone());
894        let err =
895            download_pinned_into(&root, REPO, REV, &[pinned("x", b"x")], &[], |_| {}).unwrap_err();
896        assert!(err.to_string().contains("no download source"), "{err}");
897    }
898
899    /// Unique-per-test scratch directory under the OS temp dir. We
900    /// don't pull `tempfile` in just for this — pid + nanosecond
901    /// timestamp is enough collision protection for a unit test, and
902    /// the [`scopeguard_remove`] guard cleans up regardless of
903    /// pass/fail.
904    fn unique_temp_dir(label: &str) -> std::path::PathBuf {
905        use std::time::{SystemTime, UNIX_EPOCH};
906        let nanos = SystemTime::now()
907            .duration_since(UNIX_EPOCH)
908            .map(|d| d.as_nanos())
909            .unwrap_or(0);
910        let pid = std::process::id();
911        std::env::temp_dir().join(format!("wavekat-asr-{label}-{pid}-{nanos}"))
912    }
913
914    fn scopeguard_remove(path: std::path::PathBuf) -> impl Drop {
915        struct Guard(std::path::PathBuf);
916        impl Drop for Guard {
917            fn drop(&mut self) {
918                let _ = std::fs::remove_dir_all(&self.0);
919            }
920        }
921        Guard(path)
922    }
923}