Skip to main content

forge_dictate/
fetch.rs

1//! Model fetch: download, resume, and verify before use.
2
3use std::collections::HashMap;
4use std::fs::{self, File};
5use std::ops::ControlFlow;
6use std::path::{Path, PathBuf};
7use std::sync::mpsc;
8use std::sync::{Mutex, PoisonError};
9
10use sha2::{Digest, Sha256};
11
12use crate::{Config, Error, ModelSpec};
13
14/// Bytes between progress reports during a transfer.
15const PROGRESS_INTERVAL: u64 = 1 << 20;
16
17/// Bytes between cancellation checkpoints while hashing. Coarse, since
18/// a checkpoint round-trips the prepare driver: at hashing speed this
19/// keeps cancel latency near 120 ms without an event per read.
20const VERIFY_PROGRESS_INTERVAL: u64 = 64 << 20;
21
22/// The caller's progress callback as the internals pass it around.
23type Reporter<'a> = dyn FnMut(Progress) -> ControlFlow<()> + 'a;
24
25/// Hand one progress event to the caller, turning a `Break` into the
26/// error that unwinds the whole operation.
27fn announce(on_progress: &mut Reporter<'_>, progress: Progress) -> Result<(), Error> {
28    match on_progress(progress) {
29        ControlFlow::Continue(()) => Ok(()),
30        ControlFlow::Break(()) => Err(Error::Cancelled),
31    }
32}
33
34/// What [`prepare`] reports as it works.
35#[derive(Debug, Clone)]
36pub enum Progress {
37    /// Checking a file already on disk. No bytes are transferred.
38    Verifying { file: String },
39    /// `downloaded` of `total` bytes are now on disk for `file`.
40    Downloading { file: String, downloaded: u64, total: u64 },
41    /// `file` is present and verified.
42    Ready { file: String },
43}
44
45/// Make every model the config names present and verified.
46///
47/// Blocking, like everything else here: an async caller must run this
48/// on `tokio::task::spawn_blocking` or its equivalent. On a runtime
49/// thread it panics in a debug build and silently holds a worker for
50/// the whole transfer in a release one.
51///
52/// Downloads what is missing, resumes what was interrupted, and checks
53/// size then SHA-256 against the [`crate::ModelSpec`] before reporting
54/// a file ready. Safe to call repeatedly; a verified file is left
55/// alone.
56///
57/// A file that is present but does not match its spec is fetched again,
58/// which is what settles a mismatch of bytes. A file that cannot be read
59/// is reported instead: nothing says a fetch would help it.
60///
61/// Models are prepared concurrently, one thread each, so the pair costs
62/// the slower of the two rather than their sum. `on_progress` is still
63/// called from a single thread and one event at a time, but events from
64/// the two now interleave: a caller keeping per-transfer state must key
65/// it on [`Progress`]'s `file`.
66///
67/// Hashing is skipped for a file that has not changed since it was last
68/// verified, when [`Config::digest_cache_dir`] names a place to keep
69/// that record: the check becomes the stat it already needs. Without that
70/// directory every call re-hashes each file end to end, measured here at
71/// 0.55 s/GiB in release and 4.2 s/GiB unoptimised - the second reads as
72/// a hang rather than as the profile. sha2 0.11, which this tree has
73/// pinned since the sweep that moved it, is about four times faster than
74/// the 0.10 before it.
75pub fn prepare(
76    cfg: &Config,
77    mut on_progress: impl FnMut(Progress) -> ControlFlow<()>,
78) -> Result<(), Error> {
79    let dir = models_dir(cfg)?;
80    fs::create_dir_all(&dir).map_err(|source| Error::Io { path: dir.clone(), source })?;
81
82    let specs: Vec<&ModelSpec> =
83        std::iter::once(&cfg.asr_model).chain(cfg.normalizer.as_ref()).collect();
84    let cache = DigestCache::load(cfg.digest_cache_dir.as_deref());
85    // A reference, so each worker closure captures that rather than the
86    // value, and this one can still write the record out below.
87    let cache = &cache;
88    let (report_tx, reports) = mpsc::channel::<Report>();
89
90    let outcomes: Vec<Result<(), Error>> = std::thread::scope(|scope| {
91        let workers: Vec<_> = specs
92            .iter()
93            .map(|spec| {
94                let report_tx = report_tx.clone();
95                let dir = dir.as_path();
96                scope.spawn(move || {
97                    // Each announcement blocks on the driver's verdict, so
98                    // a `Break` still stops this transfer at the same point
99                    // it would have when the callback was called directly.
100                    let mut announce_to_driver = |progress| {
101                        // A fresh channel per announcement, so the only
102                        // sender travels WITH the report. Holding one for
103                        // the worker's lifetime would keep this `recv`
104                        // connected to itself, and a panicking callback
105                        // would park every worker on a wait that can never
106                        // end - `thread::scope` then never returns and the
107                        // panic never propagates.
108                        let (verdict_tx, verdict) = mpsc::channel();
109                        let report = Report { progress, verdict: verdict_tx };
110                        if report_tx.send(report).is_err() {
111                            return ControlFlow::Break(());
112                        }
113                        verdict.recv().unwrap_or(ControlFlow::Break(()))
114                    };
115                    ensure(dir, spec, cache, &mut announce_to_driver)
116                })
117            })
118            .collect();
119        // The driver's own sender would keep `reports` open past the last
120        // worker and hang the loop below.
121        drop(report_tx);
122
123        for report in reports {
124            let _ = report.verdict.send(on_progress(report.progress));
125        }
126
127        workers
128            .into_iter()
129            .map(|worker| worker.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic)))
130            .collect()
131    });
132
133    // Written after the join, so a record updated by either worker is
134    // included, and written even when one model failed: what verified is
135    // worth keeping.
136    cache.save();
137
138    // In spec order, so which failure a caller sees does not depend on
139    // which thread lost the race.
140    outcomes.into_iter().find(Result::is_err).unwrap_or(Ok(()))
141}
142
143/// One model thread's progress event and the channel its verdict comes
144/// back on.
145struct Report {
146    progress: Progress,
147    verdict: mpsc::Sender<ControlFlow<()>>,
148}
149
150/// The configured directory, else a subdirectory of the platform cache
151/// directory. Never derived from the working directory: a caller with
152/// no usable cache directory gets an error to act on.
153pub(crate) fn models_dir(cfg: &Config) -> Result<PathBuf, Error> {
154    match &cfg.models_dir {
155        Some(dir) => Ok(dir.clone()),
156        None => dirs::cache_dir().map(|d| d.join("forge-dictate")).ok_or(Error::NoCacheDir),
157    }
158}
159
160/// The record of verified digests inside
161/// [`Config::digest_cache_dir`].
162const DIGEST_CACHE_FILE: &str = "verified.json";
163
164/// The record's own version. A file written by a layout this build does
165/// not know is ignored rather than misread.
166const DIGEST_CACHE_VERSION: u32 = 1;
167
168/// What one file's last verification saw.
169#[derive(Clone, serde::Serialize, serde::Deserialize)]
170struct DigestRecord {
171    size: u64,
172    /// Modification time in nanoseconds since the epoch. Size and mtime
173    /// are the whole key, so they are stored together and compared
174    /// together.
175    mtime_ns: u64,
176    sha256: String,
177}
178
179#[derive(serde::Serialize, serde::Deserialize)]
180struct DigestFile {
181    version: u32,
182    records: HashMap<PathBuf, DigestRecord>,
183}
184
185/// Remembers which digest each model file had when it was last verified,
186/// so a file that has not changed since is not read end to end again.
187///
188/// The state a record is keyed on is the file's size and modification
189/// time, both from the stat a verification needs anyway. When either
190/// moves, the record no longer covers the file and the full SHA-256 runs
191/// again. Bytes replaced without moving either are taken on the recorded
192/// digest: that is the trade for a boot without a 3.07 GB read, and every
193/// real replacement - a download, a copy, an edit - moves the mtime.
194///
195/// An `Err` here is never fatal. A record that cannot be read, or a
196/// directory that cannot be written, costs a hash on the next run rather
197/// than a failed one.
198struct DigestCache {
199    /// `None` disables the cache entirely, which is what a caller with
200    /// no machine-local directory of its own to put it in gets.
201    file: Option<PathBuf>,
202    inner: Mutex<CacheInner>,
203}
204
205#[derive(Default)]
206struct CacheInner {
207    records: HashMap<PathBuf, DigestRecord>,
208    /// Whether anything was added since the last read, so a run that
209    /// changed nothing does not rewrite the file.
210    changed: bool,
211}
212
213impl DigestCache {
214    /// Read the record beside `dir`, or start an empty one.
215    fn load(dir: Option<&Path>) -> Self {
216        let Some(file) = dir.map(|dir| dir.join(DIGEST_CACHE_FILE)) else {
217            return Self { file: None, inner: Mutex::new(CacheInner::default()) };
218        };
219        let records = match fs::read_to_string(&file) {
220            Ok(raw) => match serde_json::from_str::<DigestFile>(&raw) {
221                Ok(parsed) if parsed.version == DIGEST_CACHE_VERSION => parsed.records,
222                Ok(parsed) => {
223                    tracing::warn!(
224                        event_name = "dictate_digest_record_layout_unknown",
225                        path = %file.display(),
226                        version = parsed.version,
227                        "digest record is a layout this build does not read; every model is hashed this run"
228                    );
229                    HashMap::new()
230                }
231                Err(error) => {
232                    tracing::warn!(
233                        event_name = "dictate_digest_record_unreadable",
234                        path = %file.display(),
235                        %error,
236                        "digest record is unreadable; every model is hashed this run"
237                    );
238                    HashMap::new()
239                }
240            },
241            // Absent is the first run, which is not a problem to report.
242            Err(error) if error.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
243            Err(error) => {
244                tracing::warn!(
245                    event_name = "dictate_digest_record_read_failed",
246                    path = %file.display(),
247                    %error,
248                    "digest record could not be read; every model is hashed this run"
249                );
250                HashMap::new()
251            }
252        };
253        Self { file: Some(file), inner: Mutex::new(CacheInner { records, changed: false }) }
254    }
255
256    /// Whether this file, at exactly this state, held these bytes when it
257    /// was last verified. A digest recorded under a different spec is not
258    /// a hit: re-hashing is what turns that into a mismatch the caller
259    /// can act on.
260    fn hit(&self, file: &Path, spec: &ModelSpec) -> bool {
261        // Nothing is remembered for a spec with no digest, so nothing can
262        // match it.
263        let Some(expected) = spec.sha256.as_deref() else { return false };
264        let Some((size, mtime_ns)) = state_of(file) else { return false };
265        let inner = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
266        inner.records.get(file).is_some_and(|record| {
267            record.size == size
268                && record.mtime_ns == mtime_ns
269                && record.sha256.eq_ignore_ascii_case(expected)
270        })
271    }
272
273    /// Record a digest computed over `file` while it was in `state`.
274    ///
275    /// `state` is the caller's, taken before the hash: a stat here could
276    /// see a write that landed after the read and store the new state
277    /// against the old bytes, which the next boot would then skip.
278    fn record(&self, file: &Path, state: Option<(u64, u64)>, digest: &str) {
279        if self.file.is_none() {
280            return;
281        }
282        let Some((size, mtime_ns)) = state else { return };
283        let mut inner = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
284        inner
285            .records
286            .insert(file.to_path_buf(), DigestRecord { size, mtime_ns, sha256: digest.to_owned() });
287        inner.changed = true;
288    }
289
290    /// Write the record back, best effort.
291    fn save(&self) {
292        let Some(path) = self.file.as_deref() else { return };
293        let records = {
294            let inner = self.inner.lock().unwrap_or_else(PoisonError::into_inner);
295            if !inner.changed {
296                return;
297            }
298            inner.records.clone()
299        };
300        let body = match serde_json::to_string_pretty(&DigestFile {
301            version: DIGEST_CACHE_VERSION,
302            records,
303        }) {
304            Ok(body) => body,
305            Err(error) => {
306                tracing::warn!(
307                    event_name = "dictate_digest_record_serialize_failed",
308                    %error,
309                    "digest record could not be serialised; not written"
310                );
311                return;
312            }
313        };
314        let written =
315            path.parent().map_or(Ok(()), fs::create_dir_all).and_then(|()| fs::write(path, body));
316        if let Err(error) = written {
317            tracing::warn!(
318                event_name = "dictate_digest_record_write_failed",
319                path = %path.display(),
320                %error,
321                "digest record could not be written; the next run hashes what this one did"
322            );
323        }
324    }
325}
326
327/// The state a record is keyed on: length and modification time. `None`
328/// when the file cannot be stat'ed, which leaves the caller to report
329/// whatever is wrong with it.
330fn state_of(path: &Path) -> Option<(u64, u64)> {
331    let metadata = fs::metadata(path).ok()?;
332    let since_epoch = metadata.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
333    Some((metadata.len(), u64::try_from(since_epoch.as_nanos()).ok()?))
334}
335
336fn ensure(
337    dir: &Path,
338    spec: &ModelSpec,
339    cache: &DigestCache,
340    on_progress: &mut Reporter<'_>,
341) -> Result<(), Error> {
342    let target = dir.join(&spec.file);
343    if target.try_exists().map_err(|source| Error::Io { path: target.clone(), source })? {
344        match check_present(&target, spec, cache, on_progress) {
345            Ok(()) => {
346                announce(on_progress, Progress::Ready { file: spec.file.clone() })?;
347                return Ok(());
348            }
349            Err(failure) if !is_bytes_verdict(&failure) => return Err(failure),
350            // A file whose bytes do not match its spec is what a download
351            // settles. The fetch below replaces it by rename, so one that
352            // fails or is cancelled costs nothing already on disk.
353            Err(failure) => {
354                tracing::debug!(
355                    event_name = "dictate_model_mismatch_refetching",
356                    file = %spec.file,
357                    error = %failure,
358                    "model on disk does not match its spec; fetching a replacement"
359                );
360            }
361        }
362    }
363
364    let partial = dir.join(format!("{}.part", spec.file));
365    download(spec, &partial, on_progress)?;
366
367    // Announced before the hash, not after: on a multi-gigabyte file the
368    // read takes seconds, and a caller left on "100%" reads it as a hang.
369    // A digestless spec has no such read.
370    if spec.sha256.is_some() {
371        announce(on_progress, Progress::Verifying { file: spec.file.clone() })?;
372    }
373
374    let state = state_of(&partial);
375    let digest = match verify(&partial, spec, on_progress) {
376        Ok(digest) => digest,
377        Err(failure) => return Err(discard_unusable_partial(&partial, failure)),
378    };
379    fs::rename(&partial, &target).map_err(|source| Error::Io { path: target.clone(), source })?;
380    // One inode moved, so the bytes just hashed are the bytes now at
381    // `target`, still in the state that was read before them.
382    if let Some(digest) = &digest {
383        cache.record(&target, state, digest);
384    }
385    announce(on_progress, Progress::Ready { file: spec.file.clone() })?;
386    Ok(())
387}
388
389/// Check a model already on disk, verifying it unless its record says it
390/// has not changed since the last check.
391fn check_present(
392    target: &Path,
393    spec: &ModelSpec,
394    cache: &DigestCache,
395    on_progress: &mut Reporter<'_>,
396) -> Result<(), Error> {
397    // Nothing is announced before a hit: such a file is not read again,
398    // and a "verifying" row that lives for one stat is noise.
399    if cache.hit(target, spec) {
400        return Ok(());
401    }
402    // A digestless spec's whole check is the size, which is the stat
403    // `verify` makes on its way in: nothing to hash, so nothing says it
404    // is verifying.
405    if spec.sha256.is_some() {
406        announce(on_progress, Progress::Verifying { file: spec.file.clone() })?;
407    }
408    let state = state_of(target);
409    let digest = verify(target, spec, on_progress)?;
410    if let Some(digest) = &digest {
411        cache.record(target, state, digest);
412    }
413    Ok(())
414}
415
416/// Whether a failure is a verdict on the BYTES rather than on reaching or
417/// reading them. Only a verdict on the bytes is a download's to settle: an
418/// io error says nothing about them, and a fetch cannot answer it.
419fn is_bytes_verdict(failure: &Error) -> bool {
420    matches!(failure, Error::SizeMismatch { .. } | Error::HashMismatch { .. })
421}
422
423/// Decide what a failed partial deserves, and say so in the log.
424///
425/// Only a verdict on the BYTES earns a deletion: an io error says
426/// nothing about them, and a partial may predate this run, so deleting
427/// on one would throw away a correct multi-gigabyte file.
428fn discard_unusable_partial(partial: &Path, failure: Error) -> Error {
429    if !is_bytes_verdict(&failure) {
430        tracing::warn!(
431            event_name = "dictate_partial_unchecked",
432            path = %partial.display(),
433            error = %failure,
434            "could not check the partial; leaving it in place"
435        );
436        return failure;
437    }
438    match fs::remove_file(partial) {
439        Ok(()) => {
440            tracing::warn!(
441                event_name = "dictate_partial_removed",
442                path = %partial.display(),
443                error = %failure,
444                "partial does not match its spec; removed"
445            );
446            failure
447        }
448        Err(source) => {
449            tracing::error!(
450                event_name = "dictate_partial_removal_failed",
451                path = %partial.display(),
452                error = %failure,
453                remove_error = %source,
454                "partial does not match its spec and could not be removed"
455            );
456            Error::StalePartial { path: partial.into(), source }
457        }
458    }
459}
460
461/// Check `path` against the spec, hashing only what there is a digest for.
462///
463/// Size first, then digest: a truncated file is the common case and
464/// costs one `stat` to reject.
465///
466/// The digest compared here is always the spec's own, never a server
467/// `ETag`: HuggingFace answers with a chunked xet etag that is a
468/// different value from the file's SHA-256, so trusting it would reject
469/// a perfectly good file forever.
470///
471/// Returns the digest when one was computed. A spec with no digest - a
472/// model whose upstream publishes none - gets its size checked and nothing
473/// more, and the caller records nothing: there is nothing to remember.
474fn verify(
475    path: &Path,
476    spec: &ModelSpec,
477    on_progress: &mut Reporter<'_>,
478) -> Result<Option<String>, Error> {
479    let actual =
480        fs::metadata(path).map_err(|source| Error::Io { path: path.into(), source })?.len();
481    if actual != spec.size {
482        return Err(Error::SizeMismatch { path: path.into(), expected: spec.size, actual });
483    }
484
485    let Some(expected) = spec.sha256.as_deref() else {
486        return Ok(None);
487    };
488    let digest = sha256(path, spec, on_progress)?;
489    if !digest.eq_ignore_ascii_case(expected) {
490        return Err(Error::HashMismatch {
491            path: path.into(),
492            expected: expected.to_owned(),
493            actual: digest,
494        });
495    }
496    Ok(Some(digest))
497}
498
499fn sha256(path: &Path, spec: &ModelSpec, on_progress: &mut Reporter<'_>) -> Result<String, Error> {
500    let mut file = File::open(path).map_err(|source| Error::Io { path: path.into(), source })?;
501    let mut sink = HashingWriter {
502        hasher: Sha256::new(),
503        name: &spec.file,
504        hashed: 0,
505        reported: 0,
506        cancelled: false,
507        interval: VERIFY_PROGRESS_INTERVAL,
508        on_progress,
509    };
510    let copied = std::io::copy(&mut file, &mut sink);
511    // Checked before the io error, because cancellation arrives AS one.
512    if sink.cancelled {
513        return Err(Error::Cancelled);
514    }
515    copied.map_err(|source| Error::Io { path: path.into(), source })?;
516    Ok(hex::encode(sink.hasher.finalize()))
517}
518
519/// Feeds bytes into the digest and checkpoints cancellation every
520/// `interval` bytes, so a cancel during verification does not wait out
521/// the whole hash.
522struct HashingWriter<'a, 'r> {
523    hasher: Sha256,
524    name: &'a str,
525    hashed: u64,
526    reported: u64,
527    cancelled: bool,
528    interval: u64,
529    on_progress: &'a mut Reporter<'r>,
530}
531
532impl std::io::Write for HashingWriter<'_, '_> {
533    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
534        self.hasher.update(buf);
535        self.hashed += buf.len() as u64;
536        if self.hashed - self.reported >= self.interval && self.report().is_break() {
537            self.cancelled = true;
538            // Deliberately not `Interrupted`: write_all retries that
539            // kind, and the digest cannot un-eat bytes.
540            return Err(std::io::Error::other("cancelled by the progress callback"));
541        }
542        Ok(buf.len())
543    }
544
545    fn flush(&mut self) -> std::io::Result<()> {
546        Ok(())
547    }
548}
549
550impl HashingWriter<'_, '_> {
551    fn report(&mut self) -> ControlFlow<()> {
552        self.reported = self.hashed;
553        (self.on_progress)(Progress::Verifying { file: self.name.to_owned() })
554    }
555}
556
557fn download(spec: &ModelSpec, partial: &Path, on_progress: &mut Reporter<'_>) -> Result<(), Error> {
558    let mut have = match fs::metadata(partial) {
559        Ok(meta) => meta.len(),
560        Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
561        Err(source) => return Err(Error::Io { path: partial.into(), source }),
562    };
563    // A partial at exactly the full length is a transfer that finished
564    // and never got renamed, so hand it to verification rather than
565    // fetching the whole thing again. Anything longer is not a prefix of
566    // anything and starts over.
567    if have == spec.size {
568        return Ok(());
569    }
570    if have > spec.size {
571        have = 0;
572    }
573
574    let http = |source| Error::Http { url: spec.url.clone(), source };
575    let client = reqwest::blocking::Client::builder()
576        .connect_timeout(std::time::Duration::from_secs(30))
577        .build()
578        .map_err(http)?;
579
580    let mut request = client.get(&spec.url);
581    if have > 0 {
582        tracing::debug!(
583            event_name = "dictate_download_resumed",
584            file = %spec.file,
585            have,
586            "resuming interrupted download"
587        );
588        request = request.header(reqwest::header::RANGE, format!("bytes={have}-"));
589    }
590    let mut response = request.send().map_err(http)?;
591
592    let status = response.status();
593    if !status.is_success() {
594        return Err(Error::HttpStatus { url: spec.url.clone(), status: status.as_u16() });
595    }
596    // Appending anything that does not begin at `have` splices two
597    // overlapping copies together. Two ways that happens: a server free
598    // to ignore `Range` answers 200 with the whole file, and a clamping
599    // proxy answers 206 from an offset we did not ask for.
600    let resuming = have > 0
601        && status == reqwest::StatusCode::PARTIAL_CONTENT
602        && content_range_starts_at(response.headers(), have);
603    if !resuming {
604        have = 0;
605    }
606
607    let mut options = fs::OpenOptions::new();
608    options.create(true).write(true);
609    if resuming {
610        options.append(true);
611    } else {
612        options.truncate(true);
613    }
614    let file =
615        options.open(partial).map_err(|source| Error::Io { path: partial.into(), source })?;
616
617    let mut sink = ProgressWriter {
618        file,
619        name: &spec.file,
620        written: have,
621        reported: have,
622        total: spec.size,
623        cancelled: false,
624        on_progress,
625    };
626    let copied = std::io::copy(&mut response, &mut sink);
627    // Checked before the io error, because cancellation arrives AS one
628    // and would otherwise be reported as a disk fault.
629    if sink.cancelled {
630        return Err(Error::Cancelled);
631    }
632    copied.map_err(|source| Error::Io { path: partial.into(), source })?;
633    sink.report_now()
634}
635
636/// Parse `Content-Range: bytes <start>-<end>/<total>` and say whether
637/// the body starts where the caller asked. A missing or unreadable
638/// header counts as no, which costs a restart rather than a splice.
639fn content_range_starts_at(headers: &reqwest::header::HeaderMap, want: u64) -> bool {
640    headers
641        .get(reqwest::header::CONTENT_RANGE)
642        .and_then(|value| value.to_str().ok())
643        .and_then(|value| value.split_whitespace().nth(1))
644        .and_then(|range| range.split('-').next())
645        .and_then(|start| start.parse::<u64>().ok())
646        .is_some_and(|start| start == want)
647}
648
649/// Counts bytes on their way to disk and forwards the running total,
650/// rate-limited so a multi-gigabyte transfer does not call back once
651/// per read.
652struct ProgressWriter<'a, 'r> {
653    file: File,
654    name: &'a str,
655    written: u64,
656    reported: u64,
657    total: u64,
658    cancelled: bool,
659    on_progress: &'a mut Reporter<'r>,
660}
661
662impl ProgressWriter<'_, '_> {
663    fn report(&mut self) -> ControlFlow<()> {
664        self.reported = self.written;
665        (self.on_progress)(Progress::Downloading {
666            file: self.name.to_owned(),
667            downloaded: self.written,
668            total: self.total,
669        })
670    }
671
672    fn report_now(&mut self) -> Result<(), Error> {
673        match self.report() {
674            ControlFlow::Continue(()) => Ok(()),
675            ControlFlow::Break(()) => Err(Error::Cancelled),
676        }
677    }
678}
679
680impl std::io::Write for ProgressWriter<'_, '_> {
681    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
682        let n = self.file.write(buf)?;
683        self.written += n as u64;
684        if self.written - self.reported >= PROGRESS_INTERVAL && self.report().is_break() {
685            self.cancelled = true;
686            // Deliberately not `Interrupted`. `write_all` retries that
687            // kind WITHOUT advancing the buffer, and these bytes are
688            // already on disk, so the transfer would run to completion
689            // writing every cancelled chunk twice and leave an oversized
690            // partial behind. It does not spin; it corrupts.
691            return Err(std::io::Error::other("cancelled by the progress callback"));
692        }
693        Ok(n)
694    }
695
696    fn flush(&mut self) -> std::io::Result<()> {
697        self.file.flush()
698    }
699}
700
701#[cfg(test)]
702mod tests_cached_verification {
703    use super::*;
704    use crate::{ConfigBuilder, ModelFacts, ModelSpec, spec_for_download};
705    use sha2::{Digest, Sha256};
706    use std::fs;
707    use std::io::Write as _;
708
709    /// A spec whose URL is guaranteed unreachable, so a code path that
710    /// decides to refetch fails loudly as [`Error::Http`] rather than
711    /// quietly reaching a server this test never stood up.
712    fn offline_spec(file: &str, body: &[u8]) -> ModelSpec {
713        ModelSpec {
714            file: file.into(),
715            url: "http://127.0.0.1:1/unreachable".into(),
716            size: body.len() as u64,
717            sha256: Some(hex::encode(Sha256::digest(body))),
718            facts: ModelFacts::default(),
719        }
720    }
721
722    /// **A downloaded candidate has no digest to check.** No upstream
723    /// publishes one for these weights, so its spec carries the size alone
724    /// and nothing here may claim more than that.
725    #[test]
726    fn a_spec_without_a_digest_is_accepted_on_its_size_alone() {
727        let dir = tempfile::tempdir().unwrap();
728        let body = b"five!";
729        fs::write(dir.path().join("candidate.gguf"), body).unwrap();
730
731        let cfg = ConfigBuilder::new()
732            .models_dir(dir.path())
733            .asr_model(spec_for_download(
734                "candidate.gguf",
735                "http://127.0.0.1:1/unreachable",
736                body.len() as u64,
737                None,
738                ModelFacts::default(),
739            ))
740            .normalizer(None)
741            .build();
742        let announced = std::cell::RefCell::new(Vec::new());
743
744        prepare(&cfg, |progress| {
745            announced.borrow_mut().push(progress);
746            std::ops::ControlFlow::Continue(())
747        })
748        .expect("a file of the right length is all a digestless spec asks for");
749
750        assert!(
751            matches!(
752                announced.borrow().last(),
753                Some(Progress::Ready { file }) if file == "candidate.gguf"
754            ),
755            "the file was not announced ready: {:?}",
756            announced.borrow()
757        );
758        assert!(
759            !announced.borrow().iter().any(|p| matches!(p, Progress::Verifying { .. })),
760            "a digestless spec has nothing to hash, so nothing may say it is verifying"
761        );
762    }
763
764    #[test]
765    fn a_cached_model_matching_its_spec_is_used_as_is() {
766        let dir = tempfile::tempdir().unwrap();
767        let body = b"the complete model weights";
768        fs::write(dir.path().join("asr.gguf"), body).unwrap();
769
770        let cfg = ConfigBuilder::new()
771            .models_dir(dir.path())
772            .asr_model(offline_spec("asr.gguf", body))
773            .normalizer(None)
774            .build();
775
776        let mut reported = Vec::new();
777        prepare(&cfg, |p| {
778            reported.push(p);
779            ControlFlow::Continue(())
780        })
781        .expect("a cached model that matches its spec must be accepted");
782
783        let sequence: Vec<_> = reported
784            .iter()
785            .map(|p| match p {
786                Progress::Verifying { .. } => "verifying",
787                Progress::Downloading { .. } => "downloading",
788                Progress::Ready { .. } => "ready",
789            })
790            .collect();
791        assert_eq!(
792            sequence,
793            ["verifying", "ready"],
794            "a good cached model must be verified and announced without being fetched again"
795        );
796    }
797
798    #[test]
799    fn a_complete_partial_is_verified_rather_than_fetched_again() {
800        let dir = tempfile::tempdir().unwrap();
801        let body = b"the complete model weights";
802        // A transfer that finished and died before the rename.
803        fs::write(dir.path().join("asr.gguf.part"), body).unwrap();
804
805        let cfg = ConfigBuilder::new()
806            .models_dir(dir.path())
807            .asr_model(offline_spec("asr.gguf", body))
808            .normalizer(None)
809            .build();
810
811        prepare(&cfg, |_| ControlFlow::Continue(()))
812            .expect("a complete partial must be adopted, not re-downloaded");
813        assert_eq!(
814            fs::read(dir.path().join("asr.gguf")).unwrap(),
815            body,
816            "the finished bytes must be promoted in place rather than thrown away"
817        );
818    }
819
820    /// An unreadable file is the cheapest way to make `verify` fail for
821    /// a reason that says nothing about the bytes.
822    #[cfg(unix)]
823    #[test]
824    fn an_unreadable_partial_is_reported_not_destroyed() {
825        use std::os::unix::fs::PermissionsExt;
826
827        let dir = tempfile::tempdir().unwrap();
828        let body = b"the complete model weights";
829        let partial = dir.path().join("asr.gguf.part");
830        // Right length, so the size check passes and hashing is reached.
831        fs::write(&partial, body).unwrap();
832        fs::set_permissions(&partial, fs::Permissions::from_mode(0o000)).unwrap();
833
834        let cfg = ConfigBuilder::new()
835            .models_dir(dir.path())
836            .asr_model(offline_spec("asr.gguf", body))
837            .normalizer(None)
838            .build();
839
840        let err = prepare(&cfg, |_| ControlFlow::Continue(()))
841            .expect_err("an unreadable partial cannot be verified");
842        assert!(
843            matches!(err, Error::Io { .. }),
844            "a filesystem fault must stay an io error, not become a verdict on the bytes, got: {err:?}"
845        );
846        assert!(
847            partial.exists(),
848            "an io error says nothing about the bytes, so the file must survive it"
849        );
850
851        fs::set_permissions(&partial, fs::Permissions::from_mode(0o600)).unwrap();
852    }
853
854    /// A models directory nobody can write to: the bytes really are
855    /// wrong, and the cleanup that would normally fix it cannot run.
856    #[cfg(unix)]
857    #[test]
858    fn a_partial_that_cannot_be_removed_is_a_different_error() {
859        use std::os::unix::fs::PermissionsExt;
860
861        let dir = tempfile::tempdir().unwrap();
862        let partial = dir.path().join("asr.gguf.part");
863        fs::write(&partial, b"the corrupted model weight").unwrap();
864        fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap();
865
866        let cfg = ConfigBuilder::new()
867            .models_dir(dir.path())
868            .asr_model(offline_spec("asr.gguf", b"the complete model weights"))
869            .normalizer(None)
870            .build();
871
872        let err = prepare(&cfg, |_| ControlFlow::Continue(()))
873            .expect_err("a corrupt partial must not be accepted");
874        assert!(
875            matches!(err, Error::StalePartial { .. }),
876            "a corruption that will repeat forever must be distinguishable from one that clears on retry, got: {err:?}"
877        );
878
879        fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
880    }
881
882    /// The other direction, and the one a repair must not swallow: a
883    /// target that cannot be READ is not a target a fetch can settle.
884    /// Nothing here says its bytes are wrong, so it stays the refusal -
885    /// and the file survives, since the fetch was never made.
886    #[cfg(unix)]
887    #[test]
888    fn an_unreadable_target_is_reported_not_replaced() {
889        use std::os::unix::fs::PermissionsExt;
890
891        let dir = tempfile::tempdir().unwrap();
892        let body = b"the complete model weights";
893        let target = dir.path().join("asr.gguf");
894        fs::write(&target, body).unwrap();
895        fs::set_permissions(&target, fs::Permissions::from_mode(0o000)).unwrap();
896
897        let cfg = ConfigBuilder::new()
898            .models_dir(dir.path())
899            .asr_model(offline_spec("asr.gguf", body))
900            .normalizer(None)
901            .build();
902
903        let err = prepare(&cfg, |_| ControlFlow::Continue(()))
904            .expect_err("a model that cannot be read cannot be verified");
905        assert!(
906            matches!(err, Error::Io { .. }),
907            "an unreadable file must stay an io error rather than be sent to the network, got: {err:?}"
908        );
909        assert!(
910            target.exists(),
911            "an io error says nothing about the bytes, so the file must survive it"
912        );
913
914        fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap();
915    }
916
917    fn hashing_sink<'a, 'r>(
918        interval: u64,
919        on_progress: &'a mut Reporter<'r>,
920    ) -> HashingWriter<'a, 'r> {
921        HashingWriter {
922            hasher: Sha256::new(),
923            name: "asr.gguf",
924            hashed: 0,
925            reported: 0,
926            cancelled: false,
927            interval,
928            on_progress,
929        }
930    }
931
932    // The sink checkpoints cancellation mid-hash, so esc during
933    // verification costs one interval rather than the whole digest.
934    #[test]
935    fn the_hashing_sink_breaks_at_its_interval() {
936        let body = b"0123456789abcdefghij";
937        let events = std::cell::RefCell::new(Vec::new());
938        let mut on_progress = |p: Progress| {
939            events.borrow_mut().push(p);
940            ControlFlow::Break(())
941        };
942        let mut sink = hashing_sink(8, &mut on_progress);
943
944        // Five bytes stays under the interval: no report, no error.
945        sink.write_all(&body[..5]).unwrap();
946        assert!(events.borrow().is_empty(), "no checkpoint under the interval");
947
948        // Crossing 8 on the second write reports once, and the break
949        // surfaces as an error carrying the cancelled flag.
950        assert!(sink.write_all(&body[5..15]).is_err(), "a break must fail the write");
951        assert!(sink.cancelled, "the caller must be able to tell a break from a disk fault");
952        assert_eq!(sink.hashed, 15, "every fed byte reaches the count");
953        assert_eq!(sink.reported, 15, "the checkpoint reported what it had hashed");
954        assert_eq!(events.borrow().len(), 1, "exactly one checkpoint fired before the break");
955        let first = events.borrow()[0].clone();
956        assert!(
957            matches!(first, Progress::Verifying { ref file } if file == "asr.gguf"),
958            "the checkpoint names the file being verified, got: {first:?}"
959        );
960    }
961
962    // Uncancelled, the sink is just a hasher: the digest it accumulates
963    // over the fed bytes is the digest of those bytes.
964    #[test]
965    fn the_hashing_sink_accumulates_the_fed_digest() {
966        let body = b"pretend these are recognition weights";
967        let mut on_progress = |_| ControlFlow::Continue(());
968        let mut sink = hashing_sink(u64::MAX, &mut on_progress);
969
970        sink.write_all(body).unwrap();
971        assert_eq!(sink.hashed, body.len() as u64, "every fed byte reaches the count");
972        assert_eq!(sink.reported, 0, "an interval nothing crosses reports nothing");
973        assert!(!sink.cancelled, "a continuing callback never cancels");
974        assert_eq!(
975            hex::encode(sink.hasher.finalize()),
976            hex::encode(Sha256::digest(body)),
977            "the sink must hash exactly the bytes it is fed"
978        );
979    }
980}
981
982#[cfg(test)]
983mod tests_download {
984    use super::*;
985    use crate::{ConfigBuilder, ModelFacts, ModelSpec, spec_for_download};
986    use sha2::{Digest, Sha256};
987    use std::io::{BufRead, BufReader, Write};
988    use std::net::TcpListener;
989    use std::sync::mpsc;
990    use std::thread;
991
992    /// Loopback HTTP/1.1 server serving a fixed set of paths and
993    /// honouring `Range: bytes=N-`, so the resume path has a real 206
994    /// to resume against. Every request head it saw is readable from
995    /// `seen`, which is how a test proves the `Range` header was sent
996    /// rather than inferring it from the resulting bytes.
997    struct Server {
998        base: String,
999        seen: mpsc::Receiver<String>,
1000    }
1001
1002    /// How the server answers a `Range` request. Nothing obliges it to
1003    /// honour one, and the two ways of not honouring it fail differently.
1004    #[derive(Clone, Copy)]
1005    enum Ranges {
1006        /// Serve from the requested offset, as a compliant server does.
1007        Honour,
1008        /// Answer 200 with the whole file, as a mirror or proxy may.
1009        Ignore,
1010        /// Answer 206, but from byte zero rather than where asked.
1011        Clamp,
1012    }
1013
1014    fn serve(files: Vec<(&'static str, Vec<u8>)>) -> Server {
1015        serve_inner(files, Ranges::Honour)
1016    }
1017
1018    fn serve_ignoring_range(files: Vec<(&'static str, Vec<u8>)>) -> Server {
1019        serve_inner(files, Ranges::Ignore)
1020    }
1021
1022    fn serve_clamping_range(files: Vec<(&'static str, Vec<u8>)>) -> Server {
1023        serve_inner(files, Ranges::Clamp)
1024    }
1025
1026    fn serve_inner(files: Vec<(&'static str, Vec<u8>)>, ranges: Ranges) -> Server {
1027        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1028        let base = format!("http://{}", listener.local_addr().unwrap());
1029        let (tx, seen) = mpsc::channel();
1030
1031        // Detached: nextest gives each test its own process, so the
1032        // accept loop dies with it and needs no shutdown channel.
1033        thread::spawn(move || {
1034            for stream in listener.incoming() {
1035                let Ok(mut stream) = stream else { break };
1036                let mut reader = BufReader::new(stream.try_clone().unwrap());
1037                let mut head = String::new();
1038                loop {
1039                    let mut line = String::new();
1040                    if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
1041                        break;
1042                    }
1043                    head.push_str(&line);
1044                }
1045
1046                let path = head.split_whitespace().nth(1).unwrap_or("/").to_string();
1047                let asked: Option<usize> = head
1048                    .lines()
1049                    .find(|l| l.to_ascii_lowercase().starts_with("range:"))
1050                    .and_then(|l| l.split("bytes=").nth(1))
1051                    .and_then(|r| r.trim().trim_end_matches('-').parse().ok());
1052                // Whether we answer 206, and from where, are separate
1053                // choices: a clamping proxy says 206 and means zero.
1054                let (partial, start) = match (ranges, asked) {
1055                    (Ranges::Honour, Some(n)) => (true, n),
1056                    (Ranges::Clamp, Some(_)) => (true, 0),
1057                    // No range asked, or one this server declines to act on.
1058                    (Ranges::Ignore, Some(_)) | (_, None) => (false, 0),
1059                };
1060                tx.send(head).unwrap();
1061
1062                let body = files.iter().find(|(p, _)| *p == path).map(|(_, b)| b);
1063                let response = match body {
1064                    None => {
1065                        b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1066                            .to_vec()
1067                    }
1068                    Some(full) => {
1069                        let slice = &full[start.min(full.len())..];
1070                        let mut head = if partial {
1071                            format!(
1072                                "HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {}-{}/{}\r\n",
1073                                slice.len(),
1074                                start,
1075                                full.len() - 1,
1076                                full.len()
1077                            )
1078                        } else {
1079                            format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n", slice.len())
1080                        };
1081                        head.push_str("Connection: close\r\n\r\n");
1082                        let mut out = head.into_bytes();
1083                        out.extend_from_slice(slice);
1084                        out
1085                    }
1086                };
1087                let _ = stream.write_all(&response);
1088                let _ = stream.flush();
1089            }
1090        });
1091
1092        Server { base, seen }
1093    }
1094
1095    fn spec_for(server: &Server, path: &str, body: &[u8]) -> ModelSpec {
1096        ModelSpec {
1097            file: path.trim_start_matches('/').into(),
1098            url: format!("{}{path}", server.base),
1099            size: body.len() as u64,
1100            sha256: Some(hex::encode(Sha256::digest(body))),
1101            facts: ModelFacts::default(),
1102        }
1103    }
1104
1105    #[test]
1106    fn every_configured_model_is_downloaded_and_verified() {
1107        let asr = b"pretend these are recognition weights".to_vec();
1108        let norm = b"and these rewrite the words".to_vec();
1109        let server = serve(vec![("/asr.gguf", asr.clone()), ("/norm.gguf", norm.clone())]);
1110        let dir = tempfile::tempdir().unwrap();
1111
1112        let cfg = ConfigBuilder::new()
1113            .models_dir(dir.path())
1114            .asr_model(spec_for(&server, "/asr.gguf", &asr))
1115            .normalizer(spec_for(&server, "/norm.gguf", &norm))
1116            .build();
1117
1118        let mut reported = Vec::new();
1119        prepare(&cfg, |p| {
1120            reported.push(p);
1121            ControlFlow::Continue(())
1122        })
1123        .expect("both models must download and verify");
1124
1125        let landed_asr = fs::read(dir.path().join("asr.gguf"))
1126            .expect("the asr model must be on disk under its spec's file name");
1127        let landed_norm = fs::read(dir.path().join("norm.gguf"))
1128            .expect("the normalizer must be fetched too, not only the asr model");
1129        assert_eq!(landed_asr, asr, "the asr model must be the bytes the server served");
1130        assert_eq!(landed_norm, norm, "the normalizer must be the bytes the server served");
1131
1132        // Sorted, not in spec order: the models are prepared concurrently,
1133        // so which finishes first is not a property worth pinning.
1134        let mut ready: Vec<_> = reported
1135            .iter()
1136            .filter_map(|p| match p {
1137                Progress::Ready { file } => Some(file.as_str()),
1138                _ => None,
1139            })
1140            .collect();
1141        ready.sort_unstable();
1142        assert_eq!(ready, ["asr.gguf", "norm.gguf"], "each model must be reported ready");
1143    }
1144
1145    /// A panicking callback must unwind out of `prepare` rather than
1146    /// hang. The workers block waiting for a verdict, so if the channel
1147    /// they wait on can outlive the driver they park forever and
1148    /// `thread::scope` never joins - a hang with no message, inside a
1149    /// `spawn_blocking` that esc cannot reach.
1150    ///
1151    /// Watchdog rather than a bare call: the failure being guarded
1152    /// against is an infinite wait, and a test that reproduces it by
1153    /// hanging is worse than no test.
1154    #[test]
1155    fn a_panicking_callback_unwinds_rather_than_parking_the_workers() {
1156        let asr = b"pretend these are recognition weights".to_vec();
1157        let norm = b"and these rewrite the words".to_vec();
1158        let server = serve(vec![("/asr.gguf", asr.clone()), ("/norm.gguf", norm.clone())]);
1159        let dir = tempfile::tempdir().unwrap();
1160        let cfg = ConfigBuilder::new()
1161            .models_dir(dir.path())
1162            .asr_model(spec_for(&server, "/asr.gguf", &asr))
1163            .normalizer(spec_for(&server, "/norm.gguf", &norm))
1164            .build();
1165
1166        let running = std::thread::spawn(move || prepare(&cfg, |_| panic!("callback blew up")));
1167
1168        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1169        while !running.is_finished() && std::time::Instant::now() < deadline {
1170            std::thread::sleep(std::time::Duration::from_millis(25));
1171        }
1172        assert!(
1173            running.is_finished(),
1174            "prepare never returned: the workers are parked on a verdict that cannot arrive"
1175        );
1176        assert!(
1177            running.join().is_err(),
1178            "the callback's panic must surface to the caller, not be swallowed"
1179        );
1180    }
1181
1182    #[test]
1183    fn a_failing_model_does_not_stop_the_other_from_being_fetched() {
1184        let norm = b"and these rewrite the words".to_vec();
1185        let server = serve(vec![("/norm.gguf", norm.clone())]);
1186        let dir = tempfile::tempdir().unwrap();
1187
1188        let cfg = ConfigBuilder::new()
1189            .models_dir(dir.path())
1190            .asr_model(spec_for(&server, "/absent.gguf", b"never served"))
1191            .normalizer(spec_for(&server, "/norm.gguf", &norm))
1192            .build();
1193
1194        let err = prepare(&cfg, |_| ControlFlow::Continue(()))
1195            .expect_err("the missing asr model must still be reported");
1196        assert!(
1197            matches!(err, Error::HttpStatus { status: 404, .. }),
1198            "the asr model's 404 must survive the other model succeeding, got: {err:?}"
1199        );
1200        let landed = fs::read(dir.path().join("norm.gguf")).expect(
1201            "the models run concurrently, so one failing must not leave the other unattempted",
1202        );
1203        assert_eq!(landed, norm, "the normalizer must be the bytes the server served");
1204    }
1205
1206    #[test]
1207    fn a_download_reports_its_last_byte_before_it_reports_verifying() {
1208        let body = b"pretend these are recognition weights".to_vec();
1209        let server = serve(vec![("/asr.gguf", body.clone())]);
1210        let dir = tempfile::tempdir().unwrap();
1211
1212        let cfg = ConfigBuilder::new()
1213            .models_dir(dir.path())
1214            .asr_model(spec_for(&server, "/asr.gguf", &body))
1215            .normalizer(None)
1216            .build();
1217
1218        let mut reported = Vec::new();
1219        prepare(&cfg, |p| {
1220            reported.push(p);
1221            ControlFlow::Continue(())
1222        })
1223        .expect("the model must download");
1224
1225        let total = body.len() as u64;
1226        let sequence: Vec<_> = reported
1227            .iter()
1228            .map(|p| match p {
1229                Progress::Downloading { downloaded, .. } if *downloaded == total => {
1230                    "downloaded-all"
1231                }
1232                Progress::Downloading { .. } => "downloading",
1233                Progress::Verifying { .. } => "verifying",
1234                Progress::Ready { .. } => "ready",
1235            })
1236            .collect();
1237        assert_eq!(
1238            sequence,
1239            ["downloaded-all", "verifying", "ready"],
1240            "a caller must be told hashing has started, or a full progress bar looks like a hang"
1241        );
1242    }
1243
1244    #[test]
1245    fn interrupted_download_resumes_from_what_is_already_on_disk() {
1246        let body = b"pretend these are recognition weights".to_vec();
1247        let server = serve(vec![("/asr.gguf", body.clone())]);
1248        let dir = tempfile::tempdir().unwrap();
1249        // A previous run got this far and stopped.
1250        fs::write(dir.path().join("asr.gguf.part"), &body[..10]).unwrap();
1251
1252        let cfg = ConfigBuilder::new()
1253            .models_dir(dir.path())
1254            .asr_model(spec_for(&server, "/asr.gguf", &body))
1255            .normalizer(None)
1256            .build();
1257
1258        prepare(&cfg, |_| ControlFlow::Continue(()))
1259            .expect("a partial download must resume, not fail");
1260
1261        let head = server.seen.recv().unwrap();
1262        assert!(
1263            head.to_ascii_lowercase().contains("range: bytes=10-"),
1264            "resume must ask for the bytes it lacks, request was: {head}"
1265        );
1266        assert_eq!(
1267            fs::read(dir.path().join("asr.gguf")).unwrap(),
1268            body,
1269            "the resumed file must be the whole model, not the tail alone"
1270        );
1271    }
1272
1273    #[test]
1274    fn a_server_that_ignores_range_does_not_produce_a_spliced_file() {
1275        let body = b"pretend these are recognition weights".to_vec();
1276        let server = serve_ignoring_range(vec![("/asr.gguf", body.clone())]);
1277        let dir = tempfile::tempdir().unwrap();
1278        fs::write(dir.path().join("asr.gguf.part"), b"XXXXXXXXXX").unwrap();
1279
1280        let cfg = ConfigBuilder::new()
1281            .models_dir(dir.path())
1282            .asr_model(spec_for(&server, "/asr.gguf", &body))
1283            .normalizer(None)
1284            .build();
1285
1286        prepare(&cfg, |_| ControlFlow::Continue(()))
1287            .expect("a whole-file 200 must replace the partial, not be appended to it");
1288        assert_eq!(
1289            fs::read(dir.path().join("asr.gguf")).unwrap(),
1290            body,
1291            "a 200 answer to a ranged request must overwrite what was already on disk"
1292        );
1293    }
1294
1295    #[test]
1296    fn breaking_mid_transfer_leaves_a_resumable_prefix() {
1297        // Past PROGRESS_INTERVAL, so the callback is reached from inside
1298        // `write` rather than only from the final report. A body under
1299        // the threshold never exercises the mid-write path at all.
1300        let body: Vec<u8> = (0..1_572_864u32).map(|i| (i % 251) as u8).collect();
1301        let server = serve(vec![("/asr.gguf", body.clone())]);
1302        let dir = tempfile::tempdir().unwrap();
1303
1304        let cfg = ConfigBuilder::new()
1305            .models_dir(dir.path())
1306            .asr_model(spec_for(&server, "/asr.gguf", &body))
1307            .normalizer(None)
1308            .build();
1309
1310        let err = prepare(&cfg, |_| ControlFlow::Break(()))
1311            .expect_err("a callback that breaks must stop the transfer");
1312        assert!(matches!(err, Error::Cancelled), "a break must surface as Cancelled, got: {err:?}");
1313
1314        let partial = fs::read(dir.path().join("asr.gguf.part")).expect("the partial must remain");
1315        assert!(
1316            !partial.is_empty() && partial.len() < body.len(),
1317            "cancelling must stop short: kept {} of {} bytes",
1318            partial.len(),
1319            body.len()
1320        );
1321        assert_eq!(
1322            partial,
1323            body[..partial.len()],
1324            "what is kept must be a prefix of the file, or a later call resumes onto wrong bytes"
1325        );
1326    }
1327
1328    #[test]
1329    fn breaking_after_the_last_byte_still_aborts() {
1330        let body = b"pretend these are recognition weights".to_vec();
1331        let server = serve(vec![("/asr.gguf", body.clone())]);
1332        let dir = tempfile::tempdir().unwrap();
1333
1334        let cfg = ConfigBuilder::new()
1335            .models_dir(dir.path())
1336            .asr_model(spec_for(&server, "/asr.gguf", &body))
1337            .normalizer(None)
1338            .build();
1339
1340        // Under PROGRESS_INTERVAL, so the only callback is the final
1341        // report after the whole body is already on disk.
1342        let err = prepare(&cfg, |_| ControlFlow::Break(()))
1343            .expect_err("a break at the closing report must still abort");
1344        assert!(matches!(err, Error::Cancelled), "a break must surface as Cancelled, got: {err:?}");
1345        assert!(
1346            !dir.path().join("asr.gguf").exists(),
1347            "a cancelled transfer must not be promoted to the real file"
1348        );
1349    }
1350
1351    #[test]
1352    fn a_refusing_server_is_reported_as_its_status() {
1353        let server = serve(vec![("/asr.gguf", b"present".to_vec())]);
1354        let dir = tempfile::tempdir().unwrap();
1355
1356        let cfg = ConfigBuilder::new()
1357            .models_dir(dir.path())
1358            .asr_model(spec_for(&server, "/absent.gguf", b"never served"))
1359            .normalizer(None)
1360            .build();
1361
1362        let err =
1363            prepare(&cfg, |_| ControlFlow::Continue(())).expect_err("a 404 is not a download");
1364        assert!(
1365            matches!(err, Error::HttpStatus { status: 404, .. }),
1366            "a refusal must be reported as the status it was, not as a corrupt file, got: {err:?}"
1367        );
1368    }
1369
1370    #[test]
1371    fn a_206_from_an_offset_we_did_not_ask_for_does_not_splice() {
1372        let body = b"pretend these are recognition weights".to_vec();
1373        let server = serve_clamping_range(vec![("/asr.gguf", body.clone())]);
1374        let dir = tempfile::tempdir().unwrap();
1375        fs::write(dir.path().join("asr.gguf.part"), &body[..10]).unwrap();
1376
1377        let cfg = ConfigBuilder::new()
1378            .models_dir(dir.path())
1379            .asr_model(spec_for(&server, "/asr.gguf", &body))
1380            .normalizer(None)
1381            .build();
1382
1383        prepare(&cfg, |_| ControlFlow::Continue(()))
1384            .expect("a 206 starting at zero must replace the partial");
1385        assert_eq!(
1386            fs::read(dir.path().join("asr.gguf")).unwrap(),
1387            body,
1388            "only the requested offset makes a 206 a resume; any other start must overwrite"
1389        );
1390    }
1391
1392    #[test]
1393    fn partial_that_fails_verification_is_discarded() {
1394        let body = b"pretend these are recognition weights".to_vec();
1395        let server = serve(vec![("/asr.gguf", body.clone())]);
1396        let dir = tempfile::tempdir().unwrap();
1397        let partial = dir.path().join("asr.gguf.part");
1398        // Right length, wrong bytes: resuming appends the correct tail
1399        // onto a corrupt head, so the result can never verify.
1400        fs::write(&partial, b"CORRUPTED!").unwrap();
1401
1402        let cfg = ConfigBuilder::new()
1403            .models_dir(dir.path())
1404            .asr_model(spec_for(&server, "/asr.gguf", &body))
1405            .normalizer(None)
1406            .build();
1407
1408        let err = prepare(&cfg, |_| ControlFlow::Continue(()))
1409            .expect_err("a corrupt resume must not be accepted");
1410        assert!(
1411            matches!(err, Error::HashMismatch { .. }),
1412            "expected a hash rejection, got: {err:?}"
1413        );
1414        assert!(
1415            !partial.exists(),
1416            "a partial that cannot ever verify must be discarded, or every later resume inherits it"
1417        );
1418    }
1419
1420    /// Two feed entries shipping one file name, so the name is already
1421    /// taken by different bytes: the mismatch is what a fetch settles,
1422    /// not something to refuse.
1423    #[test]
1424    fn a_model_of_the_wrong_bytes_is_replaced_by_a_fetch() {
1425        let body = b"pretend these are recognition weights".to_vec();
1426        let server = serve(vec![("/asr.gguf", body.clone())]);
1427        let dir = tempfile::tempdir().unwrap();
1428        // The same length, so the size check passes and only the digest
1429        // can tell the two apart.
1430        fs::write(dir.path().join("asr.gguf"), vec![b'x'; body.len()]).unwrap();
1431
1432        let cfg = ConfigBuilder::new()
1433            .models_dir(dir.path())
1434            .asr_model(spec_for(&server, "/asr.gguf", &body))
1435            .normalizer(None)
1436            .build();
1437
1438        prepare(&cfg, |_| ControlFlow::Continue(()))
1439            .expect("a file whose bytes are wrong must be replaced, not refused");
1440        assert_eq!(
1441            fs::read(dir.path().join("asr.gguf")).unwrap(),
1442            body,
1443            "the name must end up holding the served bytes, not the ones it had"
1444        );
1445    }
1446
1447    #[test]
1448    fn a_truncated_model_is_replaced_by_a_fetch() {
1449        let body = b"pretend these are recognition weights".to_vec();
1450        let server = serve(vec![("/asr.gguf", body.clone())]);
1451        let dir = tempfile::tempdir().unwrap();
1452        fs::write(dir.path().join("asr.gguf"), &body[..body.len() - 1]).unwrap();
1453
1454        let cfg = ConfigBuilder::new()
1455            .models_dir(dir.path())
1456            .asr_model(spec_for(&server, "/asr.gguf", &body))
1457            .normalizer(None)
1458            .build();
1459
1460        prepare(&cfg, |_| ControlFlow::Continue(()))
1461            .expect("a short file must be replaced, not refused");
1462        assert_eq!(
1463            fs::read(dir.path().join("asr.gguf")).unwrap(),
1464            body,
1465            "the name must end up holding the served bytes, not the truncated ones"
1466        );
1467    }
1468
1469    /// A candidate whose upstream publishes no digest: the size is the
1470    /// whole check, and a wrong size is a fetch's to settle like any
1471    /// other mismatch. Refusing it leaves a truncated file unfixable.
1472    #[test]
1473    fn a_digestless_model_of_the_wrong_size_is_replaced_by_a_fetch() {
1474        let body = b"pretend these are recognition weights".to_vec();
1475        let server = serve(vec![("/asr.gguf", body.clone())]);
1476        let dir = tempfile::tempdir().unwrap();
1477        fs::write(dir.path().join("asr.gguf"), &body[..10]).unwrap();
1478
1479        let cfg = ConfigBuilder::new()
1480            .models_dir(dir.path())
1481            .asr_model(spec_for_download(
1482                "asr.gguf",
1483                &format!("{}/asr.gguf", server.base),
1484                body.len() as u64,
1485                None,
1486                ModelFacts::default(),
1487            ))
1488            .normalizer(None)
1489            .build();
1490
1491        prepare(&cfg, |_| ControlFlow::Continue(()))
1492            .expect("a digestless file of the wrong size must be replaced, not refused");
1493        assert_eq!(
1494            fs::read(dir.path().join("asr.gguf")).unwrap(),
1495            body,
1496            "the name must end up holding the served bytes, not the short ones"
1497        );
1498    }
1499
1500    /// One fetch, not a loop: a replacement that also fails verification
1501    /// is the refusal the caller already had. The file being replaced is
1502    /// still there afterwards - nothing is cleared before the bytes that
1503    /// replace it have verified.
1504    #[test]
1505    fn a_mismatch_a_fetch_cannot_settle_is_still_refused() {
1506        let other = b"the copy the server holds is wrong too".to_vec();
1507        let wanted = b"pretend these are recognition weights".to_vec();
1508        let server = serve(vec![("/asr.gguf", other.clone())]);
1509        let dir = tempfile::tempdir().unwrap();
1510        let target = dir.path().join("asr.gguf");
1511        let wrong = vec![b'x'; wanted.len()];
1512        fs::write(&target, &wrong).unwrap();
1513
1514        let cfg = ConfigBuilder::new()
1515            .models_dir(dir.path())
1516            .asr_model(spec_for(&server, "/asr.gguf", &wanted))
1517            .normalizer(None)
1518            .build();
1519
1520        let err = prepare(&cfg, |_| ControlFlow::Continue(()))
1521            .expect_err("bytes that fail verification twice must not be accepted");
1522        assert!(
1523            matches!(
1524                err,
1525                Error::SizeMismatch { expected, actual, .. }
1526                    if (expected, actual) == (wanted.len() as u64, other.len() as u64)
1527            ),
1528            "the second mismatch must be reported as the mismatch it is, got: {err:?}"
1529        );
1530        assert_eq!(
1531            server.seen.try_iter().count(),
1532            1,
1533            "the repair is a single fetch, not a retry loop"
1534        );
1535        assert_eq!(
1536            fs::read(&target).unwrap(),
1537            wrong,
1538            "a replacement that never verified must not cost the file it was replacing"
1539        );
1540    }
1541}
1542
1543#[cfg(test)]
1544mod tests_digest_cache {
1545    use super::*;
1546    use crate::{ConfigBuilder, ModelFacts, ModelSpec};
1547    use std::fs;
1548    use std::io::Write as _;
1549    use std::os::unix::fs::PermissionsExt;
1550    use std::time::{Duration, SystemTime};
1551
1552    const BODY: &[u8] = b"the complete model weights";
1553
1554    /// A spec whose URL is guaranteed unreachable, so a code path that
1555    /// decides to refetch fails loudly as [`Error::Http`] rather than
1556    /// quietly reaching a server this test never stood up.
1557    fn offline_spec(file: &str, body: &[u8]) -> ModelSpec {
1558        ModelSpec {
1559            file: file.into(),
1560            url: "http://127.0.0.1:1/unreachable".into(),
1561            size: body.len() as u64,
1562            sha256: Some(hex::encode(Sha256::digest(body))),
1563            facts: ModelFacts::default(),
1564        }
1565    }
1566
1567    fn config_with(models: &Path, digests: &Path, asr: ModelSpec) -> Config {
1568        ConfigBuilder::new()
1569            .models_dir(models)
1570            .digest_cache_dir(digests)
1571            .asr_model(asr)
1572            .normalizer(None)
1573            .build()
1574    }
1575
1576    fn config(models: &Path, digests: &Path) -> Config {
1577        config_with(models, digests, offline_spec("asr.gguf", BODY))
1578    }
1579
1580    fn tag(progress: &Progress) -> &'static str {
1581        match progress {
1582            Progress::Verifying { .. } => "verifying",
1583            Progress::Downloading { .. } => "downloading",
1584            Progress::Ready { .. } => "ready",
1585        }
1586    }
1587
1588    fn prepare_ok(cfg: &Config) -> Vec<&'static str> {
1589        let mut reported = Vec::new();
1590        prepare(cfg, |progress| {
1591            reported.push(tag(&progress));
1592            ControlFlow::Continue(())
1593        })
1594        .expect("prepare must succeed");
1595        reported
1596    }
1597
1598    /// A file the record does not cover: it must be read again, and the
1599    /// repair that follows is what reaches the network. The spec here is
1600    /// unreachable, so a repair surfaces as [`Error::Http`] - which is
1601    /// itself the proof that the file was not taken on trust, and that
1602    /// the fetch it reached never got far enough to touch the file.
1603    fn refuse_after_repair(cfg: &Config, target: &Path) {
1604        let before = fs::read(target).unwrap();
1605        let mut reported = Vec::new();
1606        let err = prepare(cfg, |progress| {
1607            reported.push(tag(&progress));
1608            ControlFlow::Continue(())
1609        })
1610        .expect_err("a file this run cannot accept must not be reported ready");
1611        assert!(
1612            reported.contains(&"verifying"),
1613            "the file must be read again rather than taken on trust, reported: {reported:?}"
1614        );
1615        assert!(
1616            matches!(err, Error::Http { .. }),
1617            "the refusal must be the repair it could not make, got: {err:?}"
1618        );
1619        assert_eq!(
1620            fs::read(target).unwrap(),
1621            before,
1622            "a fetch that died before it ever verified must not cost the file it was replacing"
1623        );
1624    }
1625
1626    /// Deny reads on a file without moving its size or its mtime, so a
1627    /// path that returns without reading it proves the hash was skipped
1628    /// and a path that reads it fails instead of quietly succeeding.
1629    fn deny_reads(path: &Path) {
1630        fs::set_permissions(path, fs::Permissions::from_mode(0o000)).unwrap();
1631    }
1632
1633    fn allow_reads(path: &Path) {
1634        fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
1635    }
1636
1637    /// Move a file's mtime without touching its bytes.
1638    fn touch_mtime(path: &Path, when: SystemTime) {
1639        fs::File::options().write(true).open(path).unwrap().set_modified(when).unwrap();
1640    }
1641
1642    fn mtime(path: &Path) -> SystemTime {
1643        fs::metadata(path).unwrap().modified().unwrap()
1644    }
1645
1646    #[test]
1647    fn an_unchanged_verified_file_is_not_read_again() {
1648        let models = tempfile::tempdir().unwrap();
1649        let digests = tempfile::tempdir().unwrap();
1650        let file = models.path().join("asr.gguf");
1651        fs::write(&file, BODY).unwrap();
1652        let cfg = config(models.path(), digests.path());
1653
1654        assert_eq!(
1655            prepare_ok(&cfg),
1656            ["verifying", "ready"],
1657            "a file with no record yet must be hashed this run"
1658        );
1659
1660        deny_reads(&file);
1661        assert_eq!(
1662            prepare_ok(&cfg),
1663            ["ready"],
1664            "a file whose size and mtime match its record must not be read again, and the run \
1665             must still report it ready"
1666        );
1667        allow_reads(&file);
1668    }
1669
1670    #[test]
1671    fn a_moved_mtime_is_hashed_again() {
1672        let models = tempfile::tempdir().unwrap();
1673        let digests = tempfile::tempdir().unwrap();
1674        let file = models.path().join("asr.gguf");
1675        fs::write(&file, BODY).unwrap();
1676        let cfg = config(models.path(), digests.path());
1677        prepare_ok(&cfg);
1678
1679        // Identical bytes delivered under a new mtime: a copy, or a
1680        // re-download. The state moved, so the record no longer covers it.
1681        touch_mtime(&file, SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000));
1682        deny_reads(&file);
1683        let err = prepare(&cfg, |_| ControlFlow::Continue(()))
1684            .expect_err("a file whose mtime moved must be read again");
1685        assert!(
1686            matches!(err, Error::Io { .. }),
1687            "the re-read must actually open the file, got: {err:?}"
1688        );
1689
1690        allow_reads(&file);
1691        assert_eq!(
1692            prepare_ok(&cfg),
1693            ["verifying", "ready"],
1694            "and once re-verified it is usable again"
1695        );
1696    }
1697
1698    #[test]
1699    fn a_size_change_is_not_taken_on_trust_with_the_mtime_restored() {
1700        let models = tempfile::tempdir().unwrap();
1701        let digests = tempfile::tempdir().unwrap();
1702        let file = models.path().join("asr.gguf");
1703        fs::write(&file, BODY).unwrap();
1704        let cfg = config(models.path(), digests.path());
1705        prepare_ok(&cfg);
1706
1707        // One byte longer, mtime put back to what the record holds, so
1708        // the size is the only field that can keep it from being a hit.
1709        let recorded = mtime(&file);
1710        fs::OpenOptions::new().append(true).open(&file).unwrap().write_all(b"!").unwrap();
1711        touch_mtime(&file, recorded);
1712        assert_eq!(
1713            mtime(&file),
1714            recorded,
1715            "this filesystem cannot restore an mtime exactly, so this test cannot isolate the size"
1716        );
1717
1718        refuse_after_repair(&cfg, &file);
1719    }
1720
1721    /// A build that pins a new digest for a re-released model, over a
1722    /// file already on disk: the record covers the file's state, not the
1723    /// spec, so a moved spec must re-hash rather than report the old file
1724    /// ready under a digest this build no longer blesses.
1725    #[test]
1726    fn a_moved_spec_digest_is_not_a_hit() {
1727        let models = tempfile::tempdir().unwrap();
1728        let digests = tempfile::tempdir().unwrap();
1729        let file = models.path().join("asr.gguf");
1730        fs::write(&file, BODY).unwrap();
1731        prepare_ok(&config(models.path(), digests.path()));
1732
1733        let mut moved = offline_spec("asr.gguf", BODY);
1734        moved.sha256 = Some(hex::encode(Sha256::digest(b"what the re-release weights are")));
1735        assert_eq!(
1736            moved.size,
1737            BODY.len() as u64,
1738            "only the digest may differ, or this test is about the size instead"
1739        );
1740
1741        refuse_after_repair(&config_with(models.path(), digests.path(), moved), &file);
1742    }
1743
1744    /// The direction that has to keep working: a file whose bytes were
1745    /// replaced is caught, because every real replacement moves the
1746    /// mtime with it.
1747    #[test]
1748    fn a_corrupted_file_whose_mtime_moved_is_still_caught() {
1749        let models = tempfile::tempdir().unwrap();
1750        let digests = tempfile::tempdir().unwrap();
1751        let file = models.path().join("asr.gguf");
1752        fs::write(&file, BODY).unwrap();
1753        let cfg = config(models.path(), digests.path());
1754        prepare_ok(&cfg);
1755
1756        fs::write(&file, vec![b'x'; BODY.len()]).unwrap();
1757        touch_mtime(&file, SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000));
1758
1759        refuse_after_repair(&cfg, &file);
1760    }
1761
1762    /// The trade this cache makes, pinned so it stays a decision: bytes
1763    /// replaced without moving the size or the mtime are taken on the
1764    /// recorded digest. Size and mtime are the whole key, which is what
1765    /// buys a boot without a 3.07 GB read; a download, a copy and an
1766    /// edit all move the mtime.
1767    #[test]
1768    fn same_size_and_mtime_is_taken_on_trust() {
1769        let models = tempfile::tempdir().unwrap();
1770        let digests = tempfile::tempdir().unwrap();
1771        let file = models.path().join("asr.gguf");
1772        fs::write(&file, BODY).unwrap();
1773        let cfg = config(models.path(), digests.path());
1774        prepare_ok(&cfg);
1775
1776        let recorded = mtime(&file);
1777        fs::write(&file, vec![b'x'; BODY.len()]).unwrap();
1778        touch_mtime(&file, recorded);
1779
1780        assert_eq!(
1781            prepare_ok(&cfg),
1782            ["ready"],
1783            "a state that matches the record is not re-read, whatever the bytes are"
1784        );
1785    }
1786
1787    /// The cache is an optimisation: a record that cannot be read, or a
1788    /// directory that cannot be written, must cost a hash rather than
1789    /// the run.
1790    #[test]
1791    fn an_unusable_cache_record_costs_a_hash_not_the_run() {
1792        let models = tempfile::tempdir().unwrap();
1793        let digests = tempfile::tempdir().unwrap();
1794        let file = models.path().join("asr.gguf");
1795        fs::write(&file, BODY).unwrap();
1796
1797        fs::write(digests.path().join("verified.json"), b"{ not json").unwrap();
1798        assert_eq!(
1799            prepare_ok(&config(models.path(), digests.path())),
1800            ["verifying", "ready"],
1801            "an unreadable record must fall back to hashing, not fail"
1802        );
1803
1804        let absent = digests.path().join("nothing-here-yet");
1805        assert_eq!(
1806            prepare_ok(&config(models.path(), &absent)),
1807            ["verifying", "ready"],
1808            "a record directory that does not exist yet must be created, not refused"
1809        );
1810
1811        let blocked = tempfile::tempdir().unwrap();
1812        let not_a_directory = blocked.path().join("a-file");
1813        fs::write(&not_a_directory, b"").unwrap();
1814        assert_eq!(
1815            prepare_ok(&config(models.path(), &not_a_directory)),
1816            ["verifying", "ready"],
1817            "a record path that cannot be written must not fail the run"
1818        );
1819    }
1820}