1#[cfg(feature = "local-onnx")]
2use std::cell::RefCell;
3#[cfg(feature = "local-onnx")]
4use std::collections::{hash_map::Entry, HashMap};
5use std::path::{Component, Path, PathBuf};
6
7use anyhow::{bail, Context, Result};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use super::{EmbeddingConfig, TextEmbedding};
12
13pub(super) const DEFAULT_LOCAL_SEMANTIC_DIMENSIONS: usize = 384;
14pub(super) const DEFAULT_LOCAL_SEMANTIC_MODEL: &str = "fastembed-intfloat-multilingual-e5-small-v1";
15
16const MANIFEST_FILE: &str = "remem-model-manifest.json";
17const MANIFEST_SCHEMA_VERSION: u32 = 1;
18const FASTEMBED_RUNTIME: &str = "fastembed-rs/onnxruntime";
19const HUGGING_FACE_BASE_URL: &str = "https://huggingface.co";
20
21#[derive(Debug)]
22struct LocalEmbeddingModelUnavailableError(String);
23
24impl std::fmt::Display for LocalEmbeddingModelUnavailableError {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 f.write_str(&self.0)
27 }
28}
29
30impl std::error::Error for LocalEmbeddingModelUnavailableError {}
31
32pub(super) fn is_model_unavailable_error(error: &anyhow::Error) -> bool {
33 error
34 .downcast_ref::<LocalEmbeddingModelUnavailableError>()
35 .is_some()
36}
37
38pub(super) fn model_unavailable_error(reason: impl Into<String>) -> anyhow::Error {
39 LocalEmbeddingModelUnavailableError(reason.into()).into()
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub(super) enum LocalEmbeddingInputKind {
44 Query,
45 Passage,
46 Generic,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub(super) enum LocalEmbeddingPreset {
51 MultilingualE5Small,
52 BgeM3,
53}
54
55#[cfg(feature = "local-onnx")]
56#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57struct LocalModelCacheKey {
58 preset: LocalEmbeddingPreset,
59 install_dir: PathBuf,
60}
61
62#[cfg(feature = "local-onnx")]
63thread_local! {
64 static LOCAL_MODEL_CACHE: RefCell<HashMap<LocalModelCacheKey, fastembed::TextEmbedding>> =
65 RefCell::new(HashMap::new());
66}
67
68impl LocalEmbeddingPreset {
69 fn all() -> &'static [Self] {
70 &[Self::MultilingualE5Small, Self::BgeM3]
71 }
72
73 fn default() -> Self {
74 Self::MultilingualE5Small
75 }
76
77 fn parse(raw: &str) -> Result<Self> {
78 match raw.trim().to_ascii_lowercase().as_str() {
79 "" => Ok(Self::default()),
80 "multilingual-e5-small"
81 | "intfloat/multilingual-e5-small"
82 | DEFAULT_LOCAL_SEMANTIC_MODEL => Ok(Self::MultilingualE5Small),
83 "bge-m3" | "baai/bge-m3" | "fastembed-bge-m3-v1" => Ok(Self::BgeM3),
84 other => bail!(
85 "unsupported local embedding model preset {other}; supported presets: multilingual-e5-small, bge-m3"
86 ),
87 }
88 }
89
90 fn label(self) -> &'static str {
91 match self {
92 Self::MultilingualE5Small => "multilingual-e5-small",
93 Self::BgeM3 => "bge-m3",
94 }
95 }
96
97 fn model_id(self) -> &'static str {
98 match self {
99 Self::MultilingualE5Small => DEFAULT_LOCAL_SEMANTIC_MODEL,
100 Self::BgeM3 => "fastembed-bge-m3-v1",
101 }
102 }
103
104 fn upstream_model(self) -> &'static str {
105 match self {
106 Self::MultilingualE5Small => "intfloat/multilingual-e5-small",
107 Self::BgeM3 => "BAAI/bge-m3",
108 }
109 }
110
111 fn source_url(self) -> String {
112 format!("{HUGGING_FACE_BASE_URL}/{}", self.upstream_model())
113 }
114
115 fn dimensions(self) -> usize {
116 match self {
117 Self::MultilingualE5Small => DEFAULT_LOCAL_SEMANTIC_DIMENSIONS,
118 Self::BgeM3 => 1024,
119 }
120 }
121
122 #[cfg(feature = "local-onnx")]
123 fn prefix_input(self, text: &str, kind: LocalEmbeddingInputKind) -> String {
124 match (self, kind) {
125 (Self::MultilingualE5Small, LocalEmbeddingInputKind::Query) => {
126 format!("query: {text}")
127 }
128 (Self::MultilingualE5Small, LocalEmbeddingInputKind::Passage) => {
129 format!("passage: {text}")
130 }
131 _ => text.to_string(),
132 }
133 }
134
135 #[cfg(feature = "local-onnx")]
136 fn fastembed_model(self) -> fastembed::EmbeddingModel {
137 match self {
138 Self::MultilingualE5Small => fastembed::EmbeddingModel::MultilingualE5Small,
139 Self::BgeM3 => fastembed::EmbeddingModel::BGEM3,
140 }
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub(super) struct LocalModelProfile {
146 pub(super) model: String,
147 pub(super) dimensions: usize,
148 pub(super) install_dir: PathBuf,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
152pub struct LocalEmbeddingDownloadReport {
153 pub preset: String,
154 pub model_id: String,
155 pub upstream_model: String,
156 pub dimensions: usize,
157 pub install_dir: String,
158 pub files_verified: usize,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162pub struct LocalEmbeddingModelInventory {
163 pub preset: String,
164 pub model_id: String,
165 pub upstream_model: String,
166 pub dimensions: usize,
167 pub install_dir: String,
168 pub installed: bool,
169 pub checksum_verified: bool,
170 pub unavailable_reason: Option<String>,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
174pub struct LocalEmbeddingInventoryReport {
175 pub model_root: String,
176 pub configured_preset: String,
177 pub models: Vec<LocalEmbeddingModelInventory>,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181struct LocalModelManifest {
182 schema_version: u32,
183 preset: String,
184 model_id: String,
185 upstream_model: String,
186 dimensions: usize,
187 runtime: String,
188 source_url: Option<String>,
189 downloaded_at_epoch: i64,
190 files: Vec<LocalModelFile>,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194struct LocalModelFile {
195 path: String,
196 sha256: String,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 source_sha256: Option<String>,
199 bytes: u64,
200}
201
202pub(super) fn model_root(config: &EmbeddingConfig) -> PathBuf {
203 config
204 .model_dir
205 .as_ref()
206 .map(PathBuf::from)
207 .unwrap_or_else(|| crate::db::data_dir().join("models"))
208}
209
210pub(super) fn installed_model_profile(config: &EmbeddingConfig) -> Result<LocalModelProfile> {
211 let preset = configured_preset(config)?;
212 verified_profile_for_preset(config, preset)
213}
214
215pub(super) fn download_model(model: Option<&str>) -> Result<LocalEmbeddingDownloadReport> {
216 let config = super::resolve_embedding_config()?;
217 let preset = match model {
218 Some(raw) => LocalEmbeddingPreset::parse(raw)?,
219 None => configured_local_preset_or_default(&config)?,
220 };
221 let install_dir = install_dir_for_preset(&config, preset);
222 std::fs::create_dir_all(&install_dir)
223 .with_context(|| format!("create local embedding model dir {}", install_dir.display()))?;
224 materialize_fastembed_model(preset, &install_dir)?;
225 let files = collect_model_files(&install_dir)?;
226 if files.is_empty() {
227 bail!(
228 "local embedding download did not materialize model files in {}",
229 install_dir.display()
230 );
231 }
232 let manifest = LocalModelManifest {
233 schema_version: MANIFEST_SCHEMA_VERSION,
234 preset: preset.label().to_string(),
235 model_id: preset.model_id().to_string(),
236 upstream_model: preset.upstream_model().to_string(),
237 dimensions: preset.dimensions(),
238 runtime: FASTEMBED_RUNTIME.to_string(),
239 source_url: Some(preset.source_url()),
240 downloaded_at_epoch: chrono::Utc::now().timestamp(),
241 files,
242 };
243 write_manifest(&install_dir, &manifest)?;
244 let verified = read_verified_manifest(&install_dir, Some(preset))?;
245 Ok(LocalEmbeddingDownloadReport {
246 preset: verified.preset,
247 model_id: verified.model_id,
248 upstream_model: verified.upstream_model,
249 dimensions: verified.dimensions,
250 install_dir: install_dir.display().to_string(),
251 files_verified: verified.files.len(),
252 })
253}
254
255pub(super) fn inventory() -> Result<LocalEmbeddingInventoryReport> {
256 let config = super::resolve_embedding_config()?;
257 let root = model_root(&config);
258 let configured = configured_local_preset_or_default(&config)?;
259 let models = LocalEmbeddingPreset::all()
260 .iter()
261 .copied()
262 .map(|preset| inventory_for_preset(&config, preset))
263 .collect::<Result<Vec<_>>>()?;
264 Ok(LocalEmbeddingInventoryReport {
265 model_root: root.display().to_string(),
266 configured_preset: configured.label().to_string(),
267 models,
268 })
269}
270
271pub(super) fn embed_text(
272 text: &str,
273 config: &EmbeddingConfig,
274 kind: LocalEmbeddingInputKind,
275) -> Result<TextEmbedding> {
276 let preset = configured_preset(config)?;
277 let profile = verified_profile_for_preset(config, preset)?;
278 let values = embed_with_fastembed(preset, &profile.install_dir, text, kind)?;
279 if values.len() != profile.dimensions {
280 bail!(
281 "local embedding model {} returned {} dimensions, expected {}",
282 profile.model,
283 values.len(),
284 profile.dimensions
285 );
286 }
287 TextEmbedding::new(profile.model, values)
288}
289
290fn configured_preset(config: &EmbeddingConfig) -> Result<LocalEmbeddingPreset> {
291 let raw = config.model.trim();
292 if raw.is_empty() || raw == super::OPENAI_DEFAULT_MODEL {
293 return Ok(LocalEmbeddingPreset::default());
294 }
295 LocalEmbeddingPreset::parse(raw)
296}
297
298pub(super) fn configured_model_id(config: &EmbeddingConfig) -> Result<String> {
299 Ok(configured_preset(config)?.model_id().to_string())
300}
301
302fn configured_local_preset_or_default(config: &EmbeddingConfig) -> Result<LocalEmbeddingPreset> {
303 if config.provider == super::EmbeddingProvider::Local {
304 configured_preset(config)
305 } else {
306 Ok(LocalEmbeddingPreset::default())
307 }
308}
309
310fn verified_profile_for_preset(
311 config: &EmbeddingConfig,
312 preset: LocalEmbeddingPreset,
313) -> Result<LocalModelProfile> {
314 let install_dir = install_dir_for_preset(config, preset);
315 let manifest = read_verified_manifest(&install_dir, Some(preset)).map_err(|error| {
316 model_unavailable_error(format!(
317 "local embedding model {} is not ready in {}: {error}",
318 preset.label(),
319 install_dir.display()
320 ))
321 })?;
322 Ok(LocalModelProfile {
323 model: manifest.model_id,
324 dimensions: manifest.dimensions,
325 install_dir,
326 })
327}
328
329fn inventory_for_preset(
330 config: &EmbeddingConfig,
331 preset: LocalEmbeddingPreset,
332) -> Result<LocalEmbeddingModelInventory> {
333 let install_dir = install_dir_for_preset(config, preset);
334 match read_verified_manifest(&install_dir, Some(preset)) {
335 Ok(_) => Ok(LocalEmbeddingModelInventory {
336 preset: preset.label().to_string(),
337 model_id: preset.model_id().to_string(),
338 upstream_model: preset.upstream_model().to_string(),
339 dimensions: preset.dimensions(),
340 install_dir: install_dir.display().to_string(),
341 installed: true,
342 checksum_verified: true,
343 unavailable_reason: None,
344 }),
345 Err(error) => Ok(LocalEmbeddingModelInventory {
346 preset: preset.label().to_string(),
347 model_id: preset.model_id().to_string(),
348 upstream_model: preset.upstream_model().to_string(),
349 dimensions: preset.dimensions(),
350 install_dir: install_dir.display().to_string(),
351 installed: false,
352 checksum_verified: false,
353 unavailable_reason: Some(error.to_string()),
354 }),
355 }
356}
357
358fn install_dir_for_preset(config: &EmbeddingConfig, preset: LocalEmbeddingPreset) -> PathBuf {
359 model_root(config).join(preset.model_id())
360}
361
362#[cfg(feature = "local-onnx")]
363fn materialize_fastembed_model(preset: LocalEmbeddingPreset, install_dir: &Path) -> Result<()> {
364 let options = fastembed::TextInitOptions::new(preset.fastembed_model())
365 .with_cache_dir(install_dir.to_path_buf())
366 .with_show_download_progress(true);
367 let mut model = fastembed::TextEmbedding::try_new(options)
368 .with_context(|| format!("initialize local embedding model {}", preset.label()))?;
369 let probe = preset.prefix_input(
370 "remem local embedding readiness probe",
371 LocalEmbeddingInputKind::Generic,
372 );
373 let embeddings = model
374 .embed([probe.as_str()], Some(1))
375 .with_context(|| format!("probe local embedding model {}", preset.label()))?;
376 if embeddings.len() != 1 {
377 bail!(
378 "local embedding model {} returned {} probe embeddings",
379 preset.label(),
380 embeddings.len()
381 );
382 }
383 Ok(())
384}
385
386#[cfg(not(feature = "local-onnx"))]
387fn materialize_fastembed_model(preset: LocalEmbeddingPreset, _install_dir: &Path) -> Result<()> {
388 bail!(
389 "local semantic embedding runtime is not built; rebuild remem with the local-onnx feature to download {}",
390 preset.label()
391 )
392}
393
394#[cfg(feature = "local-onnx")]
395fn embed_with_fastembed(
396 preset: LocalEmbeddingPreset,
397 install_dir: &Path,
398 text: &str,
399 kind: LocalEmbeddingInputKind,
400) -> Result<Vec<f32>> {
401 let input = preset.prefix_input(text, kind);
402 let key = LocalModelCacheKey {
403 preset,
404 install_dir: install_dir.to_path_buf(),
405 };
406 LOCAL_MODEL_CACHE.with(|cache| {
407 let mut cache = cache.borrow_mut();
408 let model = match cache.entry(key) {
409 Entry::Occupied(entry) => entry.into_mut(),
410 Entry::Vacant(entry) => {
411 let options = fastembed::TextInitOptions::new(preset.fastembed_model())
412 .with_cache_dir(install_dir.to_path_buf())
413 .with_show_download_progress(false);
414 let model = fastembed::TextEmbedding::try_new(options).with_context(|| {
415 format!("initialize local embedding model {}", preset.label())
416 })?;
417 entry.insert(model)
418 }
419 };
420 let mut embeddings = model
421 .embed([input.as_str()], Some(1))
422 .with_context(|| format!("embed text with local model {}", preset.label()))?;
423 let first = embeddings
424 .pop()
425 .context("local embedding model did not return an embedding")?;
426 if !embeddings.is_empty() {
427 bail!("local embedding model returned multiple embeddings for single input");
428 }
429 Ok(first)
430 })
431}
432
433#[cfg(not(feature = "local-onnx"))]
434fn embed_with_fastembed(
435 preset: LocalEmbeddingPreset,
436 _install_dir: &Path,
437 _text: &str,
438 _kind: LocalEmbeddingInputKind,
439) -> Result<Vec<f32>> {
440 Err(model_unavailable_error(format!(
441 "local semantic embedding runtime is not built; rebuild remem with the local-onnx feature to use {}",
442 preset.label()
443 )))
444}
445
446fn read_verified_manifest(
447 install_dir: &Path,
448 expected_preset: Option<LocalEmbeddingPreset>,
449) -> Result<LocalModelManifest> {
450 let path = install_dir.join(MANIFEST_FILE);
451 let content =
452 std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
453 let manifest: LocalModelManifest =
454 serde_json::from_str(&content).with_context(|| format!("parse {}", path.display()))?;
455 verify_manifest_header(&manifest, expected_preset)?;
456 for file in &manifest.files {
457 verify_manifest_file(install_dir, file)?;
458 }
459 Ok(manifest)
460}
461
462fn verify_manifest_header(
463 manifest: &LocalModelManifest,
464 expected_preset: Option<LocalEmbeddingPreset>,
465) -> Result<()> {
466 if manifest.schema_version != MANIFEST_SCHEMA_VERSION {
467 bail!(
468 "unsupported manifest schema {}, expected {}",
469 manifest.schema_version,
470 MANIFEST_SCHEMA_VERSION
471 );
472 }
473 let preset = LocalEmbeddingPreset::parse(&manifest.preset)?;
474 if let Some(expected) = expected_preset {
475 if preset != expected {
476 bail!(
477 "manifest preset {} does not match expected {}",
478 manifest.preset,
479 expected.label()
480 );
481 }
482 }
483 if manifest.model_id != preset.model_id() {
484 bail!(
485 "manifest model_id {} does not match preset {}",
486 manifest.model_id,
487 preset.model_id()
488 );
489 }
490 if manifest.dimensions != preset.dimensions() {
491 bail!(
492 "manifest dimensions {} do not match preset {} dimensions {}",
493 manifest.dimensions,
494 preset.label(),
495 preset.dimensions()
496 );
497 }
498 if manifest.runtime != FASTEMBED_RUNTIME {
499 bail!("unsupported local embedding runtime {}", manifest.runtime);
500 }
501 if let Some(source_url) = manifest.source_url.as_deref() {
502 let expected = preset.source_url();
503 if source_url != expected {
504 bail!(
505 "manifest source_url {} does not match preset {} source {}",
506 source_url,
507 preset.label(),
508 expected
509 );
510 }
511 }
512 if manifest.files.is_empty() {
513 bail!("local embedding manifest has no verified files");
514 }
515 Ok(())
516}
517
518fn verify_manifest_file(install_dir: &Path, file: &LocalModelFile) -> Result<()> {
519 let relative = checked_relative_path(&file.path)?;
520 let path = install_dir.join(relative);
521 let metadata = std::fs::metadata(&path).with_context(|| format!("stat {}", path.display()))?;
522 if !metadata.is_file() {
523 bail!("manifest path is not a file: {}", path.display());
524 }
525 if metadata.len() != file.bytes {
526 bail!(
527 "checksum target {} size changed: expected {} bytes, got {}",
528 path.display(),
529 file.bytes,
530 metadata.len()
531 );
532 }
533 let actual = sha256_file(&path)?;
534 if actual != file.sha256 {
535 bail!(
536 "checksum mismatch for {}: expected {}, got {}",
537 path.display(),
538 file.sha256,
539 actual
540 );
541 }
542 if let Some(source_sha256) = file.source_sha256.as_deref() {
543 if actual != source_sha256 {
544 bail!(
545 "source checksum mismatch for {}: expected {}, got {}",
546 path.display(),
547 source_sha256,
548 actual
549 );
550 }
551 }
552 Ok(())
553}
554
555fn checked_relative_path(raw: &str) -> Result<PathBuf> {
556 let path = PathBuf::from(raw);
557 if path.is_absolute() {
558 bail!("manifest path must be relative: {raw}");
559 }
560 if path
561 .components()
562 .any(|component| !matches!(component, Component::Normal(_)))
563 {
564 bail!("manifest path must not contain parent/current components: {raw}");
565 }
566 Ok(path)
567}
568
569fn write_manifest(install_dir: &Path, manifest: &LocalModelManifest) -> Result<()> {
570 let path = install_dir.join(MANIFEST_FILE);
571 let tmp = install_dir.join(format!("{MANIFEST_FILE}.tmp"));
572 let content = serde_json::to_vec_pretty(manifest).context("serialize local model manifest")?;
573 std::fs::write(&tmp, content).with_context(|| format!("write {}", tmp.display()))?;
574 std::fs::rename(&tmp, &path)
575 .with_context(|| format!("replace local model manifest {}", path.display()))?;
576 Ok(())
577}
578
579fn collect_model_files(root: &Path) -> Result<Vec<LocalModelFile>> {
580 let mut files = Vec::new();
581 collect_model_files_inner(root, root, &mut files)?;
582 files.sort_by(|left, right| left.path.cmp(&right.path));
583 Ok(files)
584}
585
586fn collect_model_files_inner(
587 root: &Path,
588 current: &Path,
589 files: &mut Vec<LocalModelFile>,
590) -> Result<()> {
591 for entry in
592 std::fs::read_dir(current).with_context(|| format!("read {}", current.display()))?
593 {
594 let entry = entry?;
595 let path = entry.path();
596 let file_name = entry.file_name();
597 let file_name = file_name.to_string_lossy();
598 if file_name == MANIFEST_FILE || file_name == format!("{MANIFEST_FILE}.tmp") {
599 continue;
600 }
601 if file_name == ".locks" || file_name.ends_with(".lock") || file_name.ends_with(".tmp") {
602 continue;
603 }
604 let metadata = entry.metadata()?;
605 if metadata.is_dir() {
606 collect_model_files_inner(root, &path, files)?;
607 } else if metadata.is_file() {
608 let relative = path.strip_prefix(root).with_context(|| {
609 format!("make {} relative to {}", path.display(), root.display())
610 })?;
611 let relative = relative
612 .components()
613 .map(|component| match component {
614 Component::Normal(value) => Ok(value.to_string_lossy().to_string()),
615 _ => bail!("unexpected non-normal cache path {}", path.display()),
616 })
617 .collect::<Result<Vec<_>>>()?
618 .join("/");
619 let sha256 = sha256_file(&path)?;
620 let source_sha256 = source_sha256_from_hf_blob_path(&relative, &sha256)?;
621 files.push(LocalModelFile {
622 path: relative,
623 sha256,
624 source_sha256,
625 bytes: metadata.len(),
626 });
627 }
628 }
629 Ok(())
630}
631
632fn source_sha256_from_hf_blob_path(relative: &str, actual_sha256: &str) -> Result<Option<String>> {
633 let parts = relative.split('/').collect::<Vec<_>>();
634 let Some(file_name) = parts.last().copied() else {
635 return Ok(None);
636 };
637 if parts.len() < 2 || parts[parts.len() - 2] != "blobs" || !is_sha256_hex(file_name) {
638 return Ok(None);
639 }
640 if file_name != actual_sha256 {
641 bail!(
642 "source checksum mismatch for Hugging Face cache blob {relative}: expected {file_name}, got {actual_sha256}"
643 );
644 }
645 Ok(Some(file_name.to_string()))
646}
647
648fn is_sha256_hex(value: &str) -> bool {
649 value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
650}
651
652fn sha256_file(path: &Path) -> Result<String> {
653 let mut file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
654 let mut hasher = Sha256::new();
655 let mut buffer = [0_u8; 64 * 1024];
656 loop {
657 let read = std::io::Read::read(&mut file, &mut buffer)
658 .with_context(|| format!("read {}", path.display()))?;
659 if read == 0 {
660 break;
661 }
662 hasher.update(&buffer[..read]);
663 }
664 Ok(hasher
665 .finalize()
666 .iter()
667 .map(|byte| format!("{byte:02x}"))
668 .collect())
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674
675 #[test]
676 fn hf_cache_blob_source_sha_is_verified() -> Result<()> {
677 let sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
678
679 let verified = source_sha256_from_hf_blob_path(&format!("models--demo/blobs/{sha}"), sha)?;
680
681 assert_eq!(verified.as_deref(), Some(sha));
682 Ok(())
683 }
684
685 #[test]
686 fn hf_cache_blob_source_sha_mismatch_fails() {
687 let source = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
688 let actual = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
689
690 let error =
691 source_sha256_from_hf_blob_path(&format!("models--demo/blobs/{source}"), actual)
692 .unwrap_err();
693
694 assert!(error.to_string().contains("source checksum mismatch"));
695 }
696}