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.
13/// Content authenticity is enforced by reviewed SHA-256 pins (JOE-1590), not by
14/// mutable branch tip alone. Prefer pins over URL mutability.
15const HF_BASE: &str = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main";
16/// Manifest schema version for diagnostics (JOE-1590).
17pub const ARTIFACT_MANIFEST_VERSION: &str = "1";
18/// Provenance label for built-in pins.
19pub const ARTIFACT_MANIFEST_SOURCE: &str = "aurum-builtin-review";
20
21/// Known local model names and their ggml filenames.
22#[derive(Debug, Clone, Copy)]
23pub struct ModelInfo {
24    pub name: &'static str,
25    pub filename: &'static str,
26    /// Approximate download size in bytes (for progress UX).
27    pub approx_bytes: u64,
28    /// Human label for lists (e.g. "quantized", "english-only").
29    pub notes: &'static str,
30}
31
32/// Models supported in v0.0.0 (full + common quantized variants).
33pub const MODELS: &[ModelInfo] = &[
34    // ---- tiny ----
35    ModelInfo {
36        name: "tiny",
37        filename: "ggml-tiny.bin",
38        approx_bytes: 75_000_000,
39        notes: "fastest full-precision",
40    },
41    ModelInfo {
42        name: "tiny-q5_1",
43        filename: "ggml-tiny-q5_1.bin",
44        approx_bytes: 32_000_000,
45        notes: "quantized ~32MB — best first-run trial",
46    },
47    ModelInfo {
48        name: "tiny-q8_0",
49        filename: "ggml-tiny-q8_0.bin",
50        approx_bytes: 44_000_000,
51        notes: "quantized",
52    },
53    ModelInfo {
54        name: "tiny.en",
55        filename: "ggml-tiny.en.bin",
56        approx_bytes: 75_000_000,
57        notes: "english-only",
58    },
59    ModelInfo {
60        name: "tiny.en-q5_1",
61        filename: "ggml-tiny.en-q5_1.bin",
62        approx_bytes: 32_000_000,
63        notes: "english-only quantized",
64    },
65    // ---- base ----
66    ModelInfo {
67        name: "base",
68        filename: "ggml-base.bin",
69        approx_bytes: 142_000_000,
70        notes: "default full-precision",
71    },
72    ModelInfo {
73        name: "base-q5_1",
74        filename: "ggml-base-q5_1.bin",
75        approx_bytes: 60_000_000,
76        notes: "quantized ~60MB",
77    },
78    ModelInfo {
79        name: "base-q8_0",
80        filename: "ggml-base-q8_0.bin",
81        approx_bytes: 82_000_000,
82        notes: "quantized",
83    },
84    ModelInfo {
85        name: "base.en",
86        filename: "ggml-base.en.bin",
87        approx_bytes: 142_000_000,
88        notes: "english-only",
89    },
90    ModelInfo {
91        name: "base.en-q5_1",
92        filename: "ggml-base.en-q5_1.bin",
93        approx_bytes: 60_000_000,
94        notes: "english-only quantized",
95    },
96    // ---- small ----
97    ModelInfo {
98        name: "small",
99        filename: "ggml-small.bin",
100        approx_bytes: 466_000_000,
101        notes: "higher accuracy",
102    },
103    ModelInfo {
104        name: "small-q5_1",
105        filename: "ggml-small-q5_1.bin",
106        approx_bytes: 190_000_000,
107        notes: "quantized",
108    },
109    ModelInfo {
110        name: "small-q8_0",
111        filename: "ggml-small-q8_0.bin",
112        approx_bytes: 264_000_000,
113        notes: "quantized",
114    },
115    ModelInfo {
116        name: "small.en",
117        filename: "ggml-small.en.bin",
118        approx_bytes: 466_000_000,
119        notes: "english-only",
120    },
121    ModelInfo {
122        name: "small.en-q5_1",
123        filename: "ggml-small.en-q5_1.bin",
124        approx_bytes: 190_000_000,
125        notes: "english-only quantized",
126    },
127    // ---- medium ----
128    ModelInfo {
129        name: "medium",
130        filename: "ggml-medium.bin",
131        approx_bytes: 1_500_000_000,
132        notes: "large download",
133    },
134    ModelInfo {
135        name: "medium.en",
136        filename: "ggml-medium.en.bin",
137        approx_bytes: 1_500_000_000,
138        notes: "english-only",
139    },
140    // ---- large ----
141    ModelInfo {
142        name: "large-v3",
143        filename: "ggml-large-v3.bin",
144        approx_bytes: 3_100_000_000,
145        notes: "highest quality",
146    },
147    ModelInfo {
148        name: "large-v3-q5_0",
149        filename: "ggml-large-v3-q5_0.bin",
150        approx_bytes: 1_080_000_000,
151        notes: "experimental — not recommended (degeneration risk; prefer large-v3-turbo)",
152    },
153    ModelInfo {
154        name: "large",
155        filename: "ggml-large-v3.bin",
156        approx_bytes: 3_100_000_000,
157        notes: "alias of large-v3",
158    },
159    ModelInfo {
160        name: "large-v3-turbo",
161        filename: "ggml-large-v3-turbo.bin",
162        approx_bytes: 1_600_000_000,
163        notes: "fast large",
164    },
165    ModelInfo {
166        name: "large-v3-turbo-q5_0",
167        filename: "ggml-large-v3-turbo-q5_0.bin",
168        approx_bytes: 574_000_000,
169        notes: "quantized turbo",
170    },
171    ModelInfo {
172        name: "turbo",
173        filename: "ggml-large-v3-turbo.bin",
174        approx_bytes: 1_600_000_000,
175        notes: "alias of large-v3-turbo",
176    },
177    ModelInfo {
178        name: "turbo-q5_0",
179        filename: "ggml-large-v3-turbo-q5_0.bin",
180        approx_bytes: 574_000_000,
181        notes: "alias of large-v3-turbo-q5_0",
182    },
183];
184
185/// Names shown in user-facing help (canonical, not aliases).
186pub fn available_model_names() -> String {
187    list_canonical_models()
188        .iter()
189        .map(|m| m.name)
190        .collect::<Vec<_>>()
191        .join(", ")
192}
193
194fn list_canonical_models() -> Vec<&'static ModelInfo> {
195    MODELS
196        .iter()
197        .filter(|m| !matches!(m.name, "large" | "turbo" | "turbo-q5_0"))
198        .collect()
199}
200
201pub fn lookup_model(name: &str) -> Result<&'static ModelInfo> {
202    let key = name.trim().to_ascii_lowercase();
203    MODELS.iter().find(|m| m.name == key).ok_or_else(|| {
204        UserError::InvalidModel {
205            model: name.to_string(),
206            available: available_model_names(),
207        }
208        .into()
209    })
210}
211
212/// Directory where ggml models are stored: `<cache>/models/`.
213pub fn models_dir(cache_dir: &Path) -> PathBuf {
214    cache_dir.join("models")
215}
216
217/// Path to a cached model file (may not exist yet).
218pub fn model_path(cache_dir: &Path, info: &ModelInfo) -> PathBuf {
219    models_dir(cache_dir).join(info.filename)
220}
221
222/// Status of a model relative to the local cache.
223#[derive(Debug, Clone)]
224pub struct ModelStatus {
225    pub info: &'static ModelInfo,
226    pub cached: bool,
227    pub path: PathBuf,
228    pub size_bytes: Option<u64>,
229}
230
231/// List canonical models and whether each is cached.
232pub fn list_models(cache_dir: &Path) -> Vec<ModelStatus> {
233    list_canonical_models()
234        .into_iter()
235        .map(|info| {
236            let path = model_path(cache_dir, info);
237            let (cached, size_bytes) = match fs::metadata(&path) {
238                Ok(m) if m.len() > 1_000_000 => (true, Some(m.len())),
239                _ => (false, None),
240            };
241            ModelStatus {
242                info,
243                cached,
244                path,
245                size_bytes,
246            }
247        })
248        .collect()
249}
250
251/// Format a human-readable model table for CLI output.
252pub fn format_model_list(cache_dir: &Path) -> String {
253    let rows = list_models(cache_dir);
254    let mut out = String::from("Local whisper.cpp models (cache: ");
255    out.push_str(&models_dir(cache_dir).display().to_string());
256    out.push_str(")\n\n");
257    out.push_str(&format!(
258        "{:<22} {:>10}  {:<8}  {:<12}  {}\n",
259        "NAME", "SIZE", "STATUS", "TIER", "NOTES"
260    ));
261    out.push_str(&format!(
262        "{:<22} {:>10}  {:<8}  {:<12}  {}\n",
263        "----", "----", "------", "----", "-----"
264    ));
265    for row in rows {
266        let size = format_bytes(row.info.approx_bytes);
267        let status = if row.cached { "cached" } else { "—" };
268        let tier = match model_support_tier(row.info.name) {
269            ModelSupportTier::Supported => "supported",
270            ModelSupportTier::Experimental => "experimental",
271        };
272        out.push_str(&format!(
273            "{:<22} {:>10}  {:<8}  {:<12}  {}\n",
274            row.info.name, size, status, tier, row.info.notes
275        ));
276    }
277    out.push_str(
278        "\nTip: first run downloads the selected model. Try `tiny-q5_1` (~32 MB) for a quick trial.\n",
279    );
280    out.push_str("Default model: `base` (~142 MB). Use --model <name> to choose.\n");
281    out.push_str(
282        "Guidance (single-clip dogfood, not formal WER): English lecture quality often \
283         favors `small.en` or `large-v3-turbo`; prefer `.en` variants for English-only audio. \
284         Avoid `large-v3-q5_0` for production (experimental).\n",
285    );
286    out
287}
288
289fn format_bytes(n: u64) -> String {
290    const KB: f64 = 1024.0;
291    const MB: f64 = KB * 1024.0;
292    const GB: f64 = MB * 1024.0;
293    let n = n as f64;
294    if n >= GB {
295        format!("{:.1} GB", n / GB)
296    } else if n >= MB {
297        format!("{:.0} MB", n / MB)
298    } else {
299        format!("{:.0} KB", n / KB)
300    }
301}
302
303/// Progress event while downloading a model (library hosts / UI).
304#[derive(Debug, Clone)]
305pub struct DownloadProgress {
306    pub model: String,
307    pub downloaded_bytes: u64,
308    pub total_bytes: u64,
309}
310
311impl DownloadProgress {
312    pub fn fraction(&self) -> Option<f64> {
313        if self.total_bytes == 0 {
314            None
315        } else {
316            Some((self.downloaded_bytes as f64 / self.total_bytes as f64).clamp(0.0, 1.0))
317        }
318    }
319}
320
321/// Callback for download progress. Invoked from the async download task.
322pub type DownloadProgressCallback = Arc<dyn Fn(DownloadProgress) + Send + Sync>;
323
324/// Options for [`ensure_model`].
325#[derive(Clone, Default)]
326pub struct EnsureModelOptions {
327    /// When true, never hit the network; fail if the model is not already cached.
328    pub local_only: bool,
329    /// Show CLI-style progress on stderr (indicatif).
330    pub show_progress: bool,
331    /// Optional structured progress hook for embedders.
332    pub on_progress: Option<DownloadProgressCallback>,
333}
334
335impl EnsureModelOptions {
336    pub fn new() -> Self {
337        Self::default()
338    }
339
340    pub fn local_only(mut self, v: bool) -> Self {
341        self.local_only = v;
342        self
343    }
344
345    pub fn show_progress(mut self, v: bool) -> Self {
346        self.show_progress = v;
347        self
348    }
349
350    pub fn on_progress(mut self, cb: DownloadProgressCallback) -> Self {
351        self.on_progress = Some(cb);
352        self
353    }
354}
355
356/// True if a usable model file is already on disk.
357pub fn is_model_cached(cache_dir: &Path, model_name: &str) -> bool {
358    let Ok(info) = lookup_model(model_name) else {
359        return false;
360    };
361    let path = model_path(cache_dir, info);
362    if !(path.exists()
363        && path
364            .metadata()
365            .map(|m| m.len() > 1_000_000)
366            .unwrap_or(false))
367    {
368        return false;
369    }
370    // Trusted catalogue requires a reviewed pin (JOE-1645).
371    pinned_sha256(info.filename).is_some() && verify_model_basic(&path, info).is_ok()
372}
373
374/// Ensure a model is present locally, downloading if needed. Returns the path.
375pub async fn ensure_model(
376    cache_dir: &Path,
377    model_name: &str,
378    show_progress: bool,
379) -> Result<PathBuf> {
380    ensure_model_with_options(
381        cache_dir,
382        model_name,
383        EnsureModelOptions {
384            show_progress,
385            ..EnsureModelOptions::default()
386        },
387    )
388    .await
389}
390
391/// Ensure model with offline / progress options.
392pub async fn ensure_model_with_options(
393    cache_dir: &Path,
394    model_name: &str,
395    opts: EnsureModelOptions,
396) -> Result<PathBuf> {
397    let info = lookup_model(model_name)?;
398    let path = model_path(cache_dir, info);
399
400    if path.exists()
401        && path
402            .metadata()
403            .map(|m| m.len() > 1_000_000)
404            .unwrap_or(false)
405    {
406        // Trusted catalogue requires reviewed pin + integrity (JOE-1645).
407        if pinned_sha256(info.filename).is_none() {
408            return Err(ProviderError::ModelDownload {
409                model: model_name.to_string(),
410                reason: format!(
411                    "model `{}` has no reviewed SHA-256 pin — not available on the trusted path",
412                    info.name
413                ),
414            }
415            .into());
416        }
417        if verify_model_basic(&path, info).is_ok() {
418            tracing::info!(model = info.name, path = %path.display(), "using cached model");
419            return Ok(path);
420        }
421        tracing::warn!(
422            model = info.name,
423            path = %path.display(),
424            "cached model failed integrity check; re-downloading"
425        );
426        let _ = fs::remove_file(&path);
427    }
428
429    if opts.local_only {
430        return Err(UserError::ModelNotCached {
431            model: model_name.to_string(),
432        }
433        .into());
434    }
435
436    fs::create_dir_all(models_dir(cache_dir)).map_err(|e| EnvironmentError::DirectoryAccess {
437        path: models_dir(cache_dir).display().to_string(),
438        reason: e.to_string(),
439    })?;
440
441    // Cross-process advisory lock so concurrent aurum runs don't double-download.
442    let lock_path = path.with_extension("bin.lock");
443    let lock_file = OpenOptions::new()
444        .create(true)
445        .read(true)
446        .write(true)
447        .truncate(false)
448        .open(&lock_path)
449        .map_err(|e| EnvironmentError::DirectoryAccess {
450            path: lock_path.display().to_string(),
451            reason: e.to_string(),
452        })?;
453
454    if opts.show_progress {
455        eprintln!("aurum: waiting for model download lock ({}) …", info.name);
456    }
457    lock_file.lock().map_err(|e| EnvironmentError::Other {
458        message: format!("failed to acquire model lock: {e}"),
459    })?;
460
461    // Re-check after lock — another process may have finished the download.
462    if path.exists()
463        && path
464            .metadata()
465            .map(|m| m.len() > 1_000_000)
466            .unwrap_or(false)
467        && pinned_sha256(info.filename).is_some()
468        && verify_model_basic(&path, info).is_ok()
469    {
470        let _ = lock_file.unlock();
471        tracing::info!(model = info.name, "model appeared while waiting on lock");
472        return Ok(path);
473    }
474
475    if opts.local_only {
476        let _ = lock_file.unlock();
477        return Err(UserError::ModelNotCached {
478            model: model_name.to_string(),
479        }
480        .into());
481    }
482
483    if opts.show_progress {
484        eprintln!(
485            "aurum: downloading model `{}` ({}) — first run only …",
486            info.name,
487            format_bytes(info.approx_bytes)
488        );
489    }
490
491    let result = download_model(info, &path, opts.show_progress, opts.on_progress.as_ref()).await;
492    let _ = lock_file.unlock();
493    result?;
494    verify_model_basic(&path, info)?;
495    Ok(path)
496}
497
498/// Sidecar checksum is operator metadata only — it never authenticates an
499/// unpinned download (JOE-1645). Returns true only when a sidecar exists and matches.
500#[allow(dead_code)]
501fn verify_cached_checksum(path: &Path) -> bool {
502    let checksum_path = path.with_extension("bin.sha256");
503    let Ok(contents) = fs::read_to_string(&checksum_path) else {
504        return false;
505    };
506    let Some(expected) = contents.split_whitespace().next() else {
507        return false;
508    };
509    verify_against_expected(path, expected)
510}
511
512async fn download_model(
513    info: &ModelInfo,
514    dest: &Path,
515    show_progress: bool,
516    on_progress: Option<&DownloadProgressCallback>,
517) -> Result<()> {
518    let url = format!("{HF_BASE}/{}?download=true", info.filename);
519    tracing::info!(model = info.name, %url, "downloading model");
520
521    // JOE-1918: exclusive create_new partial (unpredictable name, no clobber).
522    let partial = exclusive_stt_partial(dest)?;
523
524    // Bound total download time so a stalled transfer can't hold the lock forever.
525    // Follow redirects only onto known HuggingFace CDN hosts (HF always 302s resolve/
526    // URLs). Credentialed OpenRouter traffic stays on HardenedHttpClient (no redirects).
527    let client = reqwest::Client::builder()
528        .user_agent(concat!("aurum-core/", env!("CARGO_PKG_VERSION")))
529        .connect_timeout(std::time::Duration::from_secs(30))
530        .timeout(std::time::Duration::from_secs(30 * 60))
531        .redirect(reqwest::redirect::Policy::custom(hf_redirect_policy))
532        .build()
533        .map_err(|e| ProviderError::ModelDownload {
534            model: info.name.to_string(),
535            reason: format!("http client error: {e}"),
536        })?;
537
538    let response = client
539        .get(&url)
540        .send()
541        .await
542        .map_err(|e| ProviderError::ModelDownload {
543            model: info.name.to_string(),
544            reason: format!("request failed: {e}"),
545        })?;
546
547    if !response.status().is_success() {
548        return Err(ProviderError::ModelDownload {
549            model: info.name.to_string(),
550            reason: format!("HTTP {}", response.status()),
551        }
552        .into());
553    }
554
555    let total = response.content_length().unwrap_or(info.approx_bytes);
556
557    let pb = if show_progress {
558        let pb = ProgressBar::new(total);
559        pb.set_style(
560            ProgressStyle::with_template(
561                "{msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})",
562            )
563            .unwrap_or_else(|_| ProgressStyle::default_bar())
564            .progress_chars("=>-"),
565        );
566        pb.set_message(format!("Downloading {}", info.name));
567        Some(pb)
568    } else {
569        None
570    };
571
572    let mut file = OpenOptions::new().write(true).open(&partial).map_err(|e| {
573        EnvironmentError::DirectoryAccess {
574            path: partial.display().to_string(),
575            reason: e.to_string(),
576        }
577    })?;
578
579    let mut stream = response.bytes_stream();
580    let mut hasher = Sha256::new();
581    let mut downloaded: u64 = 0;
582
583    while let Some(chunk) = stream.next().await {
584        let chunk = chunk.map_err(|e| ProviderError::ModelDownload {
585            model: info.name.to_string(),
586            reason: format!("stream error: {e}"),
587        })?;
588        file.write_all(&chunk)
589            .map_err(|e| EnvironmentError::DiskSpace {
590                path: partial.display().to_string(),
591                reason: e.to_string(),
592            })?;
593        hasher.update(&chunk);
594        downloaded += chunk.len() as u64;
595        // Hard cap from reviewed metadata only (JOE-1591). Content-Length cannot raise it.
596        let max_allowed = crate::download::download_byte_cap(
597            info.approx_bytes,
598            pinned_exact_bytes(info.filename),
599            3,
600        );
601        if downloaded > max_allowed {
602            let _ = fs::remove_file(&partial);
603            return Err(ProviderError::ModelDownload {
604                model: info.name.to_string(),
605                reason: format!(
606                    "download exceeded size cap ({downloaded} > {max_allowed} bytes) — aborting"
607                ),
608            }
609            .into());
610        }
611        if let Some(pb) = &pb {
612            pb.set_position(downloaded.min(total));
613        }
614        if let Some(cb) = on_progress {
615            cb(DownloadProgress {
616                model: info.name.to_string(),
617                downloaded_bytes: downloaded,
618                total_bytes: total,
619            });
620        }
621    }
622
623    file.flush().map_err(|e| EnvironmentError::DiskSpace {
624        path: partial.display().to_string(),
625        reason: e.to_string(),
626    })?;
627    file.sync_all().map_err(|e| EnvironmentError::DiskSpace {
628        path: partial.display().to_string(),
629        reason: format!("sync partial download: {e}"),
630    })?;
631    drop(file);
632
633    let digest = hex::encode(hasher.finalize());
634    tracing::info!(
635        model = info.name,
636        sha256 = %digest,
637        bytes = downloaded,
638        "model download complete (pre-publish)"
639    );
640
641    // Verify-before-publish (JOE-1591 / JOE-1645): every trusted model requires a pin.
642    let Some(expected) = pinned_sha256(info.filename) else {
643        let _ = fs::remove_file(&partial);
644        return Err(ProviderError::ModelDownload {
645            model: info.name.to_string(),
646            reason: format!(
647                "no reviewed SHA-256 pin for {} — refusing to publish unauthenticated artifact",
648                info.filename
649            ),
650        }
651        .into());
652    };
653    if digest != expected {
654        let _ = fs::remove_file(&partial);
655        return Err(ProviderError::ModelDownload {
656            model: info.name.to_string(),
657            reason: format!(
658                "sha256 mismatch (got {digest}, expected {expected}) — refusing to publish"
659            ),
660        }
661        .into());
662    }
663    if let Some(exact) = pinned_exact_bytes(info.filename) {
664        if downloaded != exact {
665            let _ = fs::remove_file(&partial);
666            return Err(ProviderError::ModelDownload {
667                model: info.name.to_string(),
668                reason: format!(
669                    "size mismatch (got {downloaded}, expected {exact}) — refusing to publish"
670                ),
671            }
672            .into());
673        }
674    }
675
676    // Durable publish after verification (no pre-delete availability gap when possible).
677    if let Err(e) = publish_stt_partial(&partial, dest) {
678        let _ = fs::remove_file(&partial);
679        return Err(e);
680    }
681
682    if let Some(pb) = pb {
683        pb.finish_with_message(format!("Downloaded {} ({downloaded} bytes)", info.name));
684    }
685
686    let checksum_path = dest.with_extension("bin.sha256");
687    // Best-effort sidecar; authenticity is the pin check above.
688    let _ = fs::write(&checksum_path, format!("{digest}  {}\n", info.filename));
689
690    // Best-effort cleanup of orphaned partials from prior crashed runs.
691    sweep_stale_partials(dest.parent().unwrap_or_else(|| Path::new(".")));
692
693    Ok(())
694}
695
696fn exclusive_stt_partial(dest: &Path) -> Result<PathBuf> {
697    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
698    fs::create_dir_all(parent).map_err(|e| EnvironmentError::DirectoryAccess {
699        path: parent.display().to_string(),
700        reason: e.to_string(),
701    })?;
702    for _ in 0..32 {
703        let name = format!(
704            ".{}.{}-{}.aurum.partial",
705            dest.file_name()
706                .and_then(|s| s.to_str())
707                .unwrap_or("model.bin"),
708            std::process::id(),
709            std::time::SystemTime::now()
710                .duration_since(std::time::UNIX_EPOCH)
711                .map(|d| d.as_nanos())
712                .unwrap_or(0)
713        );
714        let path = parent.join(name);
715        match OpenOptions::new().write(true).create_new(true).open(&path) {
716            Ok(f) => {
717                drop(f);
718                #[cfg(unix)]
719                {
720                    use std::os::unix::fs::PermissionsExt;
721                    let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
722                }
723                return Ok(path);
724            }
725            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
726            Err(e) => {
727                return Err(EnvironmentError::DirectoryAccess {
728                    path: parent.display().to_string(),
729                    reason: format!("exclusive partial create failed: {e}"),
730                }
731                .into());
732            }
733        }
734    }
735    Err(EnvironmentError::DirectoryAccess {
736        path: parent.display().to_string(),
737        reason: "could not allocate exclusive STT partial path".into(),
738    }
739    .into())
740}
741
742fn publish_stt_partial(tmp: &Path, dest: &Path) -> Result<()> {
743    match fs::rename(tmp, dest) {
744        Ok(()) => Ok(()),
745        Err(e) if dest.exists() => {
746            let backup = dest.with_extension(format!(
747                "aurum.bak.{}-{}",
748                std::process::id(),
749                std::time::SystemTime::now()
750                    .duration_since(std::time::UNIX_EPOCH)
751                    .map(|d| d.as_nanos())
752                    .unwrap_or(0)
753            ));
754            fs::rename(dest, &backup).map_err(|re| EnvironmentError::DirectoryAccess {
755                path: dest.display().to_string(),
756                reason: format!("stage previous model: {re} (after rename: {e})"),
757            })?;
758            match fs::rename(tmp, dest) {
759                Ok(()) => {
760                    let _ = fs::remove_file(&backup);
761                    Ok(())
762                }
763                Err(re) => {
764                    let _ = fs::rename(&backup, dest);
765                    Err(EnvironmentError::DirectoryAccess {
766                        path: dest.display().to_string(),
767                        reason: format!("publish verified model: {re}"),
768                    }
769                    .into())
770                }
771            }
772        }
773        Err(e) => Err(EnvironmentError::DirectoryAccess {
774            path: dest.display().to_string(),
775            reason: e.to_string(),
776        }
777        .into()),
778    }
779}
780
781/// Allow HuggingFace CDN redirects; stop on anything else.
782fn hf_redirect_policy(attempt: reqwest::redirect::Attempt<'_>) -> reqwest::redirect::Action {
783    let url = attempt.url();
784    let host = url.host_str().unwrap_or("").to_ascii_lowercase();
785    let allowed = host == "huggingface.co"
786        || host.ends_with(".huggingface.co")
787        || host.ends_with(".hf.co")
788        || host == "hf.co"
789        || host.ends_with(".cdn.hf.co");
790    if allowed && attempt.previous().len() < 8 {
791        attempt.follow()
792    } else {
793        attempt.stop()
794    }
795}
796
797/// Independently reviewed SHA-256 digests (JOE-1590 / JOE-1645).
798///
799/// Every trusted catalogue filename must have a pin. Identity is the digest +
800/// exact size, not a mutable upstream branch tip. A missing pin is a release
801/// defect: download refuses to publish and cache verify never labels the file healthy.
802pub fn pinned_sha256(filename: &str) -> Option<&'static str> {
803    match filename {
804        "ggml-tiny.bin" => Some("be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21"),
805        "ggml-tiny-q5_1.bin" => {
806            Some("818710568da3ca15689e31a743197b520007872ff9576237bda97bd1b469c3d7")
807        }
808        "ggml-tiny-q8_0.bin" => {
809            Some("c2085835d3f50733e2ff6e4b41ae8a2b8d8110461e18821b09a15c40c42d1cca")
810        }
811        "ggml-tiny.en.bin" => {
812            Some("921e4cf8686fdd993dcd081a5da5b6c365bfde1162e72b08d75ac75289920b1f")
813        }
814        "ggml-tiny.en-q5_1.bin" => {
815            Some("c77c5766f1cef09b6b7d47f21b546cbddd4157886b3b5d6d4f709e91e66c7c2b")
816        }
817        "ggml-base.bin" => Some("60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe"),
818        "ggml-base-q5_1.bin" => {
819            Some("422f1ae452ade6f30a004d7e5c6a43195e4433bc370bf23fac9cc591f01a8898")
820        }
821        "ggml-base-q8_0.bin" => {
822            Some("c577b9a86e7e048a0b7eada054f4dd79a56bbfa911fbdacf900ac5b567cbb7d9")
823        }
824        "ggml-base.en.bin" => {
825            Some("a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002")
826        }
827        "ggml-base.en-q5_1.bin" => {
828            Some("4baf70dd0d7c4247ba2b81fafd9c01005ac77c2f9ef064e00dcf195d0e2fdd2f")
829        }
830        "ggml-small.bin" => {
831            Some("1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b")
832        }
833        "ggml-small-q5_1.bin" => {
834            Some("ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb")
835        }
836        "ggml-small-q8_0.bin" => {
837            Some("49c8fb02b65e6049d5fa6c04f81f53b867b5ec9540406812c643f177317f779f")
838        }
839        "ggml-small.en.bin" => {
840            Some("c6138d6d58ecc8322097e0f987c32f1be8bb0a18532a3f88f734d1bbf9c41e5d")
841        }
842        "ggml-small.en-q5_1.bin" => {
843            Some("bfdff4894dcb76bbf647d56263ea2a96645423f1669176f4844a1bf8e478ad30")
844        }
845        "ggml-medium.bin" => {
846            Some("6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208")
847        }
848        "ggml-medium.en.bin" => {
849            Some("cc37e93478338ec7700281a7ac30a10128929eb8f427dda2e865faa8f6da4356")
850        }
851        "ggml-large-v3.bin" => {
852            Some("64d182b440b98d5203c4f9bd541544d84c605196c4f7b845dfa11fb23594d1e2")
853        }
854        "ggml-large-v3-q5_0.bin" => {
855            Some("d75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1")
856        }
857        "ggml-large-v3-turbo.bin" => {
858            Some("1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69")
859        }
860        "ggml-large-v3-turbo-q5_0.bin" => {
861            Some("394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2")
862        }
863        _ => None,
864    }
865}
866
867/// Support tier for catalogue display (JOE-1650).
868#[derive(Debug, Clone, Copy, PartialEq, Eq)]
869pub enum ModelSupportTier {
870    /// Default recommended catalogue entries.
871    Supported,
872    /// Available but not recommended for production quality (known risk).
873    Experimental,
874}
875
876/// Support tier for a catalogue name (aliases resolve via [`lookup_model`]).
877pub fn model_support_tier(name: &str) -> ModelSupportTier {
878    match name {
879        "large-v3-q5_0" => ModelSupportTier::Experimental,
880        _ => ModelSupportTier::Supported,
881    }
882}
883
884/// Reviewed exact sizes when known (from HF package metadata / maintainer review).
885pub fn pinned_exact_bytes(filename: &str) -> Option<u64> {
886    match filename {
887        "ggml-tiny.bin" => Some(77_691_713),
888        "ggml-tiny-q5_1.bin" => Some(32_152_673),
889        "ggml-tiny-q8_0.bin" => Some(43_537_433),
890        "ggml-tiny.en.bin" => Some(77_704_715),
891        "ggml-tiny.en-q5_1.bin" => Some(32_166_155),
892        "ggml-base.bin" => Some(147_951_465),
893        "ggml-base-q5_1.bin" => Some(59_707_625),
894        "ggml-base-q8_0.bin" => Some(81_768_585),
895        "ggml-base.en.bin" => Some(147_964_211),
896        "ggml-base.en-q5_1.bin" => Some(59_721_011),
897        "ggml-small.bin" => Some(487_601_967),
898        "ggml-small-q5_1.bin" => Some(190_085_487),
899        "ggml-small-q8_0.bin" => Some(264_464_607),
900        "ggml-small.en.bin" => Some(487_614_201),
901        "ggml-small.en-q5_1.bin" => Some(190_098_681),
902        "ggml-medium.bin" => Some(1_533_763_059),
903        "ggml-medium.en.bin" => Some(1_533_774_781),
904        "ggml-large-v3.bin" => Some(3_095_033_483),
905        "ggml-large-v3-q5_0.bin" => Some(1_081_140_203),
906        "ggml-large-v3-turbo.bin" => Some(1_624_555_275),
907        "ggml-large-v3-turbo-q5_0.bin" => Some(574_041_195),
908        _ => None,
909    }
910}
911
912/// Diagnostic JSON for a catalogue entry (manifest provenance).
913pub fn artifact_manifest_json(info: &ModelInfo) -> serde_json::Value {
914    serde_json::json!({
915        "manifest_version": ARTIFACT_MANIFEST_VERSION,
916        "source": ARTIFACT_MANIFEST_SOURCE,
917        "id": info.name,
918        "filename": info.filename,
919        "approx_bytes": info.approx_bytes,
920        "exact_bytes": pinned_exact_bytes(info.filename),
921        "sha256": pinned_sha256(info.filename),
922        "license": "MIT (whisper.cpp weights via OpenAI Whisper terms)",
923        "family": "whisper",
924        "download_url_template": format!("{HF_BASE}/{}", info.filename),
925    })
926}
927
928fn sweep_stale_partials(dir: &Path) {
929    let Ok(entries) = fs::read_dir(dir) else {
930        return;
931    };
932    let stale_after = std::time::Duration::from_secs(6 * 3600);
933    let now = std::time::SystemTime::now();
934    for ent in entries.flatten() {
935        let name = ent.file_name();
936        let name = name.to_string_lossy();
937        // Only stale leftovers — never touch another live download's unique partial.
938        // Matches both legacy `.bin.partial.PID-ms` and exclusive `.name.PID-ns.aurum.partial`.
939        if !name.contains(".bin.partial.") && !name.ends_with(".aurum.partial") {
940            continue;
941        }
942        let Ok(meta) = ent.metadata() else {
943            continue;
944        };
945        let Ok(modified) = meta.modified() else {
946            continue;
947        };
948        if now.duration_since(modified).unwrap_or_default() > stale_after {
949            let _ = fs::remove_file(ent.path());
950        }
951    }
952}
953
954/// Public local-only verify used by cache inventory (no network).
955pub fn ensure_model_verified_local(path: &Path, info: &ModelInfo) -> Result<()> {
956    verify_model_basic(path, info)
957}
958
959/// Basic integrity check: file exists, is large enough, and starts with ggml magic-ish bytes.
960fn verify_model_basic(path: &Path, info: &ModelInfo) -> Result<()> {
961    let meta = fs::metadata(path).map_err(|e| ProviderError::ModelDownload {
962        model: info.name.to_string(),
963        reason: format!("missing after download: {e}"),
964    })?;
965
966    if meta.len() < 1_000_000 {
967        // Do not delete — cache verify quarantines; leave bytes for forensics.
968        return Err(ProviderError::ModelDownload {
969            model: info.name.to_string(),
970            reason: format!(
971                "model file is only {} bytes — likely truncated or HTML error page",
972                meta.len()
973            ),
974        }
975        .into());
976    }
977
978    let mut hdr = [0u8; 4];
979    let mut f = File::open(path).map_err(|e| ProviderError::ModelDownload {
980        model: info.name.to_string(),
981        reason: e.to_string(),
982    })?;
983    f.read_exact(&mut hdr)
984        .map_err(|e| ProviderError::ModelDownload {
985            model: info.name.to_string(),
986            reason: format!("cannot read header: {e}"),
987        })?;
988
989    let magic_ok = matches!(
990        &hdr,
991        b"ggml"
992            | b"lmgg"
993            | b"ggmf"
994            | b"fmgg"
995            | b"ggjt"
996            | b"tjgg"
997            | b"ggjf"
998            | b"fjgg"
999            | b"gguf"
1000            | b"fugg"
1001            | b"GGUF"
1002    );
1003
1004    if !magic_ok {
1005        // Do not delete — cache verify quarantines; leave bytes for forensics.
1006        return Err(ProviderError::ModelDownload {
1007            model: info.name.to_string(),
1008            reason: format!(
1009                "model header {:?} is not a recognized ggml/gguf magic — refusing to use file",
1010                hdr
1011            ),
1012        }
1013        .into());
1014    }
1015
1016    // JOE-1645: trusted path requires a reviewed pin; never bless unpinned files.
1017    let Some(expected) = pinned_sha256(info.filename) else {
1018        return Err(ProviderError::ModelDownload {
1019            model: info.name.to_string(),
1020            reason: format!(
1021                "no reviewed SHA-256 pin for {} — not trusted",
1022                info.filename
1023            ),
1024        }
1025        .into());
1026    };
1027    if !verify_against_expected(path, expected) {
1028        return Err(ProviderError::ModelDownload {
1029            model: info.name.to_string(),
1030            reason: format!(
1031                "cached model failed pinned sha256 check ({expected}); \
1032                 run `aurum cache verify` / quarantine repair"
1033            ),
1034        }
1035        .into());
1036    }
1037    if let Some(exact) = pinned_exact_bytes(info.filename) {
1038        if meta.len() != exact {
1039            return Err(ProviderError::ModelDownload {
1040                model: info.name.to_string(),
1041                reason: format!(
1042                    "cached model size mismatch (got {}, expected {exact})",
1043                    meta.len()
1044                ),
1045            }
1046            .into());
1047        }
1048    }
1049
1050    Ok(())
1051}
1052
1053fn verify_against_expected(path: &Path, expected: &str) -> bool {
1054    let Ok(mut file) = File::open(path) else {
1055        return false;
1056    };
1057    let mut hasher = Sha256::new();
1058    let mut buf = [0u8; 64 * 1024];
1059    loop {
1060        match file.read(&mut buf) {
1061            Ok(0) => break,
1062            Ok(n) => hasher.update(&buf[..n]),
1063            Err(_) => return false,
1064        }
1065    }
1066    hex::encode(hasher.finalize()) == expected
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071    use super::*;
1072
1073    #[test]
1074    fn lookup_known_models() {
1075        assert_eq!(lookup_model("base").unwrap().filename, "ggml-base.bin");
1076        assert_eq!(
1077            lookup_model("tiny-q5_1").unwrap().filename,
1078            "ggml-tiny-q5_1.bin"
1079        );
1080        assert_eq!(
1081            lookup_model("large-v3-turbo").unwrap().filename,
1082            "ggml-large-v3-turbo.bin"
1083        );
1084        assert_eq!(lookup_model("turbo").unwrap().name, "turbo");
1085        assert!(lookup_model("nope").is_err());
1086    }
1087
1088    #[test]
1089    fn model_path_joins() {
1090        let p = model_path(Path::new("/tmp/cache"), lookup_model("tiny").unwrap());
1091        assert_eq!(p, PathBuf::from("/tmp/cache/models/ggml-tiny.bin"));
1092    }
1093
1094    #[test]
1095    fn list_includes_quantized() {
1096        let list = format_model_list(Path::new("/tmp/aurum-cache-test"));
1097        assert!(list.contains("tiny-q5_1"));
1098        assert!(list.contains("base-q5_1"));
1099        assert!(list.contains("first run"));
1100    }
1101
1102    #[test]
1103    fn every_catalogue_file_has_exact_size_metadata() {
1104        // JOE-1590: exact size is required reviewed metadata for every unique file.
1105        let mut missing = Vec::new();
1106        for m in MODELS {
1107            if pinned_exact_bytes(m.filename).is_none() {
1108                missing.push(m.filename);
1109            }
1110        }
1111        assert!(
1112            missing.is_empty(),
1113            "missing exact_bytes pins for: {missing:?}"
1114        );
1115    }
1116
1117    #[test]
1118    fn reviewed_sha256_pins_cover_every_catalogue_filename() {
1119        // JOE-1645: every trusted entry must have an immutable digest.
1120        let mut missing = Vec::new();
1121        let mut seen = std::collections::HashSet::new();
1122        for m in MODELS {
1123            if !seen.insert(m.filename) {
1124                continue; // aliases share identity
1125            }
1126            if pinned_sha256(m.filename).is_none() {
1127                missing.push(m.filename);
1128            } else {
1129                let pin = pinned_sha256(m.filename).unwrap();
1130                assert_eq!(pin.len(), 64, "pin length for {}", m.filename);
1131            }
1132        }
1133        assert!(missing.is_empty(), "missing sha256 pins for: {missing:?}");
1134    }
1135
1136    #[test]
1137    fn aliases_share_canonical_artifact_pins() {
1138        let large = lookup_model("large").unwrap();
1139        let large_v3 = lookup_model("large-v3").unwrap();
1140        assert_eq!(large.filename, large_v3.filename);
1141        assert_eq!(
1142            pinned_sha256(large.filename),
1143            pinned_sha256(large_v3.filename)
1144        );
1145    }
1146
1147    #[test]
1148    fn large_v3_q5_0_is_experimental_tier() {
1149        assert_eq!(
1150            model_support_tier("large-v3-q5_0"),
1151            ModelSupportTier::Experimental
1152        );
1153        assert_eq!(model_support_tier("base"), ModelSupportTier::Supported);
1154    }
1155}