1use std::collections::BTreeMap;
8use std::fmt::Write as _;
9use std::fs::{self, File};
10use std::io::{BufReader, Read};
11use std::path::{Path, PathBuf};
12use std::sync::{OnceLock, RwLock};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use frankensearch_core::error::{SearchError, SearchResult};
19
20pub const DOWNLOAD_CONSENT_ENV: &str = "FRANKENSEARCH_ALLOW_DOWNLOAD";
22
23pub const PLACEHOLDER_VERIFY_AFTER_DOWNLOAD: &str = "PLACEHOLDER_VERIFY_AFTER_DOWNLOAD";
25
26pub const PLACEHOLDER_PINNED_REVISION: &str = "UNPINNED_VERIFY_AFTER_DOWNLOAD";
28
29pub const MANIFEST_SCHEMA_VERSION: u32 = 2;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum ModelTier {
40 Fast,
42 Quality,
44 Reranker,
46}
47
48const HASH_BUFFER_SIZE: usize = 8 * 1024;
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct ModelFile {
53 pub name: String,
55 pub sha256: String,
57 pub size: u64,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub url: Option<String>,
63}
64
65impl ModelFile {
66 #[must_use]
68 pub fn uses_placeholder_checksum(&self) -> bool {
69 self.sha256 == PLACEHOLDER_VERIFY_AFTER_DOWNLOAD
70 }
71
72 #[must_use]
74 pub fn has_verified_checksum(&self) -> bool {
75 is_valid_sha256_hex(&self.sha256) && !self.uses_placeholder_checksum()
76 }
77
78 #[must_use]
83 pub fn local_name(&self) -> &str {
84 self.name.rsplit('/').next().unwrap_or(&self.name)
85 }
86
87 #[must_use]
90 pub fn download_url(&self, repo: &str, revision: &str) -> String {
91 self.url.as_ref().map_or_else(
92 || {
93 format!(
94 "https://huggingface.co/{repo}/resolve/{revision}/{}",
95 self.name
96 )
97 },
98 Clone::clone,
99 )
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct ModelManifest {
106 pub id: String,
108 #[serde(default)]
110 pub version: String,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub display_name: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub description: Option<String>,
117 pub repo: String,
119 pub revision: String,
121 pub files: Vec<ModelFile>,
123 pub license: String,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub dimension: Option<u32>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub tier: Option<ModelTier>,
132 #[serde(
134 default,
135 rename = "total_size_bytes",
136 skip_serializing_if = "is_zero_u64"
137 )]
138 pub download_size_bytes: u64,
139}
140
141#[allow(clippy::trivially_copy_pass_by_ref)] const fn is_zero_u64(value: &u64) -> bool {
143 *value == 0
144}
145
146impl ModelManifest {
147 #[must_use]
149 pub fn minilm_v2() -> Self {
150 const REVISION: &str = "c9745ed1d9f207416be6d2e6f8de32d1f16199bf";
151 const REPO: &str = "sentence-transformers/all-MiniLM-L6-v2";
152 Self {
153 id: "all-minilm-l6-v2".to_owned(),
154 version: "v1".to_owned(),
155 display_name: Some("All MiniLM L6 v2 (quality tier)".to_owned()),
156 description: Some(
157 "MiniLM-L6-v2 ONNX sentence embedding model for quality-tier semantic search"
158 .to_owned(),
159 ),
160 repo: REPO.to_owned(),
161 revision: REVISION.to_owned(),
162 files: vec![
163 ModelFile {
164 name: "onnx/model.onnx".to_owned(),
165 sha256: "6fd5d72fe4589f189f8ebc006442dbb529bb7ce38f8082112682524616046452"
166 .to_owned(),
167 size: 90_405_214,
168 url: Some(
169 "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/c9745ed1d9f207416be6d2e6f8de32d1f16199bf/onnx/model.onnx"
170 .to_owned(),
171 ),
172 },
173 ModelFile {
174 name: "tokenizer.json".to_owned(),
175 sha256: "be50c3628f2bf5bb5e3a7f17b1f74611b2561a3a27eeab05e5aa30f411572037"
176 .to_owned(),
177 size: 466_247,
178 url: Some(
179 "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/c9745ed1d9f207416be6d2e6f8de32d1f16199bf/tokenizer.json"
180 .to_owned(),
181 ),
182 },
183 ModelFile {
184 name: "config.json".to_owned(),
185 sha256: "953f9c0d463486b10a6871cc2fd59f223b2c70184f49815e7efbcab5d8908b41"
186 .to_owned(),
187 size: 612,
188 url: Some(
189 "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/c9745ed1d9f207416be6d2e6f8de32d1f16199bf/config.json"
190 .to_owned(),
191 ),
192 },
193 ModelFile {
194 name: "special_tokens_map.json".to_owned(),
195 sha256: "303df45a03609e4ead04bc3dc1536d0ab19b5358db685b6f3da123d05ec200e3"
196 .to_owned(),
197 size: 112,
198 url: Some(
199 "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/c9745ed1d9f207416be6d2e6f8de32d1f16199bf/special_tokens_map.json"
200 .to_owned(),
201 ),
202 },
203 ModelFile {
204 name: "tokenizer_config.json".to_owned(),
205 sha256: "acb92769e8195aabd29b7b2137a9e6d6e25c476a4f15aa4355c233426c61576b"
206 .to_owned(),
207 size: 350,
208 url: Some(
209 "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/c9745ed1d9f207416be6d2e6f8de32d1f16199bf/tokenizer_config.json"
210 .to_owned(),
211 ),
212 },
213 ],
214 license: "Apache-2.0".to_owned(),
215 dimension: Some(384),
216 tier: Some(ModelTier::Quality),
217 download_size_bytes: 90_872_535,
218 }
219 }
220
221 #[must_use]
223 pub fn potion_128m() -> Self {
224 const REVISION: &str = "a28f4eebecd4dc585034f605e52d414878a0417c";
225 const REPO: &str = "minishlab/potion-multilingual-128M";
226 Self {
227 id: "potion-multilingual-128m".to_owned(),
228 version: "v1".to_owned(),
229 display_name: Some("Potion Multilingual 128M (fast tier)".to_owned()),
230 description: Some(
231 "Model2Vec static embedding model for fast-tier multilingual retrieval".to_owned(),
232 ),
233 repo: REPO.to_owned(),
234 revision: REVISION.to_owned(),
235 files: vec![
236 ModelFile {
237 name: "tokenizer.json".to_owned(),
238 sha256: "19f1909063da3cfe3bd83a782381f040dccea475f4816de11116444a73e1b6a1"
239 .to_owned(),
240 size: 18_616_131,
241 url: Some(
242 "https://huggingface.co/minishlab/potion-multilingual-128M/resolve/a28f4eebecd4dc585034f605e52d414878a0417c/tokenizer.json"
243 .to_owned(),
244 ),
245 },
246 ModelFile {
247 name: "model.safetensors".to_owned(),
248 sha256: "14b5eb39cb4ce5666da8ad1f3dc6be4346e9b2d601c073302fa0a31bf7943397"
249 .to_owned(),
250 size: 512_361_560,
251 url: Some(
252 "https://huggingface.co/minishlab/potion-multilingual-128M/resolve/a28f4eebecd4dc585034f605e52d414878a0417c/model.safetensors"
253 .to_owned(),
254 ),
255 },
256 ],
257 license: "Apache-2.0".to_owned(),
258 dimension: Some(256),
259 tier: Some(ModelTier::Fast),
260 download_size_bytes: 530_977_691,
261 }
262 }
263
264 #[must_use]
266 pub fn flashrank_nano() -> Self {
267 const REVISION: &str = PLACEHOLDER_PINNED_REVISION;
268 const REPO: &str = "prithivida/flashrank-nano";
269 Self {
270 id: "flashrank-nano".to_owned(),
271 version: "v1".to_owned(),
272 display_name: Some("FlashRank Nano (Reranker)".to_owned()),
273 description: Some("FlashRank compact ONNX cross-encoder reranker model".to_owned()),
274 repo: REPO.to_owned(),
275 revision: REVISION.to_owned(),
276 files: vec![
277 ModelFile {
278 name: "onnx/model.onnx".to_owned(),
279 sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
280 size: 0,
281 url: None,
282 },
283 ModelFile {
284 name: "tokenizer.json".to_owned(),
285 sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
286 size: 0,
287 url: None,
288 },
289 ],
290 license: "Apache-2.0".to_owned(),
291 dimension: None,
292 tier: Some(ModelTier::Reranker),
293 download_size_bytes: 0,
294 }
295 }
296
297 #[must_use]
299 pub fn ms_marco_reranker() -> Self {
300 const REVISION: &str = "c5ee24cb16019beea0893ab7796b1df96625c6b8";
301 const REPO: &str = "cross-encoder/ms-marco-MiniLM-L-6-v2";
302 Self {
303 id: "ms-marco-minilm-l-6-v2".to_owned(),
304 version: "v1".to_owned(),
305 display_name: Some("MS MARCO MiniLM L-6 v2 (reranker)".to_owned()),
306 description: Some(
307 "MS MARCO cross-encoder reranker model for final relevance scoring".to_owned(),
308 ),
309 repo: REPO.to_owned(),
310 revision: REVISION.to_owned(),
311 files: vec![
312 ModelFile {
313 name: "onnx/model.onnx".to_owned(),
314 sha256: "5d3e70fd0c9ff14b9b5169a51e957b7a9c74897afd0a35ce4bd318150c1d4d4a"
315 .to_owned(),
316 size: 91_011_230,
317 url: Some(
318 "https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2/resolve/c5ee24cb16019beea0893ab7796b1df96625c6b8/onnx/model.onnx"
319 .to_owned(),
320 ),
321 },
322 ModelFile {
323 name: "tokenizer.json".to_owned(),
324 sha256: "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66"
325 .to_owned(),
326 size: 711_396,
327 url: Some(
328 "https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2/resolve/c5ee24cb16019beea0893ab7796b1df96625c6b8/tokenizer.json"
329 .to_owned(),
330 ),
331 },
332 ModelFile {
333 name: "config.json".to_owned(),
334 sha256: "380e02c93f431831be65d99a4e7e5f67c133985bf2e77d9d4eba46847190bacc"
335 .to_owned(),
336 size: 794,
337 url: Some(
338 "https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2/resolve/c5ee24cb16019beea0893ab7796b1df96625c6b8/config.json"
339 .to_owned(),
340 ),
341 },
342 ModelFile {
343 name: "special_tokens_map.json".to_owned(),
344 sha256: "3c3507f36dff57bce437223db3b3081d1e2b52ec3e56ee55438193ecb2c94dd6"
345 .to_owned(),
346 size: 132,
347 url: Some(
348 "https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2/resolve/c5ee24cb16019beea0893ab7796b1df96625c6b8/special_tokens_map.json"
349 .to_owned(),
350 ),
351 },
352 ModelFile {
353 name: "tokenizer_config.json".to_owned(),
354 sha256: "a5c2e5a7b1a29a0702cd28c08a399b5ecc110c263009d17f7e3b415f25905fd8"
355 .to_owned(),
356 size: 1_330,
357 url: Some(
358 "https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2/resolve/c5ee24cb16019beea0893ab7796b1df96625c6b8/tokenizer_config.json"
359 .to_owned(),
360 ),
361 },
362 ],
363 license: "Apache-2.0".to_owned(),
364 dimension: None, tier: Some(ModelTier::Reranker),
366 download_size_bytes: 91_724_882,
367 }
368 }
369
370 #[must_use]
377 pub fn snowflake_arctic_s() -> Self {
378 const REVISION: &str = "e596f507467533e48a2e17c007f0e1dacc837b33";
379 const REPO: &str = "Snowflake/snowflake-arctic-embed-s";
380 Self {
381 id: "snowflake-arctic-embed-s".to_owned(),
382 version: "v1".to_owned(),
383 display_name: Some("Snowflake Arctic Embed S".to_owned()),
384 description: Some(
385 "Small, fast embedding model with MiniLM-compatible 384 dimensions".to_owned(),
386 ),
387 repo: REPO.to_owned(),
388 revision: REVISION.to_owned(),
389 files: vec![
390 ModelFile {
391 name: "onnx/model.onnx".to_owned(),
392 sha256: "579c1f1778a0993eb0d2a1403340ffb491c769247fb46acc4f5cf8ac5b89c1e1"
393 .to_owned(),
394 size: 133_093_492,
395 url: None,
396 },
397 ModelFile {
398 name: "tokenizer.json".to_owned(),
399 sha256: "91f1def9b9391fdabe028cd3f3fcc4efd34e5d1f08c3bf2de513ebb5911a1854"
400 .to_owned(),
401 size: 711_649,
402 url: None,
403 },
404 ModelFile {
405 name: "config.json".to_owned(),
406 sha256: "4e519aa92ec40943356032afe458c8829d70c5766b109e4a57490b82f72dcfb7"
407 .to_owned(),
408 size: 703,
409 url: None,
410 },
411 ModelFile {
412 name: "special_tokens_map.json".to_owned(),
413 sha256: "5d5b662e421ea9fac075174bb0688ee0d9431699900b90662acd44b2a350503a"
414 .to_owned(),
415 size: 695,
416 url: None,
417 },
418 ModelFile {
419 name: "tokenizer_config.json".to_owned(),
420 sha256: "9ca59277519f6e3692c8685e26b94d4afca2d5438deff66483db495e48735810"
421 .to_owned(),
422 size: 1_433,
423 url: None,
424 },
425 ],
426 license: "Apache-2.0".to_owned(),
427 dimension: Some(384),
428 tier: Some(ModelTier::Quality),
429 download_size_bytes: 133_807_972,
430 }
431 }
432
433 #[must_use]
438 pub fn nomic_embed() -> Self {
439 const REVISION: &str = "e5cf08aadaa33385f5990def41f7a23405aec398";
440 const REPO: &str = "nomic-ai/nomic-embed-text-v1.5";
441 Self {
442 id: "nomic-embed-text-v1.5".to_owned(),
443 version: "v1".to_owned(),
444 display_name: Some("Nomic Embed Text v1.5".to_owned()),
445 description: Some(
446 "Long context embedding model with Matryoshka capability (768 dims)".to_owned(),
447 ),
448 repo: REPO.to_owned(),
449 revision: REVISION.to_owned(),
450 files: vec![
451 ModelFile {
452 name: "onnx/model.onnx".to_owned(),
453 sha256: "147d5aa88c2101237358e17796cf3a227cead1ec304ec34b465bb08e9d952965"
454 .to_owned(),
455 size: 547_310_275,
456 url: None,
457 },
458 ModelFile {
459 name: "tokenizer.json".to_owned(),
460 sha256: "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66"
461 .to_owned(),
462 size: 711_396,
463 url: None,
464 },
465 ModelFile {
466 name: "config.json".to_owned(),
467 sha256: "0168e0883705b0bf8f2b381e10f45a9f3e1ef4b13869b43c160e4c8a70ddf442"
468 .to_owned(),
469 size: 2_331,
470 url: None,
471 },
472 ModelFile {
473 name: "special_tokens_map.json".to_owned(),
474 sha256: "5d5b662e421ea9fac075174bb0688ee0d9431699900b90662acd44b2a350503a"
475 .to_owned(),
476 size: 695,
477 url: None,
478 },
479 ModelFile {
480 name: "tokenizer_config.json".to_owned(),
481 sha256: "d7e0000bcc80134debd2222220427e6bf5fa20a669f40a0d0d1409cc18e0a9bc"
482 .to_owned(),
483 size: 1_191,
484 url: None,
485 },
486 ],
487 license: "Apache-2.0".to_owned(),
488 dimension: Some(768),
489 tier: Some(ModelTier::Quality),
490 download_size_bytes: 548_025_888,
491 }
492 }
493
494 #[must_use]
498 pub fn jina_reranker_turbo() -> Self {
499 const REVISION: &str = "b8c14f4e723d9e0aab4732a7b7b93741eeeb77c2";
500 const REPO: &str = "jinaai/jina-reranker-v1-turbo-en";
501 Self {
502 id: "jina-reranker-v1-turbo-en".to_owned(),
503 version: "v1".to_owned(),
504 display_name: Some("Jina Reranker v1 Turbo EN".to_owned()),
505 description: Some("Fast cross-encoder reranker optimized for English".to_owned()),
506 repo: REPO.to_owned(),
507 revision: REVISION.to_owned(),
508 files: vec![
509 ModelFile {
510 name: "onnx/model.onnx".to_owned(),
511 sha256: "c1296c66c119de645fa9cdee536d8637740efe85224cfa270281e50f213aa565"
512 .to_owned(),
513 size: 151_296_975,
514 url: None,
515 },
516 ModelFile {
517 name: "tokenizer.json".to_owned(),
518 sha256: "0046da43cc8c424b317f56b092b0512aaaa65c4f925d2f16af9d9eeb4d0ef902"
519 .to_owned(),
520 size: 2_030_772,
521 url: None,
522 },
523 ModelFile {
524 name: "config.json".to_owned(),
525 sha256: "e050ff6a15ae9295e84882fa0e98051bd8754856cd5201395ebf00ce9f2d609b"
526 .to_owned(),
527 size: 1_206,
528 url: None,
529 },
530 ModelFile {
531 name: "special_tokens_map.json".to_owned(),
532 sha256: "06e405a36dfe4b9604f484f6a1e619af1a7f7d09e34a8555eb0b77b66318067f"
533 .to_owned(),
534 size: 280,
535 url: None,
536 },
537 ModelFile {
538 name: "tokenizer_config.json".to_owned(),
539 sha256: "d291c6652d96d56ffdbcf1ea19d9bae5ed79003f7648c627e725a619227ce8fa"
540 .to_owned(),
541 size: 1_215,
542 url: None,
543 },
544 ],
545 license: "Apache-2.0".to_owned(),
546 dimension: None, tier: Some(ModelTier::Reranker),
548 download_size_bytes: 153_330_448,
549 }
550 }
551
552 #[must_use]
556 pub fn for_embedder(name: &str) -> Option<Self> {
557 match name {
558 "minilm" => Some(Self::minilm_v2()),
559 "snowflake-arctic-s" => Some(Self::snowflake_arctic_s()),
560 "nomic-embed" => Some(Self::nomic_embed()),
561 "potion-128m" => Some(Self::potion_128m()),
562 _ => None,
563 }
564 }
565
566 #[must_use]
568 pub fn for_reranker(name: &str) -> Option<Self> {
569 match name {
570 "ms-marco" => Some(Self::ms_marco_reranker()),
571 "jina-reranker-turbo" => Some(Self::jina_reranker_turbo()),
572 _ => None,
573 }
574 }
575
576 #[must_use]
578 pub fn bakeoff_embedder_candidates() -> Vec<Self> {
579 vec![Self::snowflake_arctic_s(), Self::nomic_embed()]
580 }
581
582 #[must_use]
584 pub fn bakeoff_reranker_candidates() -> Vec<Self> {
585 vec![Self::jina_reranker_turbo()]
586 }
587
588 #[must_use]
590 pub fn bakeoff_candidates() -> Vec<Self> {
591 let mut candidates = Self::bakeoff_embedder_candidates();
592 candidates.extend(Self::bakeoff_reranker_candidates());
593 candidates
594 }
595
596 #[must_use]
601 pub fn builtin_catalog() -> ModelManifestCatalog {
602 ModelManifestCatalog {
603 schema_version: MANIFEST_SCHEMA_VERSION,
604 models: vec![
605 Self::potion_128m(),
606 Self::minilm_v2(),
607 Self::ms_marco_reranker(),
608 Self::snowflake_arctic_s(),
609 Self::nomic_embed(),
610 Self::jina_reranker_turbo(),
611 Self::flashrank_nano(),
612 ],
613 }
614 }
615
616 pub fn from_json_str(raw: &str) -> SearchResult<Self> {
622 let manifest =
623 serde_json::from_str::<Self>(raw).map_err(|source| SearchError::InvalidConfig {
624 field: "manifest_json".to_owned(),
625 value: truncate_for_error(raw),
626 reason: format!("failed to parse manifest JSON: {source}"),
627 })?;
628 manifest.validate()?;
629 Ok(manifest)
630 }
631
632 pub fn to_pretty_json(&self) -> SearchResult<String> {
638 serde_json::to_string_pretty(self).map_err(|source| SearchError::InvalidConfig {
639 field: "manifest_json".to_owned(),
640 value: self.id.clone(),
641 reason: format!("failed to serialize manifest: {source}"),
642 })
643 }
644
645 #[must_use]
647 pub fn has_verified_checksums(&self) -> bool {
648 !self.files.is_empty() && self.files.iter().all(ModelFile::has_verified_checksum)
649 }
650
651 #[must_use]
653 pub fn has_pinned_revision(&self) -> bool {
654 let revision = self.revision.trim();
655 !(revision.is_empty()
656 || revision.eq_ignore_ascii_case("main")
657 || revision.eq_ignore_ascii_case("master")
658 || revision.eq_ignore_ascii_case("latest")
659 || revision.eq_ignore_ascii_case("head")
660 || revision == PLACEHOLDER_PINNED_REVISION)
661 }
662
663 #[must_use]
665 pub fn is_production_ready(&self) -> bool {
666 self.has_verified_checksums() && self.has_pinned_revision()
667 }
668
669 #[must_use]
671 pub fn total_size_bytes(&self) -> u64 {
672 if self.download_size_bytes > 0 {
673 return self.download_size_bytes;
674 }
675 self.files.iter().map(|file| file.size).sum()
676 }
677
678 #[must_use]
680 pub fn total_size(&self) -> u64 {
681 self.total_size_bytes()
682 }
683
684 #[must_use]
686 pub fn download_url(&self, file: &ModelFile) -> String {
687 file.download_url(&self.repo, &self.revision)
688 }
689
690 pub fn validate(&self) -> SearchResult<()> {
696 if self.id.trim().is_empty() {
697 return Err(invalid_manifest_field("id", &self.id, "must not be empty"));
698 }
699 if self.repo.trim().is_empty() {
700 return Err(invalid_manifest_field(
701 "repo",
702 &self.repo,
703 "must not be empty",
704 ));
705 }
706 if self.revision.trim().is_empty() {
707 return Err(invalid_manifest_field(
708 "revision",
709 &self.revision,
710 "must not be empty",
711 ));
712 }
713 if self.license.trim().is_empty() {
714 return Err(invalid_manifest_field(
715 "license",
716 &self.license,
717 "must not be empty",
718 ));
719 }
720
721 for file in &self.files {
722 validate_model_file_name(&file.name)?;
723 if file.uses_placeholder_checksum() {
724 continue;
725 }
726 if !is_valid_sha256_hex(&file.sha256) {
727 return Err(invalid_manifest_field(
728 "files[].sha256",
729 &file.sha256,
730 "must be lowercase 64-char SHA256 hex or placeholder",
731 ));
732 }
733 }
734
735 if self.download_size_bytes > 0 {
736 let computed_size: u64 = self.files.iter().map(|file| file.size).sum();
737 if computed_size != self.download_size_bytes {
738 return Err(invalid_manifest_field(
739 "total_size_bytes",
740 &self.download_size_bytes.to_string(),
741 "must match the sum of files[].size",
742 ));
743 }
744 }
745
746 Ok(())
747 }
748
749 pub fn validate_checksum_policy(&self) -> SearchResult<()> {
755 self.validate_checksum_policy_for(cfg!(not(debug_assertions)))
756 }
757
758 pub fn validate_checksum_policy_for(&self, release_mode: bool) -> SearchResult<()> {
764 if release_mode && self.files.iter().any(ModelFile::uses_placeholder_checksum) {
765 return Err(invalid_manifest_field(
766 "files[].sha256",
767 PLACEHOLDER_VERIFY_AFTER_DOWNLOAD,
768 "placeholder checksums are forbidden in release mode",
769 ));
770 }
771 Ok(())
772 }
773
774 pub fn verify_dir(&self, model_dir: &Path) -> SearchResult<()> {
780 for file in &self.files {
781 let path = resolve_model_file_path(model_dir, &file.name)?;
782 verify_file_sha256(&path, &file.sha256, file.size)?;
783 }
784 Ok(())
785 }
786
787 pub fn promote_verified_installation(
795 &self,
796 staged_dir: &Path,
797 destination_dir: &Path,
798 ) -> SearchResult<Option<PathBuf>> {
799 self.verify_dir(staged_dir)?;
800 promote_atomically(staged_dir, destination_dir)
801 }
802
803 #[must_use]
805 pub fn detect_update_state(&self, installed_revision: &str) -> Option<ModelState> {
806 if !self.has_pinned_revision() {
807 return None;
808 }
809 let current = installed_revision.trim();
810 if current == self.revision {
811 return None;
812 }
813 Some(ModelState::UpdateAvailable {
814 current_revision: if current.is_empty() {
815 "unknown".to_owned()
816 } else {
817 current.to_owned()
818 },
819 latest_revision: self.revision.clone(),
820 })
821 }
822
823 pub fn register(self) -> SearchResult<()> {
829 self.validate()?;
830 manifest_registry()
831 .write()
832 .map_err(|_| manifest_registry_lock_error("write"))?
833 .insert(self.id.clone(), self);
834 Ok(())
835 }
836
837 #[must_use]
839 pub fn lookup(id: &str) -> Option<Self> {
840 let guard = manifest_registry().read().unwrap_or_else(|poisoned| {
841 tracing::warn!(
842 "model manifest registry lock poisoned on read during lookup; using recovered state"
843 );
844 poisoned.into_inner()
845 });
846 guard.get(id).cloned()
847 }
848
849 #[must_use]
851 pub fn registered() -> Vec<Self> {
852 let guard = manifest_registry().read().unwrap_or_else(|poisoned| {
853 tracing::warn!(
854 "model manifest registry lock poisoned on read during listing; using recovered state"
855 );
856 poisoned.into_inner()
857 });
858 guard.values().cloned().collect()
859 }
860}
861
862#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
864pub struct ModelManifestCatalog {
865 #[serde(default = "default_schema_version")]
867 pub schema_version: u32,
868 #[serde(default)]
870 pub models: Vec<ModelManifest>,
871}
872
873const fn default_schema_version() -> u32 {
874 MANIFEST_SCHEMA_VERSION
875}
876
877impl Default for ModelManifestCatalog {
878 fn default() -> Self {
879 Self {
880 schema_version: MANIFEST_SCHEMA_VERSION,
881 models: Vec::new(),
882 }
883 }
884}
885
886impl ModelManifestCatalog {
887 pub fn from_json_str(raw: &str) -> SearchResult<Self> {
893 serde_json::from_str::<Self>(raw).map_err(|source| SearchError::InvalidConfig {
894 field: "manifest_catalog_json".to_owned(),
895 value: truncate_for_error(raw),
896 reason: format!("failed to parse manifest catalog JSON: {source}"),
897 })
898 }
899
900 pub fn validate(&self) -> SearchResult<()> {
906 for model in &self.models {
907 model.validate()?;
908 }
909 Ok(())
910 }
911}
912
913#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
915pub enum ModelState {
916 NotInstalled,
917 NeedsConsent,
918 Downloading {
919 progress_pct: u8,
920 bytes_downloaded: u64,
921 total_bytes: u64,
922 },
923 Verifying,
924 Ready,
925 Disabled {
926 reason: String,
927 },
928 VerificationFailed {
929 reason: String,
930 },
931 UpdateAvailable {
932 current_revision: String,
933 latest_revision: String,
934 },
935 Cancelled,
936}
937
938impl ModelState {
939 #[must_use]
941 pub fn is_ready(&self) -> bool {
942 matches!(self, Self::Ready)
943 }
944
945 #[must_use]
947 pub fn is_downloading(&self) -> bool {
948 matches!(self, Self::Downloading { .. })
949 }
950
951 #[must_use]
953 pub fn needs_consent(&self) -> bool {
954 matches!(self, Self::NeedsConsent)
955 }
956
957 #[must_use]
959 pub fn summary(&self) -> String {
960 match self {
961 Self::NotInstalled => "not installed".into(),
962 Self::NeedsConsent => "needs consent".into(),
963 Self::Downloading { progress_pct, .. } => {
964 format!("downloading ({progress_pct}%)")
965 }
966 Self::Verifying => "verifying".into(),
967 Self::Ready => "ready".into(),
968 Self::Disabled { reason } => format!("disabled: {reason}"),
969 Self::VerificationFailed { reason } => format!("verification failed: {reason}"),
970 Self::UpdateAvailable {
971 current_revision,
972 latest_revision,
973 } => {
974 format!("update available: {current_revision} -> {latest_revision}")
975 }
976 Self::Cancelled => "cancelled".into(),
977 }
978 }
979}
980
981#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
983pub enum ConsentSource {
984 Programmatic,
985 Environment,
986 Interactive,
987 ConfigFile,
988}
989
990#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
992pub struct DownloadConsent {
993 pub granted: bool,
995 pub source: Option<ConsentSource>,
997}
998
999impl DownloadConsent {
1000 #[must_use]
1002 pub const fn granted(source: ConsentSource) -> Self {
1003 Self {
1004 granted: true,
1005 source: Some(source),
1006 }
1007 }
1008
1009 #[must_use]
1011 pub const fn denied(source: Option<ConsentSource>) -> Self {
1012 Self {
1013 granted: false,
1014 source,
1015 }
1016 }
1017}
1018
1019#[must_use]
1022pub fn resolve_download_consent(
1023 programmatic: Option<bool>,
1024 interactive: Option<bool>,
1025 config_file: Option<bool>,
1026) -> DownloadConsent {
1027 let env_value = std::env::var(DOWNLOAD_CONSENT_ENV).ok();
1028 resolve_download_consent_with_env(programmatic, env_value.as_deref(), interactive, config_file)
1029}
1030
1031fn resolve_download_consent_with_env(
1032 programmatic: Option<bool>,
1033 env_value: Option<&str>,
1034 interactive: Option<bool>,
1035 config_file: Option<bool>,
1036) -> DownloadConsent {
1037 if let Some(granted) = programmatic {
1038 return DownloadConsent {
1039 granted,
1040 source: Some(ConsentSource::Programmatic),
1041 };
1042 }
1043
1044 if let Some(raw) = env_value
1045 && let Some(granted) = parse_bool_flag(raw)
1046 {
1047 return DownloadConsent {
1048 granted,
1049 source: Some(ConsentSource::Environment),
1050 };
1051 }
1052
1053 if let Some(granted) = interactive {
1054 return DownloadConsent {
1055 granted,
1056 source: Some(ConsentSource::Interactive),
1057 };
1058 }
1059
1060 if let Some(granted) = config_file {
1061 return DownloadConsent {
1062 granted,
1063 source: Some(ConsentSource::ConfigFile),
1064 };
1065 }
1066
1067 DownloadConsent::denied(None)
1068}
1069
1070#[derive(Debug, Clone)]
1072pub struct ModelLifecycle {
1073 manifest: ModelManifest,
1074 state: ModelState,
1075 consent: DownloadConsent,
1076}
1077
1078impl ModelLifecycle {
1079 #[must_use]
1081 pub const fn new(manifest: ModelManifest, consent: DownloadConsent) -> Self {
1082 let state = if consent.granted {
1083 ModelState::NotInstalled
1084 } else {
1085 ModelState::NeedsConsent
1086 };
1087 Self {
1088 manifest,
1089 state,
1090 consent,
1091 }
1092 }
1093
1094 #[must_use]
1096 pub const fn state(&self) -> &ModelState {
1097 &self.state
1098 }
1099
1100 #[must_use]
1102 pub const fn manifest(&self) -> &ModelManifest {
1103 &self.manifest
1104 }
1105
1106 pub fn approve_consent(&mut self, source: ConsentSource) {
1108 self.consent = DownloadConsent::granted(source);
1109 if matches!(self.state, ModelState::NeedsConsent) {
1110 self.state = ModelState::NotInstalled;
1111 }
1112 }
1113
1114 pub fn begin_download(&mut self, total_bytes: u64) -> SearchResult<()> {
1120 if !self.consent.granted {
1121 self.state = ModelState::NeedsConsent;
1122 return Err(SearchError::EmbedderUnavailable {
1123 model: self.manifest.id.clone(),
1124 reason: "download consent required".to_owned(),
1125 });
1126 }
1127 if total_bytes == 0 {
1128 return Err(SearchError::InvalidConfig {
1129 field: "total_bytes".to_owned(),
1130 value: "0".to_owned(),
1131 reason: "must be greater than zero".to_owned(),
1132 });
1133 }
1134
1135 match self.state {
1136 ModelState::NotInstalled
1137 | ModelState::Cancelled
1138 | ModelState::VerificationFailed { .. } => {
1139 self.state = ModelState::Downloading {
1140 progress_pct: 0,
1141 bytes_downloaded: 0,
1142 total_bytes,
1143 };
1144 Ok(())
1145 }
1146 _ => Err(invalid_state_transition(
1147 &self.state,
1148 "begin_download",
1149 "expected NotInstalled/Cancelled/VerificationFailed",
1150 )),
1151 }
1152 }
1153
1154 pub fn update_download_progress(&mut self, bytes_downloaded: u64) -> SearchResult<()> {
1160 let (progress_pct, total_bytes, bounded_bytes) = match self.state {
1161 ModelState::Downloading { total_bytes, .. } => {
1162 let bounded = bytes_downloaded.min(total_bytes);
1163 let pct_u64 = bounded.saturating_mul(100) / total_bytes;
1164 #[allow(clippy::cast_possible_truncation)]
1165 let pct = pct_u64 as u8;
1166 (pct.min(100), total_bytes, bounded)
1167 }
1168 _ => {
1169 return Err(invalid_state_transition(
1170 &self.state,
1171 "update_download_progress",
1172 "expected Downloading",
1173 ));
1174 }
1175 };
1176
1177 self.state = ModelState::Downloading {
1178 progress_pct,
1179 bytes_downloaded: bounded_bytes,
1180 total_bytes,
1181 };
1182 Ok(())
1183 }
1184
1185 pub fn begin_verification(&mut self) -> SearchResult<()> {
1191 if matches!(self.state, ModelState::Downloading { .. }) {
1192 self.state = ModelState::Verifying;
1193 return Ok(());
1194 }
1195 Err(invalid_state_transition(
1196 &self.state,
1197 "begin_verification",
1198 "expected Downloading",
1199 ))
1200 }
1201
1202 pub fn mark_ready(&mut self) {
1204 self.state = ModelState::Ready;
1205 }
1206
1207 pub fn fail_verification(&mut self, reason: impl Into<String>) {
1209 self.state = ModelState::VerificationFailed {
1210 reason: reason.into(),
1211 };
1212 }
1213
1214 pub fn disable(&mut self, reason: impl Into<String>) {
1216 self.state = ModelState::Disabled {
1217 reason: reason.into(),
1218 };
1219 }
1220
1221 pub fn mark_update_available(
1223 &mut self,
1224 current_revision: impl Into<String>,
1225 latest_revision: impl Into<String>,
1226 ) {
1227 self.state = ModelState::UpdateAvailable {
1228 current_revision: current_revision.into(),
1229 latest_revision: latest_revision.into(),
1230 };
1231 }
1232
1233 pub fn cancel(&mut self) {
1235 self.state = ModelState::Cancelled;
1236 }
1237
1238 pub fn recover_after_cancel(&mut self) -> SearchResult<()> {
1244 if !matches!(self.state, ModelState::Cancelled) {
1245 return Err(invalid_state_transition(
1246 &self.state,
1247 "recover_after_cancel",
1248 "expected Cancelled",
1249 ));
1250 }
1251 self.state = if self.consent.granted {
1252 ModelState::NotInstalled
1253 } else {
1254 ModelState::NeedsConsent
1255 };
1256 Ok(())
1257 }
1258}
1259
1260pub fn verify_file_sha256(
1266 path: &Path,
1267 expected_sha256: &str,
1268 expected_size: u64,
1269) -> SearchResult<()> {
1270 if expected_sha256 == PLACEHOLDER_VERIFY_AFTER_DOWNLOAD {
1271 return Err(SearchError::InvalidConfig {
1272 field: "sha256".to_owned(),
1273 value: expected_sha256.to_owned(),
1274 reason: "placeholder checksum cannot be verified".to_owned(),
1275 });
1276 }
1277 if !is_valid_sha256_hex(expected_sha256) {
1278 return Err(SearchError::InvalidConfig {
1279 field: "sha256".to_owned(),
1280 value: expected_sha256.to_owned(),
1281 reason: "expected lowercase 64-char SHA256 hex".to_owned(),
1282 });
1283 }
1284 if !path.exists() {
1285 return Err(SearchError::ModelNotFound {
1286 name: format!("missing model file: {}", path.display()),
1287 });
1288 }
1289
1290 let metadata = fs::metadata(path).map_err(|source| SearchError::ModelLoadFailed {
1291 path: path.to_path_buf(),
1292 source: Box::new(source),
1293 })?;
1294 if !metadata.is_file() {
1295 return Err(SearchError::ModelLoadFailed {
1296 path: path.to_path_buf(),
1297 source: "expected a regular file".into(),
1298 });
1299 }
1300
1301 let file = File::open(path).map_err(|source| SearchError::ModelLoadFailed {
1302 path: path.to_path_buf(),
1303 source: Box::new(source),
1304 })?;
1305 let mut reader = BufReader::new(file);
1306 let mut buffer = [0_u8; HASH_BUFFER_SIZE];
1307 let mut hasher = Sha256::new();
1308 let mut bytes_read = 0_u64;
1309
1310 loop {
1311 let read = reader
1312 .read(&mut buffer)
1313 .map_err(|source| SearchError::ModelLoadFailed {
1314 path: path.to_path_buf(),
1315 source: Box::new(source),
1316 })?;
1317 if read == 0 {
1318 break;
1319 }
1320 let read_u64 = u64::try_from(read).map_err(|_| SearchError::InvalidConfig {
1321 field: "read_size".to_owned(),
1322 value: read.to_string(),
1323 reason: "read size does not fit u64".to_owned(),
1324 })?;
1325 bytes_read = bytes_read.saturating_add(read_u64);
1326 hasher.update(&buffer[..read]);
1327 }
1328
1329 let actual_sha256 = to_hex_lowercase(&hasher.finalize());
1330 let expected_lower = expected_sha256.to_ascii_lowercase();
1331 if bytes_read != expected_size || actual_sha256 != expected_lower {
1332 return Err(SearchError::HashMismatch {
1333 path: path.to_path_buf(),
1334 expected: format!("sha256={expected_lower},size={expected_size}"),
1335 actual: format!("sha256={actual_sha256},size={bytes_read}"),
1336 });
1337 }
1338
1339 Ok(())
1340}
1341
1342const VERIFIED_MARKER_FILE: &str = ".verified";
1346
1347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1353pub struct FileVerificationState {
1354 pub size_bytes: u64,
1356 pub modified_unix_nanos: u64,
1358}
1359
1360fn capture_file_verification_state(path: &Path) -> Option<FileVerificationState> {
1361 let metadata = fs::metadata(path).ok()?;
1362 if !metadata.is_file() {
1363 return None;
1364 }
1365 let modified = metadata
1366 .modified()
1367 .ok()?
1368 .duration_since(UNIX_EPOCH)
1369 .ok()?
1370 .as_nanos();
1371 let modified_unix_nanos = u64::try_from(modified).ok()?;
1372 Some(FileVerificationState {
1373 size_bytes: metadata.len(),
1374 modified_unix_nanos,
1375 })
1376}
1377
1378fn resolve_model_file_path(model_dir: &Path, file_name: &str) -> SearchResult<PathBuf> {
1379 validate_model_file_name(file_name)?;
1380 Ok(model_dir.join(file_name))
1381}
1382
1383fn validate_model_file_name(file_name: &str) -> SearchResult<()> {
1384 if file_name.trim().is_empty() {
1385 return Err(invalid_manifest_field(
1386 "files[].name",
1387 file_name,
1388 "must not be empty",
1389 ));
1390 }
1391 for component in Path::new(file_name).components() {
1392 match component {
1393 std::path::Component::ParentDir => {
1394 return Err(invalid_manifest_field(
1395 "files[].name",
1396 file_name,
1397 "must not contain '..' path traversal",
1398 ));
1399 }
1400 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
1401 return Err(invalid_manifest_field(
1402 "files[].name",
1403 file_name,
1404 "must be a relative path without root",
1405 ));
1406 }
1407 _ => {}
1408 }
1409 }
1410 Ok(())
1411}
1412
1413#[derive(Debug, Clone, Serialize, Deserialize)]
1420pub struct VerificationMarker {
1421 pub manifest_id: String,
1423 pub schema_version: u32,
1425 pub verified_at: u64,
1427 pub file_states: BTreeMap<String, FileVerificationState>,
1429}
1430
1431impl VerificationMarker {
1432 fn new_for(manifest: &ModelManifest, model_dir: &Path) -> Self {
1434 let now = SystemTime::now()
1435 .duration_since(UNIX_EPOCH)
1436 .map_or(0, |d| d.as_secs());
1437
1438 let mut file_states = BTreeMap::new();
1439 for file in &manifest.files {
1440 if let Ok(path) = resolve_model_file_path(model_dir, &file.name)
1441 && let Some(state) = capture_file_verification_state(&path)
1442 {
1443 file_states.insert(file.name.clone(), state);
1444 }
1445 }
1446
1447 Self {
1448 manifest_id: manifest.id.clone(),
1449 schema_version: MANIFEST_SCHEMA_VERSION,
1450 verified_at: now,
1451 file_states,
1452 }
1453 }
1454
1455 fn is_valid_for(&self, manifest: &ModelManifest, model_dir: &Path) -> bool {
1462 if self.manifest_id != manifest.id || self.schema_version != MANIFEST_SCHEMA_VERSION {
1463 return false;
1464 }
1465
1466 for file in &manifest.files {
1467 let Some(expected_state) = self.file_states.get(&file.name) else {
1468 return false;
1469 };
1470 let Ok(path) = resolve_model_file_path(model_dir, &file.name) else {
1471 return false;
1472 };
1473 let Some(current_state) = capture_file_verification_state(&path) else {
1474 return false;
1475 };
1476 if current_state != *expected_state {
1477 return false;
1478 }
1479 }
1480
1481 true
1482 }
1483}
1484
1485pub fn write_verification_marker(manifest: &ModelManifest, model_dir: &Path) {
1489 let marker = VerificationMarker::new_for(manifest, model_dir);
1490 let Ok(json) = serde_json::to_string_pretty(&marker) else {
1491 return;
1492 };
1493 let _ = (|| -> std::io::Result<()> {
1494 let mut file = File::create(model_dir.join(VERIFIED_MARKER_FILE))?;
1495 std::io::Write::write_all(&mut file, json.as_bytes())?;
1496 file.sync_all()?;
1497 Ok(())
1498 })();
1499}
1500
1501#[must_use]
1506pub fn is_verification_cached(manifest: &ModelManifest, model_dir: &Path) -> bool {
1507 let path = model_dir.join(VERIFIED_MARKER_FILE);
1508 let Ok(raw) = fs::read_to_string(&path) else {
1509 return false;
1510 };
1511 let Ok(marker) = serde_json::from_str::<VerificationMarker>(&raw) else {
1512 return false;
1513 };
1514 marker.is_valid_for(manifest, model_dir)
1515}
1516
1517pub fn verify_dir_cached(manifest: &ModelManifest, model_dir: &Path) -> SearchResult<()> {
1529 if !manifest.has_verified_checksums() {
1530 return Ok(());
1531 }
1532
1533 if is_verification_cached(manifest, model_dir) {
1534 return Ok(());
1535 }
1536
1537 manifest.verify_dir(model_dir)?;
1538 write_verification_marker(manifest, model_dir);
1539 Ok(())
1540}
1541
1542fn promote_atomically(staged_dir: &Path, destination_dir: &Path) -> SearchResult<Option<PathBuf>> {
1543 let destination_parent =
1544 destination_dir
1545 .parent()
1546 .ok_or_else(|| SearchError::InvalidConfig {
1547 field: "destination_dir".to_owned(),
1548 value: destination_dir.display().to_string(),
1549 reason: "destination must have a parent directory".to_owned(),
1550 })?;
1551 fs::create_dir_all(destination_parent).map_err(SearchError::from)?;
1552
1553 let stage_name = destination_dir.file_name().map_or_else(
1554 || "model".to_owned(),
1555 |part| part.to_string_lossy().into_owned(),
1556 );
1557 let timestamp = SystemTime::now()
1558 .duration_since(UNIX_EPOCH)
1559 .map_or(0, |duration| duration.as_nanos());
1560 let pid = std::process::id();
1561 let stage_target =
1562 destination_parent.join(format!(".{stage_name}.installing.{timestamp}.{pid}"));
1563 fs::rename(staged_dir, &stage_target).map_err(SearchError::from)?;
1564
1565 let backup_path = if destination_dir.exists() {
1566 let backup = destination_parent.join(format!("{stage_name}.backup.{timestamp}.{pid}"));
1567 fs::rename(destination_dir, &backup).map_err(SearchError::from)?;
1568 Some(backup)
1569 } else {
1570 None
1571 };
1572
1573 fs::rename(&stage_target, destination_dir).map_err(SearchError::from)?;
1574 Ok(backup_path)
1575}
1576
1577fn manifest_registry() -> &'static RwLock<BTreeMap<String, ModelManifest>> {
1578 static REGISTRY: OnceLock<RwLock<BTreeMap<String, ModelManifest>>> = OnceLock::new();
1579 REGISTRY.get_or_init(|| {
1580 let catalog = ModelManifest::builtin_catalog();
1581 let mut data = BTreeMap::new();
1582 for manifest in catalog.models {
1583 data.insert(manifest.id.clone(), manifest);
1584 }
1585 RwLock::new(data)
1586 })
1587}
1588
1589fn manifest_registry_lock_error(action: &str) -> SearchError {
1590 SearchError::SubsystemError {
1591 subsystem: "model_manifest",
1592 source: std::io::Error::other(format!("manifest registry {action} lock poisoned")).into(),
1593 }
1594}
1595
1596fn invalid_manifest_field(field: &str, value: &str, reason: &str) -> SearchError {
1597 SearchError::InvalidConfig {
1598 field: field.to_owned(),
1599 value: value.to_owned(),
1600 reason: reason.to_owned(),
1601 }
1602}
1603
1604fn invalid_state_transition(state: &ModelState, operation: &str, reason: &str) -> SearchError {
1605 SearchError::InvalidConfig {
1606 field: "model_state".to_owned(),
1607 value: format!("{state:?}"),
1608 reason: format!("invalid transition for {operation}: {reason}"),
1609 }
1610}
1611
1612fn truncate_for_error(value: &str) -> String {
1613 const MAX: usize = 120;
1614 let mut chars = value.chars();
1615 let truncated: String = chars.by_ref().take(MAX).collect();
1616 if chars.next().is_none() {
1617 return truncated;
1618 }
1619 let mut out = truncated;
1620 out.push_str("...");
1621 out
1622}
1623
1624fn parse_bool_flag(raw: &str) -> Option<bool> {
1625 let value = raw.trim();
1626 if value == "1"
1627 || value.eq_ignore_ascii_case("true")
1628 || value.eq_ignore_ascii_case("yes")
1629 || value.eq_ignore_ascii_case("on")
1630 {
1631 return Some(true);
1632 }
1633 if value == "0"
1634 || value.eq_ignore_ascii_case("false")
1635 || value.eq_ignore_ascii_case("no")
1636 || value.eq_ignore_ascii_case("off")
1637 {
1638 return Some(false);
1639 }
1640 None
1641}
1642
1643fn is_valid_sha256_hex(value: &str) -> bool {
1644 value.len() == 64
1645 && value
1646 .bytes()
1647 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1648}
1649
1650fn to_hex_lowercase(bytes: &[u8]) -> String {
1651 let mut output = String::with_capacity(bytes.len() * 2);
1652 for byte in bytes {
1653 let _ = write!(&mut output, "{byte:02x}");
1654 }
1655 output
1656}
1657
1658#[cfg(test)]
1659mod tests {
1660 use super::*;
1661 use std::io::Write;
1662
1663 fn write_temp_file(path: &Path, bytes: &[u8]) {
1664 let mut file = File::create(path).unwrap();
1665 file.write_all(bytes).unwrap();
1666 file.flush().unwrap();
1667 }
1668
1669 #[test]
1670 fn invalid_manifest_json_returns_clear_error() {
1671 let err = ModelManifest::from_json_str("{not-valid-json]").unwrap_err();
1672 assert!(matches!(err, SearchError::InvalidConfig { .. }));
1673 assert!(err.to_string().contains("manifest JSON"));
1674 }
1675
1676 #[test]
1677 fn valid_manifest_json_round_trips_expected_fields() {
1678 let manifest = ModelManifest::from_json_str(
1679 r#"{
1680 "id":"test-model",
1681 "repo":"acme/test-model",
1682 "revision":"0123456789abcdef0123456789abcdef01234567",
1683 "files":[
1684 {
1685 "name":"model.bin",
1686 "sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1687 "size":42
1688 }
1689 ],
1690 "license":"MIT"
1691 }"#,
1692 )
1693 .unwrap();
1694
1695 assert_eq!(manifest.id, "test-model");
1696 assert_eq!(manifest.repo, "acme/test-model");
1697 assert_eq!(manifest.total_size_bytes(), 42);
1698 assert!(manifest.has_verified_checksums());
1699 assert!(manifest.has_pinned_revision());
1700 assert!(manifest.is_production_ready());
1701 }
1702
1703 #[test]
1704 fn missing_required_manifest_field_surfaces_field_name() {
1705 let err = ModelManifest::from_json_str(
1706 r#"{
1707 "id":"test-model",
1708 "repo":"acme/test-model",
1709 "revision":"0123456789abcdef0123456789abcdef01234567",
1710 "files":[]
1711 }"#,
1712 )
1713 .unwrap_err();
1714
1715 assert!(matches!(err, SearchError::InvalidConfig { .. }));
1716 assert!(err.to_string().contains("license"));
1717 }
1718
1719 #[test]
1720 fn verify_file_sha256_success_wrong_hash_and_truncated() {
1721 let temp = tempfile::tempdir().unwrap();
1722 let path = temp.path().join("model.bin");
1723 let bytes = b"model-bytes";
1724 write_temp_file(&path, bytes);
1725
1726 let expected_hash = to_hex_lowercase(&Sha256::digest(bytes));
1727 let expected_size = u64::try_from(bytes.len()).unwrap();
1728 verify_file_sha256(&path, &expected_hash, expected_size).unwrap();
1729
1730 let wrong_hash = "0000000000000000000000000000000000000000000000000000000000000000";
1731 let err = verify_file_sha256(&path, wrong_hash, expected_size).unwrap_err();
1732 assert!(matches!(err, SearchError::HashMismatch { .. }));
1733
1734 let err = verify_file_sha256(&path, &expected_hash, expected_size + 1).unwrap_err();
1735 assert!(matches!(err, SearchError::HashMismatch { .. }));
1736 }
1737
1738 #[test]
1739 fn verify_file_sha256_rejects_placeholder_invalid_hash_and_missing_file() {
1740 let temp = tempfile::tempdir().unwrap();
1741 let missing_path = temp.path().join("missing.bin");
1742
1743 let err =
1744 verify_file_sha256(&missing_path, PLACEHOLDER_VERIFY_AFTER_DOWNLOAD, 1).unwrap_err();
1745 assert!(matches!(err, SearchError::InvalidConfig { .. }));
1746
1747 let err = verify_file_sha256(&missing_path, "NOT-A-HASH", 1).unwrap_err();
1748 assert!(matches!(err, SearchError::InvalidConfig { .. }));
1749
1750 let valid_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1751 let err = verify_file_sha256(&missing_path, valid_hash, 1).unwrap_err();
1752 assert!(matches!(err, SearchError::ModelNotFound { .. }));
1753 }
1754
1755 #[test]
1756 fn catalog_validate_reports_invalid_nested_manifest() {
1757 let catalog = ModelManifestCatalog::from_json_str(
1758 r#"{
1759 "models":[
1760 {
1761 "id":"bad-model",
1762 "repo":"acme/bad-model",
1763 "revision":"0123456789abcdef0123456789abcdef01234567",
1764 "files":[
1765 {"name":"model.bin","sha256":"bad-hash","size":10}
1766 ],
1767 "license":"MIT"
1768 }
1769 ]
1770 }"#,
1771 )
1772 .unwrap();
1773
1774 let err = catalog.validate().unwrap_err();
1775 assert!(matches!(err, SearchError::InvalidConfig { .. }));
1776 }
1777
1778 #[test]
1779 fn lifecycle_state_machine_success_path() {
1780 let manifest = ModelManifest::potion_128m();
1781 let mut lifecycle = ModelLifecycle::new(
1782 manifest,
1783 DownloadConsent::granted(ConsentSource::Programmatic),
1784 );
1785
1786 assert_eq!(lifecycle.state(), &ModelState::NotInstalled);
1787
1788 lifecycle.begin_download(100).unwrap();
1789 lifecycle.update_download_progress(40).unwrap();
1790 lifecycle.begin_verification().unwrap();
1791 lifecycle.mark_ready();
1792
1793 assert_eq!(lifecycle.state(), &ModelState::Ready);
1794 }
1795
1796 #[test]
1797 fn lifecycle_state_machine_failure_path() {
1798 let manifest = ModelManifest::potion_128m();
1799 let mut lifecycle = ModelLifecycle::new(
1800 manifest,
1801 DownloadConsent::granted(ConsentSource::Programmatic),
1802 );
1803
1804 lifecycle.begin_download(100).unwrap();
1805 lifecycle.fail_verification("checksum mismatch");
1806 assert!(matches!(
1807 lifecycle.state(),
1808 ModelState::VerificationFailed { .. }
1809 ));
1810 }
1811
1812 #[test]
1813 fn download_progress_percent_is_bounded_to_100() {
1814 let manifest = ModelManifest::minilm_v2();
1815 let mut lifecycle = ModelLifecycle::new(
1816 manifest,
1817 DownloadConsent::granted(ConsentSource::Programmatic),
1818 );
1819 lifecycle.begin_download(10).unwrap();
1820 lifecycle.update_download_progress(10_000).unwrap();
1821
1822 let progress_pct = match lifecycle.state() {
1823 ModelState::Downloading { progress_pct, .. } => *progress_pct,
1824 _ => 0,
1825 };
1826 assert!(progress_pct <= 100);
1827 assert_eq!(progress_pct, 100);
1828 }
1829
1830 #[test]
1831 fn placeholder_checksums_are_rejected_in_release_policy_mode() {
1832 let mut manifest = ModelManifest::minilm_v2();
1833 manifest.files[0].sha256 = PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned();
1834 manifest.files[0].size = 0;
1835 manifest.files[0].url = None;
1836 let err = manifest.validate_checksum_policy_for(true).unwrap_err();
1837 assert!(matches!(err, SearchError::InvalidConfig { .. }));
1838 }
1839
1840 #[test]
1841 fn cancelled_state_can_recover() {
1842 let manifest = ModelManifest::potion_128m();
1843 let mut lifecycle = ModelLifecycle::new(
1844 manifest,
1845 DownloadConsent::granted(ConsentSource::Programmatic),
1846 );
1847 lifecycle.begin_download(10).unwrap();
1848 lifecycle.cancel();
1849 lifecycle.recover_after_cancel().unwrap();
1850 assert_eq!(lifecycle.state(), &ModelState::NotInstalled);
1851 }
1852
1853 #[test]
1854 fn empty_manifest_catalog_is_valid() {
1855 let catalog = ModelManifestCatalog::from_json_str(r#"{"models":[]}"#).unwrap();
1856 assert!(catalog.models.is_empty());
1857 catalog.validate().unwrap();
1858 }
1859
1860 #[test]
1861 fn unreadable_model_file_returns_clear_error() {
1862 let temp = tempfile::tempdir().unwrap();
1863 let model_root = temp.path();
1864 let bogus_path = model_root.join("tokenizer.json");
1865 fs::create_dir_all(&bogus_path).unwrap();
1866
1867 let manifest = ModelManifest {
1868 id: "test".to_owned(),
1869 version: "test-v1".to_owned(),
1870 display_name: None,
1871 description: None,
1872 repo: "owner/repo".to_owned(),
1873 revision: "abcdef1".to_owned(),
1874 files: vec![ModelFile {
1875 name: "tokenizer.json".to_owned(),
1876 sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1877 .to_owned(),
1878 size: 1,
1879 url: None,
1880 }],
1881 license: "MIT".to_owned(),
1882 dimension: None,
1883 tier: None,
1884 download_size_bytes: 0,
1885 };
1886
1887 let err = manifest.verify_dir(model_root).unwrap_err();
1888 assert!(matches!(err, SearchError::ModelLoadFailed { .. }));
1889 assert!(err.to_string().contains("regular file"));
1890 }
1891
1892 #[test]
1893 fn verify_dir_rejects_traversal_file_names_without_needing_validate_call() {
1894 let temp = tempfile::tempdir().expect("tempdir");
1895 let manifest = ModelManifest {
1896 id: "test".to_owned(),
1897 version: "test-v1".to_owned(),
1898 display_name: None,
1899 description: None,
1900 repo: "owner/repo".to_owned(),
1901 revision: "abcdef1".to_owned(),
1902 files: vec![ModelFile {
1903 name: "../escape.bin".to_owned(),
1904 sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1905 .to_owned(),
1906 size: 0,
1907 url: None,
1908 }],
1909 license: "MIT".to_owned(),
1910 dimension: None,
1911 tier: None,
1912 download_size_bytes: 0,
1913 };
1914
1915 let err = manifest
1916 .verify_dir(temp.path())
1917 .expect_err("must reject traversal");
1918 assert!(matches!(
1919 err,
1920 SearchError::InvalidConfig { ref field, .. } if field == "files[].name"
1921 ));
1922 assert!(err.to_string().contains("path traversal"));
1923 }
1924
1925 #[test]
1926 fn can_register_and_lookup_custom_manifest() {
1927 let unique_id = format!(
1928 "custom-{}-{}",
1929 std::process::id(),
1930 SystemTime::now()
1931 .duration_since(UNIX_EPOCH)
1932 .unwrap()
1933 .as_nanos()
1934 );
1935 let manifest = ModelManifest {
1936 id: unique_id.clone(),
1937 version: "test-v1".to_owned(),
1938 display_name: None,
1939 description: None,
1940 repo: "acme/custom".to_owned(),
1941 revision: "0123456789abcdef0123456789abcdef01234567".to_owned(),
1942 files: vec![ModelFile {
1943 name: "weights.bin".to_owned(),
1944 sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1945 .to_owned(),
1946 size: 42,
1947 url: None,
1948 }],
1949 license: "MIT".to_owned(),
1950 dimension: None,
1951 tier: None,
1952 download_size_bytes: 0,
1953 };
1954
1955 manifest.clone().register().unwrap();
1956 let loaded = ModelManifest::lookup(&unique_id).unwrap();
1957 assert_eq!(loaded, manifest);
1958 }
1959
1960 #[test]
1961 fn resolve_download_consent_priority_order() {
1962 let consent =
1963 resolve_download_consent_with_env(Some(false), Some("1"), Some(true), Some(true));
1964 assert_eq!(consent.source, Some(ConsentSource::Programmatic));
1965 assert!(!consent.granted);
1966
1967 let consent = resolve_download_consent_with_env(None, Some("1"), Some(false), Some(true));
1968 assert_eq!(consent.source, Some(ConsentSource::Environment));
1969 assert!(consent.granted);
1970
1971 let consent = resolve_download_consent_with_env(None, None, Some(false), Some(true));
1972 assert_eq!(consent.source, Some(ConsentSource::Interactive));
1973 assert!(!consent.granted);
1974 }
1975
1976 #[test]
1979 fn valid_manifest_parses_all_fields() {
1980 let json = r#"{
1981 "id": "test-model",
1982 "repo": "owner/test-model",
1983 "revision": "abc123def456",
1984 "files": [
1985 {"name": "model.onnx", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1024},
1986 {"name": "tokenizer.json", "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "size": 512}
1987 ],
1988 "license": "Apache-2.0"
1989 }"#;
1990 let manifest = ModelManifest::from_json_str(json).unwrap();
1991 assert_eq!(manifest.id, "test-model");
1992 assert_eq!(manifest.repo, "owner/test-model");
1993 assert_eq!(manifest.revision, "abc123def456");
1994 assert_eq!(manifest.files.len(), 2);
1995 assert_eq!(manifest.files[0].name, "model.onnx");
1996 assert_eq!(manifest.files[1].size, 512);
1997 assert_eq!(manifest.license, "Apache-2.0");
1998 }
1999
2000 #[test]
2001 fn missing_id_field_returns_clear_error() {
2002 let json = r#"{"id": "", "repo": "r", "revision": "v", "files": [], "license": "MIT"}"#;
2003 let err = ModelManifest::from_json_str(json).unwrap_err();
2004 assert!(err.to_string().contains("must not be empty"));
2005 }
2006
2007 #[test]
2008 fn missing_repo_field_returns_clear_error() {
2009 let json = r#"{"id": "m", "repo": " ", "revision": "v", "files": [], "license": "MIT"}"#;
2010 let err = ModelManifest::from_json_str(json).unwrap_err();
2011 assert!(err.to_string().contains("must not be empty"));
2012 }
2013
2014 #[test]
2015 fn missing_revision_field_returns_clear_error() {
2016 let json = r#"{"id": "m", "repo": "r", "revision": "", "files": [], "license": "MIT"}"#;
2017 let err = ModelManifest::from_json_str(json).unwrap_err();
2018 assert!(err.to_string().contains("must not be empty"));
2019 }
2020
2021 #[test]
2022 fn missing_license_field_returns_clear_error() {
2023 let json = r#"{"id": "m", "repo": "r", "revision": "v", "files": [], "license": ""}"#;
2024 let err = ModelManifest::from_json_str(json).unwrap_err();
2025 assert!(err.to_string().contains("must not be empty"));
2026 }
2027
2028 #[test]
2029 fn invalid_sha256_format_rejected() {
2030 let json = r#"{
2031 "id": "m", "repo": "r", "revision": "v", "license": "MIT",
2032 "files": [{"name": "f.bin", "sha256": "not-a-valid-hash", "size": 1}]
2033 }"#;
2034 let err = ModelManifest::from_json_str(json).unwrap_err();
2035 assert!(err.to_string().contains("SHA256 hex"));
2036 }
2037
2038 #[test]
2039 fn file_with_zero_size_and_valid_hash_accepted() {
2040 let json = r#"{
2041 "id": "m", "repo": "r", "revision": "v", "license": "MIT",
2042 "files": [{"name": "f.bin", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 0}]
2043 }"#;
2044 let manifest = ModelManifest::from_json_str(json).unwrap();
2045 assert_eq!(manifest.files[0].size, 0);
2046 }
2047
2048 #[test]
2049 fn empty_file_name_rejected() {
2050 let json = r#"{
2051 "id": "m", "repo": "r", "revision": "v", "license": "MIT",
2052 "files": [{"name": "", "sha256": "PLACEHOLDER_VERIFY_AFTER_DOWNLOAD", "size": 0}]
2053 }"#;
2054 let err = ModelManifest::from_json_str(json).unwrap_err();
2055 assert!(err.to_string().contains("must not be empty"));
2056 }
2057
2058 #[test]
2059 fn verify_missing_file_returns_model_not_found() {
2060 let temp = tempfile::tempdir().unwrap();
2061 let path = temp.path().join("does_not_exist.bin");
2062 let hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
2063 let err = verify_file_sha256(&path, hash, 100).unwrap_err();
2064 assert!(matches!(err, SearchError::ModelNotFound { .. }));
2065 }
2066
2067 #[test]
2068 fn verify_placeholder_checksum_rejected() {
2069 let temp = tempfile::tempdir().unwrap();
2070 let path = temp.path().join("file.bin");
2071 write_temp_file(&path, b"data");
2072 let err = verify_file_sha256(&path, PLACEHOLDER_VERIFY_AFTER_DOWNLOAD, 4).unwrap_err();
2073 assert!(matches!(err, SearchError::InvalidConfig { .. }));
2074 assert!(err.to_string().contains("placeholder"));
2075 }
2076
2077 #[test]
2078 fn verify_zero_expected_size_accepts_empty_file() {
2079 let temp = tempfile::tempdir().unwrap();
2080 let path = temp.path().join("empty.bin");
2081 write_temp_file(&path, b"");
2082 let hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
2083 verify_file_sha256(&path, hash, 0).unwrap();
2084 }
2085
2086 #[test]
2087 fn verify_zero_expected_size_still_rejects_non_empty_file() {
2088 let temp = tempfile::tempdir().unwrap();
2089 let path = temp.path().join("file.bin");
2090 write_temp_file(&path, b"data");
2091 let hash = to_hex_lowercase(&Sha256::digest(b"data"));
2092 let err = verify_file_sha256(&path, &hash, 0).unwrap_err();
2093 assert!(matches!(err, SearchError::HashMismatch { .. }));
2094 }
2095
2096 #[test]
2097 fn to_pretty_json_roundtrip() {
2098 let manifest = ModelManifest::potion_128m();
2099 let json = manifest.to_pretty_json().unwrap();
2100 let restored = ModelManifest::from_json_str(&json).unwrap();
2101 assert_eq!(restored.id, manifest.id);
2102 assert_eq!(restored.files.len(), manifest.files.len());
2103 }
2104
2105 #[test]
2106 fn builtin_manifests_validate() {
2107 ModelManifest::minilm_v2().validate().unwrap();
2108 ModelManifest::potion_128m().validate().unwrap();
2109 }
2110
2111 #[test]
2112 fn builtin_manifests_are_production_ready() {
2113 assert!(ModelManifest::minilm_v2().is_production_ready());
2114 assert!(ModelManifest::potion_128m().is_production_ready());
2115 assert!(ModelManifest::ms_marco_reranker().is_production_ready());
2116 }
2117
2118 #[test]
2119 fn has_pinned_revision_rejects_floating_aliases() {
2120 for alias in &[
2121 "main",
2122 "master",
2123 "latest",
2124 "HEAD",
2125 PLACEHOLDER_PINNED_REVISION,
2126 ] {
2127 let m = ModelManifest {
2128 revision: alias.to_string(),
2129 ..ModelManifest::potion_128m()
2130 };
2131 assert!(
2132 !m.has_pinned_revision(),
2133 "'{alias}' should not be considered pinned"
2134 );
2135 }
2136 }
2137
2138 #[test]
2139 fn has_pinned_revision_accepts_commit_sha() {
2140 let m = ModelManifest {
2141 revision: "0123456789abcdef0123456789abcdef01234567".to_owned(),
2142 ..ModelManifest::potion_128m()
2143 };
2144 assert!(m.has_pinned_revision());
2145 }
2146
2147 #[test]
2148 fn total_size_bytes_sums_all_files() {
2149 let m = ModelManifest {
2150 files: vec![
2151 ModelFile {
2152 name: "a".to_owned(),
2153 sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2154 size: 100,
2155 url: None,
2156 },
2157 ModelFile {
2158 name: "b".to_owned(),
2159 sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2160 size: 200,
2161 url: None,
2162 },
2163 ],
2164 download_size_bytes: 0,
2165 ..ModelManifest::potion_128m()
2166 };
2167 assert_eq!(m.total_size_bytes(), 300);
2168 }
2169
2170 #[test]
2171 fn model_state_serde_roundtrip() {
2172 let states = vec![
2173 ModelState::NotInstalled,
2174 ModelState::NeedsConsent,
2175 ModelState::Downloading {
2176 progress_pct: 50,
2177 bytes_downloaded: 1000,
2178 total_bytes: 2000,
2179 },
2180 ModelState::Verifying,
2181 ModelState::Ready,
2182 ModelState::Disabled {
2183 reason: "out of disk".to_owned(),
2184 },
2185 ModelState::VerificationFailed {
2186 reason: "hash mismatch".to_owned(),
2187 },
2188 ModelState::UpdateAvailable {
2189 current_revision: "old".to_owned(),
2190 latest_revision: "new".to_owned(),
2191 },
2192 ModelState::Cancelled,
2193 ];
2194 for state in &states {
2195 let json = serde_json::to_string(state).unwrap();
2196 let decoded: ModelState = serde_json::from_str(&json).unwrap();
2197 assert_eq!(&decoded, state);
2198 }
2199 }
2200
2201 #[test]
2202 fn consent_source_serde_roundtrip() {
2203 for source in &[
2204 ConsentSource::Programmatic,
2205 ConsentSource::Environment,
2206 ConsentSource::Interactive,
2207 ConsentSource::ConfigFile,
2208 ] {
2209 let json = serde_json::to_string(source).unwrap();
2210 let decoded: ConsentSource = serde_json::from_str(&json).unwrap();
2211 assert_eq!(&decoded, source);
2212 }
2213 }
2214
2215 #[test]
2216 fn lifecycle_needs_consent_when_not_granted() {
2217 let manifest = ModelManifest::potion_128m();
2218 let lifecycle = ModelLifecycle::new(manifest, DownloadConsent::denied(None));
2219 assert_eq!(lifecycle.state(), &ModelState::NeedsConsent);
2220 }
2221
2222 #[test]
2223 fn lifecycle_begin_download_without_consent_fails() {
2224 let manifest = ModelManifest::potion_128m();
2225 let mut lifecycle = ModelLifecycle::new(manifest, DownloadConsent::denied(None));
2226 let err = lifecycle.begin_download(100).unwrap_err();
2227 assert!(matches!(err, SearchError::EmbedderUnavailable { .. }));
2228 }
2229
2230 #[test]
2231 fn lifecycle_begin_download_zero_bytes_fails() {
2232 let manifest = ModelManifest::potion_128m();
2233 let mut lifecycle = ModelLifecycle::new(
2234 manifest,
2235 DownloadConsent::granted(ConsentSource::Programmatic),
2236 );
2237 let err = lifecycle.begin_download(0).unwrap_err();
2238 assert!(matches!(err, SearchError::InvalidConfig { .. }));
2239 }
2240
2241 #[test]
2242 fn lifecycle_approve_consent_transitions() {
2243 let manifest = ModelManifest::potion_128m();
2244 let mut lifecycle = ModelLifecycle::new(manifest, DownloadConsent::denied(None));
2245 assert_eq!(lifecycle.state(), &ModelState::NeedsConsent);
2246
2247 lifecycle.approve_consent(ConsentSource::Interactive);
2248 assert_eq!(lifecycle.state(), &ModelState::NotInstalled);
2249 }
2250
2251 #[test]
2252 fn lifecycle_disable_and_update() {
2253 let manifest = ModelManifest::potion_128m();
2254 let mut lifecycle = ModelLifecycle::new(
2255 manifest,
2256 DownloadConsent::granted(ConsentSource::Programmatic),
2257 );
2258
2259 lifecycle.disable("maintenance");
2260 assert!(matches!(lifecycle.state(), ModelState::Disabled { .. }));
2261
2262 lifecycle.mark_update_available("v1", "v2");
2263 assert!(matches!(
2264 lifecycle.state(),
2265 ModelState::UpdateAvailable { .. }
2266 ));
2267 }
2268
2269 #[test]
2270 fn lifecycle_recovery_from_non_cancelled_fails() {
2271 let manifest = ModelManifest::potion_128m();
2272 let mut lifecycle = ModelLifecycle::new(
2273 manifest,
2274 DownloadConsent::granted(ConsentSource::Programmatic),
2275 );
2276 let err = lifecycle.recover_after_cancel().unwrap_err();
2277 assert!(matches!(err, SearchError::InvalidConfig { .. }));
2278 }
2279
2280 #[test]
2281 fn lifecycle_begin_verification_from_not_downloading_fails() {
2282 let manifest = ModelManifest::potion_128m();
2283 let mut lifecycle = ModelLifecycle::new(
2284 manifest,
2285 DownloadConsent::granted(ConsentSource::Programmatic),
2286 );
2287 let err = lifecycle.begin_verification().unwrap_err();
2288 assert!(matches!(err, SearchError::InvalidConfig { .. }));
2289 }
2290
2291 #[test]
2292 fn lifecycle_update_progress_from_not_downloading_fails() {
2293 let manifest = ModelManifest::potion_128m();
2294 let mut lifecycle = ModelLifecycle::new(
2295 manifest,
2296 DownloadConsent::granted(ConsentSource::Programmatic),
2297 );
2298 let err = lifecycle.update_download_progress(50).unwrap_err();
2299 assert!(matches!(err, SearchError::InvalidConfig { .. }));
2300 }
2301
2302 #[test]
2303 fn detect_update_state_same_revision_returns_none() {
2304 let m = ModelManifest {
2305 revision: "abc123".to_owned(),
2306 ..ModelManifest::potion_128m()
2307 };
2308 assert!(m.detect_update_state("abc123").is_none());
2309 }
2310
2311 #[test]
2312 fn detect_update_state_different_revision_returns_update() {
2313 let m = ModelManifest {
2314 revision: "new_rev".to_owned(),
2315 ..ModelManifest::potion_128m()
2316 };
2317 let state = m.detect_update_state("old_rev").unwrap();
2318 assert!(matches!(state, ModelState::UpdateAvailable { .. }));
2319 }
2320
2321 #[test]
2322 fn detect_update_state_unpinned_returns_none() {
2323 let manifest = ModelManifest {
2324 revision: PLACEHOLDER_PINNED_REVISION.to_owned(),
2325 ..ModelManifest::potion_128m()
2326 };
2327 assert!(manifest.detect_update_state("anything").is_none());
2328 }
2329
2330 #[test]
2331 fn resolve_consent_config_file_path() {
2332 let consent = resolve_download_consent_with_env(None, None, None, Some(true));
2333 assert_eq!(consent.source, Some(ConsentSource::ConfigFile));
2334 assert!(consent.granted);
2335 }
2336
2337 #[test]
2338 fn resolve_consent_no_source_denies() {
2339 let consent = resolve_download_consent_with_env(None, None, None, None);
2340 assert!(!consent.granted);
2341 assert!(consent.source.is_none());
2342 }
2343
2344 #[test]
2345 fn resolve_consent_env_values() {
2346 for (val, expected) in &[
2347 ("1", true),
2348 ("true", true),
2349 ("yes", true),
2350 ("on", true),
2351 ("0", false),
2352 ("false", false),
2353 ("no", false),
2354 ("off", false),
2355 ] {
2356 let consent = resolve_download_consent_with_env(None, Some(val), None, None);
2357 assert_eq!(consent.granted, *expected, "env={val}");
2358 }
2359 }
2360
2361 #[test]
2362 fn resolve_consent_invalid_env_skipped() {
2363 let consent = resolve_download_consent_with_env(None, Some("maybe"), Some(true), None);
2364 assert_eq!(consent.source, Some(ConsentSource::Interactive));
2365 assert!(consent.granted);
2366 }
2367
2368 #[test]
2369 fn model_file_placeholder_detection() {
2370 let file = ModelFile {
2371 name: "f.bin".to_owned(),
2372 sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2373 size: 0,
2374 url: None,
2375 };
2376 assert!(file.uses_placeholder_checksum());
2377 assert!(!file.has_verified_checksum());
2378 }
2379
2380 #[test]
2381 fn model_file_verified_checksum_detection() {
2382 let file = ModelFile {
2383 name: "f.bin".to_owned(),
2384 sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(),
2385 size: 42,
2386 url: None,
2387 };
2388 assert!(!file.uses_placeholder_checksum());
2389 assert!(file.has_verified_checksum());
2390 }
2391
2392 #[test]
2393 fn model_file_zero_byte_verified_checksum_detection() {
2394 let file = ModelFile {
2395 name: "empty.bin".to_owned(),
2396 sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_owned(),
2397 size: 0,
2398 url: None,
2399 };
2400 assert!(!file.uses_placeholder_checksum());
2401 assert!(file.has_verified_checksum());
2402 }
2403
2404 #[test]
2405 fn promote_verified_installation_success() {
2406 let temp = tempfile::tempdir().unwrap();
2407 let staged = temp.path().join("staged");
2408 let dest = temp.path().join("final");
2409 fs::create_dir_all(&staged).unwrap();
2410
2411 let data = b"model data";
2412 write_temp_file(&staged.join("model.bin"), data);
2413 let hash = to_hex_lowercase(&Sha256::digest(data));
2414 let size = u64::try_from(data.len()).unwrap();
2415
2416 let manifest = ModelManifest {
2417 id: "test".to_owned(),
2418 version: "test-v1".to_owned(),
2419 display_name: None,
2420 description: None,
2421 repo: "owner/repo".to_owned(),
2422 revision: "abc".to_owned(),
2423 files: vec![ModelFile {
2424 name: "model.bin".to_owned(),
2425 sha256: hash,
2426 size,
2427 url: None,
2428 }],
2429 license: "MIT".to_owned(),
2430 dimension: None,
2431 tier: None,
2432 download_size_bytes: 0,
2433 };
2434
2435 let backup = manifest
2436 .promote_verified_installation(&staged, &dest)
2437 .unwrap();
2438 assert!(backup.is_none());
2439 assert!(dest.join("model.bin").exists());
2440 }
2441
2442 #[test]
2443 fn promote_verified_creates_backup_of_existing() {
2444 let temp = tempfile::tempdir().unwrap();
2445 let staged = temp.path().join("staged");
2446 let dest = temp.path().join("final");
2447 fs::create_dir_all(&staged).unwrap();
2448 fs::create_dir_all(&dest).unwrap();
2449 write_temp_file(&dest.join("old.bin"), b"old");
2450
2451 let data = b"new model";
2452 write_temp_file(&staged.join("model.bin"), data);
2453 let hash = to_hex_lowercase(&Sha256::digest(data));
2454 let size = u64::try_from(data.len()).unwrap();
2455
2456 let manifest = ModelManifest {
2457 id: "test".to_owned(),
2458 version: "test-v1".to_owned(),
2459 display_name: None,
2460 description: None,
2461 repo: "owner/repo".to_owned(),
2462 revision: "abc".to_owned(),
2463 files: vec![ModelFile {
2464 name: "model.bin".to_owned(),
2465 sha256: hash,
2466 size,
2467 url: None,
2468 }],
2469 license: "MIT".to_owned(),
2470 dimension: None,
2471 tier: None,
2472 download_size_bytes: 0,
2473 };
2474
2475 let backup = manifest
2476 .promote_verified_installation(&staged, &dest)
2477 .unwrap();
2478 assert!(backup.is_some());
2479 assert!(dest.join("model.bin").exists());
2480 }
2481
2482 #[test]
2483 fn manifest_catalog_with_multiple_models() {
2484 let json = r#"{"models": [
2485 {"id": "m1", "repo": "r1", "revision": "v1", "files": [], "license": "MIT"},
2486 {"id": "m2", "repo": "r2", "revision": "v2", "files": [], "license": "Apache-2.0"}
2487 ]}"#;
2488 let catalog = ModelManifestCatalog::from_json_str(json).unwrap();
2489 assert_eq!(catalog.models.len(), 2);
2490 catalog.validate().unwrap();
2491 }
2492
2493 #[test]
2494 fn manifest_catalog_invalid_model_fails_validation() {
2495 let json = r#"{"models": [
2496 {"id": "", "repo": "r", "revision": "v", "files": [], "license": "MIT"}
2497 ]}"#;
2498 let catalog = ModelManifestCatalog::from_json_str(json).unwrap();
2499 let err = catalog.validate().unwrap_err();
2500 assert!(err.to_string().contains("must not be empty"));
2501 }
2502
2503 #[test]
2504 fn is_valid_sha256_hex_checks() {
2505 assert!(is_valid_sha256_hex(
2506 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
2507 ));
2508 assert!(!is_valid_sha256_hex("short"));
2509 assert!(!is_valid_sha256_hex(
2511 "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
2512 ));
2513 assert!(!is_valid_sha256_hex(
2515 "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg"
2516 ));
2517 }
2518
2519 #[test]
2520 fn download_consent_constructors() {
2521 let granted = DownloadConsent::granted(ConsentSource::Programmatic);
2522 assert!(granted.granted);
2523 assert_eq!(granted.source, Some(ConsentSource::Programmatic));
2524
2525 let denied = DownloadConsent::denied(Some(ConsentSource::Environment));
2526 assert!(!denied.granted);
2527 assert_eq!(denied.source, Some(ConsentSource::Environment));
2528
2529 let denied_none = DownloadConsent::denied(None);
2530 assert!(!denied_none.granted);
2531 assert!(denied_none.source.is_none());
2532 }
2533
2534 #[test]
2535 fn lifecycle_can_restart_after_verification_failure() {
2536 let manifest = ModelManifest::potion_128m();
2537 let mut lifecycle = ModelLifecycle::new(
2538 manifest,
2539 DownloadConsent::granted(ConsentSource::Programmatic),
2540 );
2541
2542 lifecycle.begin_download(100).unwrap();
2543 lifecycle.fail_verification("bad hash");
2544 assert!(matches!(
2545 lifecycle.state(),
2546 ModelState::VerificationFailed { .. }
2547 ));
2548
2549 lifecycle.begin_download(100).unwrap();
2550 assert!(matches!(lifecycle.state(), ModelState::Downloading { .. }));
2551 }
2552
2553 #[test]
2554 fn lifecycle_double_begin_download_from_ready_fails() {
2555 let manifest = ModelManifest::potion_128m();
2556 let mut lifecycle = ModelLifecycle::new(
2557 manifest,
2558 DownloadConsent::granted(ConsentSource::Programmatic),
2559 );
2560
2561 lifecycle.begin_download(100).unwrap();
2562 lifecycle.begin_verification().unwrap();
2563 lifecycle.mark_ready();
2564
2565 let err = lifecycle.begin_download(200).unwrap_err();
2566 assert!(matches!(err, SearchError::InvalidConfig { .. }));
2567 }
2568
2569 #[test]
2570 fn truncate_for_error_short_passthrough() {
2571 let short = "hello world";
2572 assert_eq!(truncate_for_error(short), "hello world");
2573 }
2574
2575 #[test]
2576 fn truncate_for_error_long_truncated() {
2577 let long = "x".repeat(200);
2578 let result = truncate_for_error(&long);
2579 assert!(result.ends_with("..."));
2580 assert!(result.len() < 200);
2581 }
2582
2583 #[test]
2586 fn manifest_schema_version_is_two() {
2587 assert_eq!(MANIFEST_SCHEMA_VERSION, 2);
2588 }
2589
2590 #[test]
2591 fn model_tier_serde_roundtrip() {
2592 for tier in &[ModelTier::Fast, ModelTier::Quality, ModelTier::Reranker] {
2593 let json = serde_json::to_string(tier).unwrap();
2594 let decoded: ModelTier = serde_json::from_str(&json).unwrap();
2595 assert_eq!(&decoded, tier);
2596 }
2597 }
2598
2599 #[test]
2600 fn model_tier_serde_uses_snake_case() {
2601 assert_eq!(serde_json::to_string(&ModelTier::Fast).unwrap(), "\"fast\"");
2602 assert_eq!(
2603 serde_json::to_string(&ModelTier::Quality).unwrap(),
2604 "\"quality\""
2605 );
2606 assert_eq!(
2607 serde_json::to_string(&ModelTier::Reranker).unwrap(),
2608 "\"reranker\""
2609 );
2610 }
2611
2612 #[test]
2613 fn builtin_potion_has_correct_metadata() {
2614 let m = ModelManifest::potion_128m();
2615 assert_eq!(m.dimension, Some(256));
2616 assert_eq!(m.tier, Some(ModelTier::Fast));
2617 assert!(m.display_name.is_some());
2618 assert!(m.display_name.as_deref().unwrap().contains("fast"));
2619 }
2620
2621 #[test]
2622 fn builtin_minilm_has_correct_metadata() {
2623 let m = ModelManifest::minilm_v2();
2624 assert_eq!(m.dimension, Some(384));
2625 assert_eq!(m.tier, Some(ModelTier::Quality));
2626 assert!(m.display_name.is_some());
2627 assert!(m.display_name.as_deref().unwrap().contains("quality"));
2628 }
2629
2630 #[test]
2631 fn builtin_reranker_has_correct_metadata() {
2632 let m = ModelManifest::ms_marco_reranker();
2633 assert_eq!(m.id, "ms-marco-minilm-l-6-v2");
2634 assert_eq!(m.dimension, None); assert_eq!(m.tier, Some(ModelTier::Reranker));
2636 assert!(m.display_name.is_some());
2637 assert!(m.display_name.as_deref().unwrap().contains("reranker"));
2638 m.validate().unwrap();
2639 }
2640
2641 #[test]
2642 fn builtin_catalog_contains_all_models() {
2643 let catalog = ModelManifest::builtin_catalog();
2644 assert_eq!(catalog.schema_version, MANIFEST_SCHEMA_VERSION);
2645 assert_eq!(catalog.models.len(), 7);
2646
2647 let ids: Vec<&str> = catalog.models.iter().map(|m| m.id.as_str()).collect();
2648 assert!(ids.contains(&"potion-multilingual-128m"));
2649 assert!(ids.contains(&"all-minilm-l6-v2"));
2650 assert!(ids.contains(&"ms-marco-minilm-l-6-v2"));
2651 assert!(ids.contains(&"snowflake-arctic-embed-s"));
2652 assert!(ids.contains(&"nomic-embed-text-v1.5"));
2653 assert!(ids.contains(&"jina-reranker-v1-turbo-en"));
2654 assert!(ids.contains(&"flashrank-nano"));
2655
2656 catalog.validate().unwrap();
2657 }
2658
2659 #[test]
2660 fn builtin_catalog_covers_all_tiers() {
2661 let catalog = ModelManifest::builtin_catalog();
2662 let tiers: Vec<Option<ModelTier>> = catalog.models.iter().map(|m| m.tier).collect();
2663 assert!(tiers.contains(&Some(ModelTier::Fast)));
2664 assert!(tiers.contains(&Some(ModelTier::Quality)));
2665 assert!(tiers.contains(&Some(ModelTier::Reranker)));
2666 }
2667
2668 #[test]
2669 fn builtin_manifests_include_version_description_and_size_metadata() {
2670 let manifests = [
2671 ModelManifest::potion_128m(),
2672 ModelManifest::minilm_v2(),
2673 ModelManifest::ms_marco_reranker(),
2674 ModelManifest::snowflake_arctic_s(),
2675 ModelManifest::nomic_embed(),
2676 ModelManifest::jina_reranker_turbo(),
2677 ModelManifest::flashrank_nano(),
2678 ];
2679
2680 for manifest in manifests {
2681 assert!(!manifest.version.is_empty());
2682 assert!(manifest.description.is_some());
2683 let all_placeholder = manifest
2689 .files
2690 .iter()
2691 .all(|file| file.sha256 == PLACEHOLDER_VERIFY_AFTER_DOWNLOAD);
2692 if !all_placeholder {
2693 assert!(manifest.download_size_bytes > 0);
2694 }
2695 let summed_size: u64 = manifest.files.iter().map(|file| file.size).sum();
2696 assert_eq!(manifest.download_size_bytes, summed_size);
2697 }
2698 }
2699
2700 #[test]
2701 fn model_file_download_url_uses_explicit_when_present() {
2702 let file = ModelFile {
2703 name: "model.onnx".to_owned(),
2704 sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2705 size: 0,
2706 url: Some("https://mirror.example.com/model.onnx".to_owned()),
2707 };
2708 let url = file.download_url("owner/repo", "abc123");
2709 assert_eq!(url, "https://mirror.example.com/model.onnx");
2710 }
2711
2712 #[test]
2713 fn model_file_download_url_derives_from_repo_when_none() {
2714 let file = ModelFile {
2715 name: "onnx/model.onnx".to_owned(),
2716 sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2717 size: 0,
2718 url: None,
2719 };
2720 let url = file.download_url("sentence-transformers/all-MiniLM-L6-v2", "abc123");
2721 assert_eq!(
2722 url,
2723 "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/abc123/onnx/model.onnx"
2724 );
2725 }
2726
2727 #[test]
2728 fn model_file_url_field_is_optional_in_json() {
2729 let json = r#"{"name":"f.bin","sha256":"PLACEHOLDER_VERIFY_AFTER_DOWNLOAD","size":0}"#;
2731 let file: ModelFile = serde_json::from_str(json).unwrap();
2732 assert!(file.url.is_none());
2733
2734 let json = r#"{"name":"f.bin","sha256":"PLACEHOLDER_VERIFY_AFTER_DOWNLOAD","size":0,"url":"https://example.com/f.bin"}"#;
2736 let file: ModelFile = serde_json::from_str(json).unwrap();
2737 assert_eq!(file.url.as_deref(), Some("https://example.com/f.bin"));
2738 }
2739
2740 #[test]
2741 fn model_file_url_skipped_in_serialization_when_none() {
2742 let file = ModelFile {
2743 name: "f.bin".to_owned(),
2744 sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2745 size: 0,
2746 url: None,
2747 };
2748 let json = serde_json::to_string(&file).unwrap();
2749 assert!(!json.contains("url"));
2750 }
2751
2752 #[test]
2753 fn manifest_display_name_optional_in_json() {
2754 let json = r#"{
2755 "id": "test", "repo": "r", "revision": "v",
2756 "files": [], "license": "MIT"
2757 }"#;
2758 let m: ModelManifest = serde_json::from_str(json).unwrap();
2759 assert!(m.display_name.is_none());
2760 assert!(m.dimension.is_none());
2761 assert!(m.tier.is_none());
2762 }
2763
2764 #[test]
2765 fn manifest_with_all_new_fields_roundtrips() {
2766 let m = ModelManifest {
2767 id: "test".to_owned(),
2768 version: "test-v1".to_owned(),
2769 display_name: Some("Test Model".to_owned()),
2770 description: Some("test manifest".to_owned()),
2771 repo: "owner/repo".to_owned(),
2772 revision: "abc123".to_owned(),
2773 files: vec![ModelFile {
2774 name: "model.onnx".to_owned(),
2775 sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2776 size: 0,
2777 url: Some("https://example.com/model.onnx".to_owned()),
2778 }],
2779 license: "MIT".to_owned(),
2780 dimension: Some(384),
2781 tier: Some(ModelTier::Quality),
2782 download_size_bytes: 0,
2783 };
2784 let json = m.to_pretty_json().unwrap();
2785 let restored = ModelManifest::from_json_str(&json).unwrap();
2786 assert_eq!(restored.display_name, m.display_name);
2787 assert_eq!(restored.dimension, m.dimension);
2788 assert_eq!(restored.tier, m.tier);
2789 assert_eq!(restored.files[0].url, m.files[0].url);
2790 }
2791
2792 #[test]
2793 fn catalog_schema_version_defaults_on_missing() {
2794 let json = r#"{"models":[]}"#;
2795 let catalog = ModelManifestCatalog::from_json_str(json).unwrap();
2796 assert_eq!(catalog.schema_version, MANIFEST_SCHEMA_VERSION);
2797 }
2798
2799 #[test]
2800 fn catalog_schema_version_preserved_from_json() {
2801 let json = r#"{"schema_version": 42, "models":[]}"#;
2802 let catalog = ModelManifestCatalog::from_json_str(json).unwrap();
2803 assert_eq!(catalog.schema_version, 42);
2804 }
2805
2806 #[test]
2807 fn builtin_catalog_json_roundtrip() {
2808 let catalog = ModelManifest::builtin_catalog();
2809 let json = serde_json::to_string_pretty(&catalog).unwrap();
2810 let restored = ModelManifestCatalog::from_json_str(&json).unwrap();
2811 assert_eq!(restored.schema_version, catalog.schema_version);
2812 assert_eq!(restored.models.len(), catalog.models.len());
2813 for (orig, rest) in catalog.models.iter().zip(restored.models.iter()) {
2814 assert_eq!(orig.id, rest.id);
2815 assert_eq!(orig.dimension, rest.dimension);
2816 assert_eq!(orig.tier, rest.tier);
2817 }
2818 }
2819
2820 #[test]
2821 fn registry_includes_reranker() {
2822 let all = ModelManifest::registered();
2823 let ids: Vec<&str> = all.iter().map(|m| m.id.as_str()).collect();
2824 assert!(
2825 ids.contains(&"ms-marco-minilm-l-6-v2"),
2826 "registry should contain ms-marco reranker, got: {ids:?}"
2827 );
2828 }
2829
2830 fn make_test_manifest(file_name: &str, content: &[u8]) -> ModelManifest {
2833 use sha2::{Digest, Sha256};
2834 let mut hasher = Sha256::new();
2835 hasher.update(content);
2836 let sha = to_hex_lowercase(&hasher.finalize());
2837 ModelManifest {
2838 id: "test-model".to_owned(),
2839 repo: "test/repo".to_owned(),
2840 revision: "abc".to_owned(),
2841 files: vec![ModelFile {
2842 name: file_name.to_owned(),
2843 sha256: sha,
2844 size: u64::try_from(content.len()).unwrap(),
2845 url: None,
2846 }],
2847 license: "MIT".to_owned(),
2848 tier: None,
2849 dimension: None,
2850 display_name: None,
2851 version: String::new(),
2852 description: None,
2853 download_size_bytes: u64::try_from(content.len()).unwrap(),
2854 }
2855 }
2856
2857 #[test]
2858 fn verification_marker_roundtrip() {
2859 let tmp = tempfile::tempdir().unwrap();
2860 let content = b"hello model";
2861 let manifest = make_test_manifest("model.bin", content);
2862 write_temp_file(&tmp.path().join("model.bin"), content);
2863
2864 let marker = VerificationMarker::new_for(&manifest, tmp.path());
2865 let json = serde_json::to_string_pretty(&marker).unwrap();
2866 let restored: VerificationMarker = serde_json::from_str(&json).unwrap();
2867 assert_eq!(restored.manifest_id, "test-model");
2868 assert_eq!(restored.schema_version, MANIFEST_SCHEMA_VERSION);
2869 let state = restored.file_states.get("model.bin").unwrap();
2870 assert_eq!(state.size_bytes, u64::try_from(content.len()).unwrap());
2871 assert!(state.modified_unix_nanos > 0);
2872 }
2873
2874 #[test]
2875 fn verification_cache_hit_when_files_unchanged() {
2876 let tmp = tempfile::tempdir().unwrap();
2877 let content = b"model data";
2878 let manifest = make_test_manifest("model.bin", content);
2879 write_temp_file(&tmp.path().join("model.bin"), content);
2880
2881 assert!(!is_verification_cached(&manifest, tmp.path()));
2882 write_verification_marker(&manifest, tmp.path());
2883 assert!(is_verification_cached(&manifest, tmp.path()));
2884 }
2885
2886 #[test]
2887 fn verification_cache_miss_when_manifest_id_changes() {
2888 let tmp = tempfile::tempdir().unwrap();
2889 let content = b"model data";
2890 let manifest = make_test_manifest("model.bin", content);
2891 write_temp_file(&tmp.path().join("model.bin"), content);
2892
2893 write_verification_marker(&manifest, tmp.path());
2894 let mut changed = manifest;
2895 changed.id = "different-model".to_owned();
2896 assert!(!is_verification_cached(&changed, tmp.path()));
2897 }
2898
2899 #[test]
2900 fn verification_cache_miss_when_file_state_differs() {
2901 let tmp = tempfile::tempdir().unwrap();
2902 let content = b"model data";
2903 let manifest = make_test_manifest("model.bin", content);
2904 write_temp_file(&tmp.path().join("model.bin"), content);
2905
2906 write_verification_marker(&manifest, tmp.path());
2908 assert!(is_verification_cached(&manifest, tmp.path()));
2909
2910 let marker_path = tmp.path().join(VERIFIED_MARKER_FILE);
2911 let raw = std::fs::read_to_string(&marker_path).unwrap();
2912 let mut marker: VerificationMarker = serde_json::from_str(&raw).unwrap();
2913 marker.file_states.insert(
2915 "model.bin".to_owned(),
2916 FileVerificationState {
2917 size_bytes: 1,
2918 modified_unix_nanos: 1,
2919 },
2920 );
2921 let tampered = serde_json::to_string_pretty(&marker).unwrap();
2922 std::fs::write(&marker_path, tampered).unwrap();
2923
2924 assert!(!is_verification_cached(&manifest, tmp.path()));
2925 }
2926
2927 #[test]
2928 fn verify_dir_cached_writes_marker_on_success() {
2929 let tmp = tempfile::tempdir().unwrap();
2930 let content = b"model data for verify";
2931 let manifest = make_test_manifest("model.bin", content);
2932 write_temp_file(&tmp.path().join("model.bin"), content);
2933
2934 assert!(!tmp.path().join(VERIFIED_MARKER_FILE).exists());
2935 verify_dir_cached(&manifest, tmp.path()).unwrap();
2936 assert!(tmp.path().join(VERIFIED_MARKER_FILE).exists());
2937 assert!(is_verification_cached(&manifest, tmp.path()));
2938 }
2939
2940 #[test]
2941 fn verify_dir_cached_skips_rehash_on_cached_hit() {
2942 let tmp = tempfile::tempdir().unwrap();
2943 let content = b"model data cached";
2944 let manifest = make_test_manifest("model.bin", content);
2945 write_temp_file(&tmp.path().join("model.bin"), content);
2946
2947 verify_dir_cached(&manifest, tmp.path()).unwrap();
2949
2950 verify_dir_cached(&manifest, tmp.path()).unwrap();
2952 }
2953
2954 #[test]
2955 fn verify_dir_cached_skips_when_no_verified_checksums() {
2956 let tmp = tempfile::tempdir().unwrap();
2957 let manifest = ModelManifest {
2958 id: "test".to_owned(),
2959 repo: "r".to_owned(),
2960 revision: "v".to_owned(),
2961 files: vec![ModelFile {
2962 name: "f.bin".to_owned(),
2963 sha256: PLACEHOLDER_VERIFY_AFTER_DOWNLOAD.to_owned(),
2964 size: 0,
2965 url: None,
2966 }],
2967 license: "MIT".to_owned(),
2968 tier: None,
2969 dimension: None,
2970 display_name: None,
2971 version: String::new(),
2972 description: None,
2973 download_size_bytes: 0,
2974 };
2975 verify_dir_cached(&manifest, tmp.path()).unwrap();
2977 }
2978}