1use 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
12const HF_BASE: &str = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main";
16pub const ARTIFACT_MANIFEST_VERSION: &str = "1";
18pub const ARTIFACT_MANIFEST_SOURCE: &str = "aurum-builtin-review";
20
21#[derive(Debug, Clone, Copy)]
23pub struct ModelInfo {
24 pub name: &'static str,
25 pub filename: &'static str,
26 pub approx_bytes: u64,
28 pub notes: &'static str,
30}
31
32pub const MODELS: &[ModelInfo] = &[
34 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 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 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 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 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
185pub 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
212pub fn models_dir(cache_dir: &Path) -> PathBuf {
214 cache_dir.join("models")
215}
216
217pub fn model_path(cache_dir: &Path, info: &ModelInfo) -> PathBuf {
219 models_dir(cache_dir).join(info.filename)
220}
221
222#[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
231pub 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
251pub 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#[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
321pub type DownloadProgressCallback = Arc<dyn Fn(DownloadProgress) + Send + Sync>;
323
324#[derive(Clone, Default)]
326pub struct EnsureModelOptions {
327 pub local_only: bool,
329 pub show_progress: bool,
331 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
356pub 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 pinned_sha256(info.filename).is_some() && verify_model_basic(&path, info).is_ok()
372}
373
374pub 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
391pub 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 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 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 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#[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 let partial = exclusive_stt_partial(dest)?;
523
524 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 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 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 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 let _ = fs::write(&checksum_path, format!("{digest} {}\n", info.filename));
689
690 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
781fn 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
797pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
869pub enum ModelSupportTier {
870 Supported,
872 Experimental,
874}
875
876pub fn model_support_tier(name: &str) -> ModelSupportTier {
878 match name {
879 "large-v3-q5_0" => ModelSupportTier::Experimental,
880 _ => ModelSupportTier::Supported,
881 }
882}
883
884pub 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
912pub 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 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
954pub fn ensure_model_verified_local(path: &Path, info: &ModelInfo) -> Result<()> {
956 verify_model_basic(path, info)
957}
958
959fn 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 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 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 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 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 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; }
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}