Skip to main content

aurum_core/model/
mod.rs

1//! Local whisper.cpp model management: resolve, download, cache, list.
2
3use crate::error::{EnvironmentError, ProviderError, Result, UserError};
4use futures_util::StreamExt;
5use indicatif::{ProgressBar, ProgressStyle};
6use sha2::{Digest, Sha256};
7use std::fs::{self, File, OpenOptions};
8use std::io::{Read, Write};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12/// HuggingFace repo hosting official ggml whisper.cpp models.
13const HF_BASE: &str = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main";
14
15/// Known local model names and their ggml filenames.
16#[derive(Debug, Clone, Copy)]
17pub struct ModelInfo {
18    pub name: &'static str,
19    pub filename: &'static str,
20    /// Approximate download size in bytes (for progress UX).
21    pub approx_bytes: u64,
22    /// Human label for lists (e.g. "quantized", "english-only").
23    pub notes: &'static str,
24}
25
26/// Models supported in v0.0.0 (full + common quantized variants).
27pub const MODELS: &[ModelInfo] = &[
28    // ---- tiny ----
29    ModelInfo {
30        name: "tiny",
31        filename: "ggml-tiny.bin",
32        approx_bytes: 75_000_000,
33        notes: "fastest full-precision",
34    },
35    ModelInfo {
36        name: "tiny-q5_1",
37        filename: "ggml-tiny-q5_1.bin",
38        approx_bytes: 32_000_000,
39        notes: "quantized ~32MB — best first-run trial",
40    },
41    ModelInfo {
42        name: "tiny-q8_0",
43        filename: "ggml-tiny-q8_0.bin",
44        approx_bytes: 44_000_000,
45        notes: "quantized",
46    },
47    ModelInfo {
48        name: "tiny.en",
49        filename: "ggml-tiny.en.bin",
50        approx_bytes: 75_000_000,
51        notes: "english-only",
52    },
53    ModelInfo {
54        name: "tiny.en-q5_1",
55        filename: "ggml-tiny.en-q5_1.bin",
56        approx_bytes: 32_000_000,
57        notes: "english-only quantized",
58    },
59    // ---- base ----
60    ModelInfo {
61        name: "base",
62        filename: "ggml-base.bin",
63        approx_bytes: 142_000_000,
64        notes: "default full-precision",
65    },
66    ModelInfo {
67        name: "base-q5_1",
68        filename: "ggml-base-q5_1.bin",
69        approx_bytes: 60_000_000,
70        notes: "quantized ~60MB",
71    },
72    ModelInfo {
73        name: "base-q8_0",
74        filename: "ggml-base-q8_0.bin",
75        approx_bytes: 82_000_000,
76        notes: "quantized",
77    },
78    ModelInfo {
79        name: "base.en",
80        filename: "ggml-base.en.bin",
81        approx_bytes: 142_000_000,
82        notes: "english-only",
83    },
84    ModelInfo {
85        name: "base.en-q5_1",
86        filename: "ggml-base.en-q5_1.bin",
87        approx_bytes: 60_000_000,
88        notes: "english-only quantized",
89    },
90    // ---- small ----
91    ModelInfo {
92        name: "small",
93        filename: "ggml-small.bin",
94        approx_bytes: 466_000_000,
95        notes: "higher accuracy",
96    },
97    ModelInfo {
98        name: "small-q5_1",
99        filename: "ggml-small-q5_1.bin",
100        approx_bytes: 190_000_000,
101        notes: "quantized",
102    },
103    ModelInfo {
104        name: "small-q8_0",
105        filename: "ggml-small-q8_0.bin",
106        approx_bytes: 264_000_000,
107        notes: "quantized",
108    },
109    ModelInfo {
110        name: "small.en",
111        filename: "ggml-small.en.bin",
112        approx_bytes: 466_000_000,
113        notes: "english-only",
114    },
115    ModelInfo {
116        name: "small.en-q5_1",
117        filename: "ggml-small.en-q5_1.bin",
118        approx_bytes: 190_000_000,
119        notes: "english-only quantized",
120    },
121    // ---- medium ----
122    ModelInfo {
123        name: "medium",
124        filename: "ggml-medium.bin",
125        approx_bytes: 1_500_000_000,
126        notes: "large download",
127    },
128    ModelInfo {
129        name: "medium.en",
130        filename: "ggml-medium.en.bin",
131        approx_bytes: 1_500_000_000,
132        notes: "english-only",
133    },
134    // ---- large ----
135    ModelInfo {
136        name: "large-v3",
137        filename: "ggml-large-v3.bin",
138        approx_bytes: 3_100_000_000,
139        notes: "highest quality",
140    },
141    ModelInfo {
142        name: "large-v3-q5_0",
143        filename: "ggml-large-v3-q5_0.bin",
144        approx_bytes: 1_080_000_000,
145        notes: "quantized",
146    },
147    ModelInfo {
148        name: "large",
149        filename: "ggml-large-v3.bin",
150        approx_bytes: 3_100_000_000,
151        notes: "alias of large-v3",
152    },
153    ModelInfo {
154        name: "large-v3-turbo",
155        filename: "ggml-large-v3-turbo.bin",
156        approx_bytes: 1_600_000_000,
157        notes: "fast large",
158    },
159    ModelInfo {
160        name: "large-v3-turbo-q5_0",
161        filename: "ggml-large-v3-turbo-q5_0.bin",
162        approx_bytes: 574_000_000,
163        notes: "quantized turbo",
164    },
165    ModelInfo {
166        name: "turbo",
167        filename: "ggml-large-v3-turbo.bin",
168        approx_bytes: 1_600_000_000,
169        notes: "alias of large-v3-turbo",
170    },
171    ModelInfo {
172        name: "turbo-q5_0",
173        filename: "ggml-large-v3-turbo-q5_0.bin",
174        approx_bytes: 574_000_000,
175        notes: "alias of large-v3-turbo-q5_0",
176    },
177];
178
179/// Names shown in user-facing help (canonical, not aliases).
180pub fn available_model_names() -> String {
181    list_canonical_models()
182        .iter()
183        .map(|m| m.name)
184        .collect::<Vec<_>>()
185        .join(", ")
186}
187
188fn list_canonical_models() -> Vec<&'static ModelInfo> {
189    MODELS
190        .iter()
191        .filter(|m| !matches!(m.name, "large" | "turbo" | "turbo-q5_0"))
192        .collect()
193}
194
195pub fn lookup_model(name: &str) -> Result<&'static ModelInfo> {
196    let key = name.trim().to_ascii_lowercase();
197    MODELS.iter().find(|m| m.name == key).ok_or_else(|| {
198        UserError::InvalidModel {
199            model: name.to_string(),
200            available: available_model_names(),
201        }
202        .into()
203    })
204}
205
206/// Directory where ggml models are stored: `<cache>/models/`.
207pub fn models_dir(cache_dir: &Path) -> PathBuf {
208    cache_dir.join("models")
209}
210
211/// Path to a cached model file (may not exist yet).
212pub fn model_path(cache_dir: &Path, info: &ModelInfo) -> PathBuf {
213    models_dir(cache_dir).join(info.filename)
214}
215
216/// Status of a model relative to the local cache.
217#[derive(Debug, Clone)]
218pub struct ModelStatus {
219    pub info: &'static ModelInfo,
220    pub cached: bool,
221    pub path: PathBuf,
222    pub size_bytes: Option<u64>,
223}
224
225/// List canonical models and whether each is cached.
226pub fn list_models(cache_dir: &Path) -> Vec<ModelStatus> {
227    list_canonical_models()
228        .into_iter()
229        .map(|info| {
230            let path = model_path(cache_dir, info);
231            let (cached, size_bytes) = match fs::metadata(&path) {
232                Ok(m) if m.len() > 1_000_000 => (true, Some(m.len())),
233                _ => (false, None),
234            };
235            ModelStatus {
236                info,
237                cached,
238                path,
239                size_bytes,
240            }
241        })
242        .collect()
243}
244
245/// Format a human-readable model table for CLI output.
246pub fn format_model_list(cache_dir: &Path) -> String {
247    let rows = list_models(cache_dir);
248    let mut out = String::from("Local whisper.cpp models (cache: ");
249    out.push_str(&models_dir(cache_dir).display().to_string());
250    out.push_str(")\n\n");
251    out.push_str(&format!(
252        "{:<22} {:>10}  {:<8}  {}\n",
253        "NAME", "SIZE", "STATUS", "NOTES"
254    ));
255    out.push_str(&format!(
256        "{:<22} {:>10}  {:<8}  {}\n",
257        "----", "----", "------", "-----"
258    ));
259    for row in rows {
260        let size = format_bytes(row.info.approx_bytes);
261        let status = if row.cached { "cached" } else { "—" };
262        out.push_str(&format!(
263            "{:<22} {:>10}  {:<8}  {}\n",
264            row.info.name, size, status, row.info.notes
265        ));
266    }
267    out.push_str(
268        "\nTip: first run downloads the selected model. Try `tiny-q5_1` (~32 MB) for a quick trial.\n",
269    );
270    out.push_str("Default model: `base` (~142 MB). Use --model <name> to choose.\n");
271    out
272}
273
274fn format_bytes(n: u64) -> String {
275    const KB: f64 = 1024.0;
276    const MB: f64 = KB * 1024.0;
277    const GB: f64 = MB * 1024.0;
278    let n = n as f64;
279    if n >= GB {
280        format!("{:.1} GB", n / GB)
281    } else if n >= MB {
282        format!("{:.0} MB", n / MB)
283    } else {
284        format!("{:.0} KB", n / KB)
285    }
286}
287
288/// Progress event while downloading a model (library hosts / UI).
289#[derive(Debug, Clone)]
290pub struct DownloadProgress {
291    pub model: String,
292    pub downloaded_bytes: u64,
293    pub total_bytes: u64,
294}
295
296impl DownloadProgress {
297    pub fn fraction(&self) -> Option<f64> {
298        if self.total_bytes == 0 {
299            None
300        } else {
301            Some((self.downloaded_bytes as f64 / self.total_bytes as f64).clamp(0.0, 1.0))
302        }
303    }
304}
305
306/// Callback for download progress. Invoked from the async download task.
307pub type DownloadProgressCallback = Arc<dyn Fn(DownloadProgress) + Send + Sync>;
308
309/// Options for [`ensure_model`].
310#[derive(Clone, Default)]
311pub struct EnsureModelOptions {
312    /// When true, never hit the network; fail if the model is not already cached.
313    pub local_only: bool,
314    /// Show CLI-style progress on stderr (indicatif).
315    pub show_progress: bool,
316    /// Optional structured progress hook for embedders.
317    pub on_progress: Option<DownloadProgressCallback>,
318}
319
320impl EnsureModelOptions {
321    pub fn new() -> Self {
322        Self::default()
323    }
324
325    pub fn local_only(mut self, v: bool) -> Self {
326        self.local_only = v;
327        self
328    }
329
330    pub fn show_progress(mut self, v: bool) -> Self {
331        self.show_progress = v;
332        self
333    }
334
335    pub fn on_progress(mut self, cb: DownloadProgressCallback) -> Self {
336        self.on_progress = Some(cb);
337        self
338    }
339}
340
341/// True if a usable model file is already on disk.
342pub fn is_model_cached(cache_dir: &Path, model_name: &str) -> bool {
343    let Ok(info) = lookup_model(model_name) else {
344        return false;
345    };
346    let path = model_path(cache_dir, info);
347    if !(path.exists()
348        && path
349            .metadata()
350            .map(|m| m.len() > 1_000_000)
351            .unwrap_or(false))
352    {
353        return false;
354    }
355    // Pinned path: one hash inside verify_model_basic. Unpinned: magic + sidecar.
356    if pinned_sha256(info.filename).is_some() {
357        verify_model_basic(&path, info).is_ok()
358    } else {
359        verify_model_basic(&path, info).is_ok() && verify_cached_checksum(&path)
360    }
361}
362
363/// Ensure a model is present locally, downloading if needed. Returns the path.
364pub async fn ensure_model(
365    cache_dir: &Path,
366    model_name: &str,
367    show_progress: bool,
368) -> Result<PathBuf> {
369    ensure_model_with_options(
370        cache_dir,
371        model_name,
372        EnsureModelOptions {
373            show_progress,
374            ..EnsureModelOptions::default()
375        },
376    )
377    .await
378}
379
380/// Ensure model with offline / progress options.
381pub async fn ensure_model_with_options(
382    cache_dir: &Path,
383    model_name: &str,
384    opts: EnsureModelOptions,
385) -> Result<PathBuf> {
386    let info = lookup_model(model_name)?;
387    let path = model_path(cache_dir, info);
388
389    if path.exists()
390        && path
391            .metadata()
392            .map(|m| m.len() > 1_000_000)
393            .unwrap_or(false)
394    {
395        // Prefer pinned SHA when available (one full hash). Otherwise magic + optional sidecar.
396        let ok = if pinned_sha256(info.filename).is_some() {
397            verify_model_basic(&path, info).is_ok()
398        } else {
399            verify_model_basic(&path, info).is_ok() && verify_cached_checksum(&path)
400        };
401        if ok {
402            tracing::info!(model = info.name, path = %path.display(), "using cached model");
403            return Ok(path);
404        }
405        tracing::warn!(
406            model = info.name,
407            path = %path.display(),
408            "cached model failed integrity check; re-downloading"
409        );
410        let _ = fs::remove_file(&path);
411    }
412
413    if opts.local_only {
414        return Err(UserError::ModelNotCached {
415            model: model_name.to_string(),
416        }
417        .into());
418    }
419
420    fs::create_dir_all(models_dir(cache_dir)).map_err(|e| EnvironmentError::DirectoryAccess {
421        path: models_dir(cache_dir).display().to_string(),
422        reason: e.to_string(),
423    })?;
424
425    // Cross-process advisory lock so concurrent aurum runs don't double-download.
426    let lock_path = path.with_extension("bin.lock");
427    let lock_file = OpenOptions::new()
428        .create(true)
429        .read(true)
430        .write(true)
431        .truncate(false)
432        .open(&lock_path)
433        .map_err(|e| EnvironmentError::DirectoryAccess {
434            path: lock_path.display().to_string(),
435            reason: e.to_string(),
436        })?;
437
438    if opts.show_progress {
439        eprintln!("aurum: waiting for model download lock ({}) …", info.name);
440    }
441    lock_file.lock().map_err(|e| EnvironmentError::Other {
442        message: format!("failed to acquire model lock: {e}"),
443    })?;
444
445    // Re-check after lock — another process may have finished the download.
446    if path.exists()
447        && path
448            .metadata()
449            .map(|m| m.len() > 1_000_000)
450            .unwrap_or(false)
451        && verify_model_basic(&path, info).is_ok()
452        && verify_cached_checksum(&path)
453    {
454        let _ = lock_file.unlock();
455        tracing::info!(model = info.name, "model appeared while waiting on lock");
456        return Ok(path);
457    }
458
459    if opts.local_only {
460        let _ = lock_file.unlock();
461        return Err(UserError::ModelNotCached {
462            model: model_name.to_string(),
463        }
464        .into());
465    }
466
467    if opts.show_progress {
468        eprintln!(
469            "aurum: downloading model `{}` ({}) — first run only …",
470            info.name,
471            format_bytes(info.approx_bytes)
472        );
473    }
474
475    let result = download_model(info, &path, opts.show_progress, opts.on_progress.as_ref()).await;
476    let _ = lock_file.unlock();
477    result?;
478    verify_model_basic(&path, info)?;
479    Ok(path)
480}
481
482/// If a `.sha256` sidecar exists, ensure it matches the file contents.
483fn verify_cached_checksum(path: &Path) -> bool {
484    let checksum_path = path.with_extension("bin.sha256");
485    let Ok(contents) = fs::read_to_string(&checksum_path) else {
486        return true;
487    };
488    let Some(expected) = contents.split_whitespace().next() else {
489        return true;
490    };
491    let Ok(mut file) = File::open(path) else {
492        return false;
493    };
494    let mut hasher = Sha256::new();
495    let mut buf = [0u8; 1024 * 64];
496    loop {
497        match file.read(&mut buf) {
498            Ok(0) => break,
499            Ok(n) => hasher.update(&buf[..n]),
500            Err(_) => return false,
501        }
502    }
503    let actual = hex::encode(hasher.finalize());
504    if actual != expected {
505        tracing::warn!(%expected, %actual, "model checksum mismatch");
506        return false;
507    }
508    true
509}
510
511async fn download_model(
512    info: &ModelInfo,
513    dest: &Path,
514    show_progress: bool,
515    on_progress: Option<&DownloadProgressCallback>,
516) -> Result<()> {
517    let url = format!("{HF_BASE}/{}?download=true", info.filename);
518    tracing::info!(model = info.name, %url, "downloading model");
519
520    let partial = dest.with_extension(format!(
521        "bin.partial.{}-{}",
522        std::process::id(),
523        std::time::SystemTime::now()
524            .duration_since(std::time::UNIX_EPOCH)
525            .map(|d| d.as_millis())
526            .unwrap_or(0)
527    ));
528    if partial.exists() {
529        let _ = fs::remove_file(&partial);
530    }
531
532    // Bound total download time so a stalled transfer can't hold the lock forever.
533    let client = reqwest::Client::builder()
534        .user_agent(concat!("aurum-core/", env!("CARGO_PKG_VERSION")))
535        .connect_timeout(std::time::Duration::from_secs(30))
536        .timeout(std::time::Duration::from_secs(30 * 60))
537        .build()
538        .map_err(|e| ProviderError::ModelDownload {
539            model: info.name.to_string(),
540            reason: format!("http client error: {e}"),
541        })?;
542
543    let response = client
544        .get(&url)
545        .send()
546        .await
547        .map_err(|e| ProviderError::ModelDownload {
548            model: info.name.to_string(),
549            reason: format!("request failed: {e}"),
550        })?;
551
552    if !response.status().is_success() {
553        return Err(ProviderError::ModelDownload {
554            model: info.name.to_string(),
555            reason: format!("HTTP {}", response.status()),
556        }
557        .into());
558    }
559
560    let total = response.content_length().unwrap_or(info.approx_bytes);
561
562    let pb = if show_progress {
563        let pb = ProgressBar::new(total);
564        pb.set_style(
565            ProgressStyle::with_template(
566                "{msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})",
567            )
568            .unwrap_or_else(|_| ProgressStyle::default_bar())
569            .progress_chars("=>-"),
570        );
571        pb.set_message(format!("Downloading {}", info.name));
572        Some(pb)
573    } else {
574        None
575    };
576
577    let mut file = File::create(&partial).map_err(|e| EnvironmentError::DirectoryAccess {
578        path: partial.display().to_string(),
579        reason: e.to_string(),
580    })?;
581
582    let mut stream = response.bytes_stream();
583    let mut hasher = Sha256::new();
584    let mut downloaded: u64 = 0;
585
586    while let Some(chunk) = stream.next().await {
587        let chunk = chunk.map_err(|e| ProviderError::ModelDownload {
588            model: info.name.to_string(),
589            reason: format!("stream error: {e}"),
590        })?;
591        file.write_all(&chunk)
592            .map_err(|e| EnvironmentError::DiskSpace {
593                path: partial.display().to_string(),
594                reason: e.to_string(),
595            })?;
596        hasher.update(&chunk);
597        downloaded += chunk.len() as u64;
598        // Cap stream size (disk-fill protection). Allow 25% slack over Content-Length / approx.
599        let max_allowed = total
600            .saturating_mul(5)
601            .saturating_div(4)
602            .max(info.approx_bytes);
603        if downloaded > max_allowed {
604            let _ = fs::remove_file(&partial);
605            return Err(ProviderError::ModelDownload {
606                model: info.name.to_string(),
607                reason: format!(
608                    "download exceeded size cap ({downloaded} > {max_allowed} bytes) — aborting"
609                ),
610            }
611            .into());
612        }
613        if let Some(pb) = &pb {
614            pb.set_position(downloaded.min(total));
615        }
616        if let Some(cb) = on_progress {
617            cb(DownloadProgress {
618                model: info.name.to_string(),
619                downloaded_bytes: downloaded,
620                total_bytes: total,
621            });
622        }
623    }
624
625    file.flush().ok();
626    drop(file);
627
628    fs::rename(&partial, dest).map_err(|e| EnvironmentError::DirectoryAccess {
629        path: dest.display().to_string(),
630        reason: e.to_string(),
631    })?;
632
633    if let Some(pb) = pb {
634        pb.finish_with_message(format!("Downloaded {} ({downloaded} bytes)", info.name));
635    }
636
637    let digest = hex::encode(hasher.finalize());
638    tracing::info!(
639        model = info.name,
640        sha256 = %digest,
641        bytes = downloaded,
642        "model download complete"
643    );
644
645    // Fail closed when we have a known-good pin for this filename.
646    if let Some(expected) = pinned_sha256(info.filename) {
647        if digest != expected {
648            let _ = fs::remove_file(dest);
649            return Err(ProviderError::ModelDownload {
650                model: info.name.to_string(),
651                reason: format!(
652                    "sha256 mismatch (got {digest}, expected {expected}) — refusing to cache"
653                ),
654            }
655            .into());
656        }
657    }
658
659    let checksum_path = dest.with_extension("bin.sha256");
660    let _ = fs::write(&checksum_path, format!("{digest}  {}\n", info.filename));
661
662    // Best-effort cleanup of orphaned partials from prior crashed runs.
663    sweep_stale_partials(dest.parent().unwrap_or_else(|| Path::new(".")));
664
665    Ok(())
666}
667
668/// Independently pinned SHA-256 digests for models we have verified.
669/// Unlisted models still get magic + size checks and a self-written sidecar.
670/// Independently verified SHA-256 digests (HuggingFace ggerganov/whisper.cpp main).
671fn pinned_sha256(filename: &str) -> Option<&'static str> {
672    match filename {
673        "ggml-tiny.bin" => Some("be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21"),
674        "ggml-tiny-q5_1.bin" => {
675            Some("818710568da3ca15689e31a743197b520007872ff9576237bda97bd1b469c3d7")
676        }
677        "ggml-tiny.en-q5_1.bin" => {
678            Some("c77c5766f1cef09b6b7d47f21b546cbddd4157886b3b5d6d4f709e91e66c7c2b")
679        }
680        "ggml-base.bin" => Some("60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe"),
681        "ggml-base-q5_1.bin" => {
682            Some("422f1ae452ade6f30a004d7e5c6a43195e4433bc370bf23fac9cc591f01a8898")
683        }
684        "ggml-base.en-q5_1.bin" => {
685            Some("4baf70dd0d7c4247ba2b81fafd9c01005ac77c2f9ef064e00dcf195d0e2fdd2f")
686        }
687        "ggml-small-q5_1.bin" => {
688            Some("ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb")
689        }
690        _ => None,
691    }
692}
693
694fn sweep_stale_partials(dir: &Path) {
695    let Ok(entries) = fs::read_dir(dir) else {
696        return;
697    };
698    let stale_after = std::time::Duration::from_secs(6 * 3600);
699    let now = std::time::SystemTime::now();
700    for ent in entries.flatten() {
701        let name = ent.file_name();
702        let name = name.to_string_lossy();
703        // Only stale leftovers — never touch another live download's unique partial.
704        if !name.contains(".bin.partial.") {
705            continue;
706        }
707        let Ok(meta) = ent.metadata() else {
708            continue;
709        };
710        let Ok(modified) = meta.modified() else {
711            continue;
712        };
713        if now.duration_since(modified).unwrap_or_default() > stale_after {
714            let _ = fs::remove_file(ent.path());
715        }
716    }
717}
718
719/// Basic integrity check: file exists, is large enough, and starts with ggml magic-ish bytes.
720fn verify_model_basic(path: &Path, info: &ModelInfo) -> Result<()> {
721    let meta = fs::metadata(path).map_err(|e| ProviderError::ModelDownload {
722        model: info.name.to_string(),
723        reason: format!("missing after download: {e}"),
724    })?;
725
726    if meta.len() < 1_000_000 {
727        let _ = fs::remove_file(path);
728        return Err(ProviderError::ModelDownload {
729            model: info.name.to_string(),
730            reason: format!(
731                "downloaded file is only {} bytes — likely truncated or HTML error page",
732                meta.len()
733            ),
734        }
735        .into());
736    }
737
738    let mut hdr = [0u8; 4];
739    let mut f = File::open(path).map_err(|e| ProviderError::ModelDownload {
740        model: info.name.to_string(),
741        reason: e.to_string(),
742    })?;
743    f.read_exact(&mut hdr)
744        .map_err(|e| ProviderError::ModelDownload {
745            model: info.name.to_string(),
746            reason: format!("cannot read header: {e}"),
747        })?;
748
749    let magic_ok = matches!(
750        &hdr,
751        b"ggml"
752            | b"lmgg"
753            | b"ggmf"
754            | b"fmgg"
755            | b"ggjt"
756            | b"tjgg"
757            | b"ggjf"
758            | b"fjgg"
759            | b"gguf"
760            | b"fugg"
761            | b"GGUF"
762    );
763
764    if !magic_ok {
765        let _ = fs::remove_file(path);
766        return Err(ProviderError::ModelDownload {
767            model: info.name.to_string(),
768            reason: format!(
769                "model header {:?} is not a recognized ggml/gguf magic — refusing to use file",
770                hdr
771            ),
772        }
773        .into());
774    }
775
776    // When a pin exists, also enforce it on cache hits.
777    if let Some(expected) = pinned_sha256(info.filename) {
778        if !verify_against_expected(path, expected) {
779            let _ = fs::remove_file(path);
780            return Err(ProviderError::ModelDownload {
781                model: info.name.to_string(),
782                reason: format!("cached model failed pinned sha256 check ({expected})"),
783            }
784            .into());
785        }
786    }
787
788    Ok(())
789}
790
791fn verify_against_expected(path: &Path, expected: &str) -> bool {
792    let Ok(mut file) = File::open(path) else {
793        return false;
794    };
795    let mut hasher = Sha256::new();
796    let mut buf = [0u8; 64 * 1024];
797    loop {
798        match file.read(&mut buf) {
799            Ok(0) => break,
800            Ok(n) => hasher.update(&buf[..n]),
801            Err(_) => return false,
802        }
803    }
804    hex::encode(hasher.finalize()) == expected
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810
811    #[test]
812    fn lookup_known_models() {
813        assert_eq!(lookup_model("base").unwrap().filename, "ggml-base.bin");
814        assert_eq!(
815            lookup_model("tiny-q5_1").unwrap().filename,
816            "ggml-tiny-q5_1.bin"
817        );
818        assert_eq!(
819            lookup_model("large-v3-turbo").unwrap().filename,
820            "ggml-large-v3-turbo.bin"
821        );
822        assert_eq!(lookup_model("turbo").unwrap().name, "turbo");
823        assert!(lookup_model("nope").is_err());
824    }
825
826    #[test]
827    fn model_path_joins() {
828        let p = model_path(Path::new("/tmp/cache"), lookup_model("tiny").unwrap());
829        assert_eq!(p, PathBuf::from("/tmp/cache/models/ggml-tiny.bin"));
830    }
831
832    #[test]
833    fn list_includes_quantized() {
834        let list = format_model_list(Path::new("/tmp/aurum-cache-test"));
835        assert!(list.contains("tiny-q5_1"));
836        assert!(list.contains("base-q5_1"));
837        assert!(list.contains("first run"));
838    }
839}