1pub mod adapter;
5pub mod download;
6pub mod manifest;
7pub mod metadata;
8pub mod verify;
9pub use adapter::{AdapterError, AdapterFactory, AdapterRegistry, AdapterStage, BuiltinAdapter};
10pub use download::{
11 DownloadError, download_with_checksum, download_with_checksum_and_signature, verify_sha256,
12};
13pub use manifest::{
14 Manifest, ManifestError, ModelEntry, ProfileEntry, SCHEMA_V1, SCHEMA_V2, is_supported_schema,
15};
16pub use metadata::{MetaSource, ModelConfigMeta, load_model_config, read_onnx_metadata_props};
17
18use crate::types::Profile;
19use std::path::{Path, PathBuf};
20use std::sync::OnceLock;
21
22pub const DEFAULT_MANIFEST_TOML: &str = include_str!("manifest.toml");
24
25#[derive(Clone, Copy, Debug)]
31pub struct VbxPldaArtifact {
32 pub id: &'static str,
33 pub filename: &'static str,
34 pub sha256: &'static str,
35 pub size: u64,
36}
37
38pub const VBX_PLDA_MODEL_IDS: &[&str] = &[
43 "vbx_plda_transform",
44 "vbx_plda_phi_computed",
45 "vbx_plda_mean1",
46 "vbx_plda_mean2",
47 "vbx_plda_lda",
48 "vbx_plda_mu",
49];
50
51#[allow(clippy::panic)] pub fn vbx_plda_artifacts() -> &'static [VbxPldaArtifact; 6] {
62 static TABLE: OnceLock<[VbxPldaArtifact; 6]> = OnceLock::new();
63 TABLE.get_or_init(|| {
64 let manifest = default_manifest();
65 std::array::from_fn(|i| {
66 let id = VBX_PLDA_MODEL_IDS[i];
67 let entry = manifest.model(id).unwrap_or_else(|| {
72 panic!("embedded manifest is missing VBx PLDA entry '{id}' — static-asset bug")
73 });
74 VbxPldaArtifact {
75 id,
76 filename: leak_manifest_string(&entry.filename),
77 sha256: leak_manifest_string(&entry.sha256),
78 size: entry.size.unwrap_or_else(|| {
79 panic!("manifest VBx PLDA entry '{id}' has no size — static-asset bug")
80 }),
81 }
82 })
83 })
84}
85
86fn leak_manifest_string(s: &str) -> &'static str {
89 Box::leak(s.to_owned().into_boxed_str())
90}
91
92#[derive(Clone, Copy, Debug)]
96pub struct VbxPldaArtifacts;
97
98pub const VBX_PLDA_ARTIFACTS: VbxPldaArtifacts = VbxPldaArtifacts;
100
101impl IntoIterator for VbxPldaArtifacts {
102 type Item = &'static VbxPldaArtifact;
103 type IntoIter = std::slice::Iter<'static, VbxPldaArtifact>;
104
105 fn into_iter(self) -> Self::IntoIter {
106 vbx_plda_artifacts().iter()
107 }
108}
109
110#[allow(clippy::expect_used)]
121pub fn default_manifest() -> Manifest {
122 Manifest::from_toml_str(DEFAULT_MANIFEST_TOML)
125 .expect("embedded manifest.toml must parse — this is a static-asset bug")
126}
127
128#[derive(Debug, thiserror::Error)]
130pub enum RegistryError {
131 #[error("model '{model_id}' not found in manifest")]
132 ModelNotFound { model_id: String },
133 #[error(
134 "model '{model_id}' has no signature in the manifest — release builds require a \
135 minisign signature for every profile-resolved model (a manifest that drops the \
136 signature would otherwise silently downgrade authenticity to a self-consistent hash)"
137 )]
138 UnsignedModel { model_id: String },
139 #[error("profile '{profile}' not found in manifest")]
140 ProfileNotFound { profile: String },
141 #[error("custom profile cannot be resolved by registry — caller must supply models")]
142 CustomProfileUnresolvable,
143 #[error("cache directory {path} is not writable")]
144 CacheNotWritable { path: PathBuf },
145 #[error("model '{model_id}' is not present in cache and offline mode is requested")]
146 OfflineMissing { model_id: String },
147 #[error("manifest error: {0}")]
148 Manifest(#[from] ManifestError),
149 #[error("download error: {0}")]
150 Download(#[from] DownloadError),
151 #[error("io error on {path}: {source}")]
152 Io {
153 path: PathBuf,
154 #[source]
155 source: std::io::Error,
156 },
157}
158
159#[derive(Debug, Clone)]
161pub struct ProfileModels {
162 pub segmenter_path: PathBuf,
163 pub embedder_path: PathBuf,
164}
165
166#[derive(Debug, Clone)]
169pub struct ModelRegistry {
170 manifest: Manifest,
171 cache_dir: PathBuf,
172 require_signatures: bool,
176}
177
178const REQUIRE_SIGNATURES_DEFAULT: bool = cfg!(not(debug_assertions));
181
182impl ModelRegistry {
183 #[allow(clippy::should_implement_trait)]
190 pub fn default() -> Result<Self, RegistryError> {
191 let cache = dirs::cache_dir()
192 .ok_or_else(|| RegistryError::CacheNotWritable {
193 path: PathBuf::from("(unresolved-cache-dir)"),
194 })?
195 .join("polyvoice")
196 .join("models");
197 Self::with_cache_dir(cache)
198 }
199
200 pub fn with_cache_dir(path: impl AsRef<Path>) -> Result<Self, RegistryError> {
206 let path = path.as_ref().to_path_buf();
207 std::fs::create_dir_all(&path).map_err(|e| RegistryError::Io {
208 path: path.clone(),
209 source: e,
210 })?;
211 Ok(Self {
212 manifest: default_manifest(),
213 cache_dir: path,
214 require_signatures: REQUIRE_SIGNATURES_DEFAULT,
215 })
216 }
217
218 #[cfg(test)]
224 pub fn with_manifest_override(mut self, manifest: Manifest) -> Self {
225 self.manifest = manifest;
226 self
227 }
228
229 #[cfg(test)]
232 pub fn with_require_signatures(mut self, require: bool) -> Self {
233 self.require_signatures = require;
234 self
235 }
236
237 #[cfg(test)]
242 pub fn with_manifest(
243 manifest: Manifest,
244 cache_dir: impl AsRef<Path>,
245 ) -> Result<Self, RegistryError> {
246 let path = cache_dir.as_ref().to_path_buf();
247 std::fs::create_dir_all(&path).map_err(|e| RegistryError::Io {
248 path: path.clone(),
249 source: e,
250 })?;
251 Ok(Self {
252 manifest,
253 cache_dir: path,
254 require_signatures: REQUIRE_SIGNATURES_DEFAULT,
255 })
256 }
257
258 pub fn cache_dir(&self) -> &Path {
262 &self.cache_dir
263 }
264
265 pub fn manifest(&self) -> &Manifest {
269 &self.manifest
270 }
271
272 pub fn ensure(&self, model_id: &str) -> Result<PathBuf, RegistryError> {
279 let entry = self
280 .manifest
281 .model(model_id)
282 .ok_or_else(|| RegistryError::ModelNotFound {
283 model_id: model_id.to_owned(),
284 })?;
285 let dest = self.cache_dir.join(&entry.filename);
286 download_with_checksum_and_signature(
287 &entry.url,
288 &entry.sha256,
289 entry.signature.as_deref(),
290 &dest,
291 )?;
292 Ok(dest)
293 }
294
295 pub fn ensure_vbx_plda_dir(&self) -> Result<PathBuf, RegistryError> {
303 for id in VBX_PLDA_MODEL_IDS {
304 self.ensure(id)?;
305 }
306 Ok(self.cache_dir.clone())
307 }
308
309 #[doc(hidden)]
314 #[cfg(test)] pub fn ensure_in_cache_only(&self, model_id: &str) -> Result<PathBuf, RegistryError> {
318 let entry = self
319 .manifest
320 .model(model_id)
321 .ok_or_else(|| RegistryError::ModelNotFound {
322 model_id: model_id.to_owned(),
323 })?;
324 let dest = self.cache_dir.join(&entry.filename);
325 if !dest.exists() {
326 return Err(RegistryError::OfflineMissing {
327 model_id: model_id.to_owned(),
328 });
329 }
330 Ok(dest)
333 }
334
335 fn require_signature_for(&self, model_id: &str) -> Result<(), RegistryError> {
341 if !self.require_signatures {
342 return Ok(());
343 }
344 let entry = self
345 .manifest
346 .model(model_id)
347 .ok_or_else(|| RegistryError::ModelNotFound {
348 model_id: model_id.to_owned(),
349 })?;
350 if entry.signature.is_none() {
351 return Err(RegistryError::UnsignedModel {
352 model_id: model_id.to_owned(),
353 });
354 }
355 Ok(())
356 }
357
358 pub fn ensure_for_profile(&self, profile: Profile) -> Result<ProfileModels, RegistryError> {
365 self.ensure_for_profile_with(profile, Self::ensure)
366 }
367
368 #[cfg(test)]
373 pub fn ensure_in_cache_only_for_profile(
374 &self,
375 profile: Profile,
376 ) -> Result<ProfileModels, RegistryError> {
377 self.ensure_for_profile_with(profile, Self::ensure_in_cache_only)
378 }
379
380 fn ensure_for_profile_with(
386 &self,
387 profile: Profile,
388 ensure_fn: impl Fn(&Self, &str) -> Result<PathBuf, RegistryError>,
389 ) -> Result<ProfileModels, RegistryError> {
390 if profile == Profile::Custom {
391 return Err(RegistryError::CustomProfileUnresolvable);
392 }
393 let prof = self
394 .manifest
395 .profile(profile.manifest_id())
396 .ok_or_else(|| RegistryError::ProfileNotFound {
397 profile: profile.manifest_id().to_owned(),
398 })?;
399 self.require_signature_for(&prof.segmenter)?;
400 self.require_signature_for(&prof.embedder)?;
401 let segmenter_path = ensure_fn(self, &prof.segmenter)?;
402 let embedder_path = ensure_fn(self, &prof.embedder)?;
403 Ok(ProfileModels {
404 segmenter_path,
405 embedder_path,
406 })
407 }
408}
409
410#[allow(clippy::unwrap_used)]
411#[cfg(test)]
412pub(crate) mod tests_helpers {
413 pub const TINY_MANIFEST: &str = r#"
416 schema = "polyvoice-models-v1"
417 [profiles.mobile]
418 segmenter = "hello_model"
419 embedder = "hello_model"
420 [profiles.balanced]
421 segmenter = "hello_model"
422 embedder = "hello_model"
423 [models.hello_model]
424 url = "file:///dev/null"
425 sha256 = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
426 size = 5
427 filename = "hello.bin"
428 "#;
429}
430
431#[allow(clippy::unwrap_used)]
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use crate::types::Profile;
436 use std::path::Path;
437 use tempfile::TempDir;
438
439 #[test]
440 fn embedded_manifest_parses() {
441 let m = default_manifest();
443 assert!(
444 is_supported_schema(&m.schema),
445 "embedded schema must be v1 or v2, got {}",
446 m.schema
447 );
448 assert!(m.profiles.contains_key("mobile"));
449 assert!(m.profiles.contains_key("balanced"));
450 }
451
452 #[test]
453 fn embedded_manifest_is_v2_with_adapter_metadata() {
454 let m = default_manifest();
455 assert_eq!(m.schema, SCHEMA_V2);
456 for (id, entry) in &m.models {
458 assert!(
459 entry.adapter_type.is_some(),
460 "model '{id}' missing adapter_type"
461 );
462 assert!(entry.license.is_some(), "model '{id}' missing license");
463 assert!(entry.version.is_some(), "model '{id}' missing version");
464 }
465 assert_eq!(
467 m.resolve_model_ref("segmenter", "latest"),
468 Some("powerset_fp32")
469 );
470 assert_eq!(
471 m.resolve_model_ref("embedder", "latest"),
472 Some("wespeaker_resnet34")
473 );
474 assert_eq!(m.resolve_model_ref("vad", "latest"), Some("silero_vad"));
475 }
476
477 #[test]
478 fn embedded_manifest_lists_legacy_models() {
479 let m = default_manifest();
480 assert!(m.models.contains_key("silero_vad"));
481 assert!(m.models.contains_key("wespeaker_resnet34"));
482 }
483
484 #[test]
485 fn profiles_share_segmenter_and_embedder_in_v2_hotfix() {
486 let m = default_manifest();
491 let mob = m.profile("mobile").unwrap();
492 let bal = m.profile("balanced").unwrap();
493 assert_eq!(mob.segmenter, bal.segmenter, "both use powerset");
494 assert_eq!(
495 mob.embedder, bal.embedder,
496 "both use resnet34 (CAM++ broken)"
497 );
498 }
499
500 #[test]
501 fn registry_default_uses_user_cache() {
502 let r = ModelRegistry::default().expect("default cache dir resolvable");
503 let path = r.cache_dir().to_path_buf();
504 assert!(path.ends_with("polyvoice/models") || path.ends_with("polyvoice\\models"));
505 }
506
507 #[test]
508 fn registry_with_cache_dir_creates_dir() {
509 let tmp = TempDir::new().unwrap();
510 let path = tmp.path().join("nested/models");
511 let r = ModelRegistry::with_cache_dir(&path).unwrap();
512 assert!(path.exists());
513 assert_eq!(r.cache_dir(), path.as_path());
514 }
515
516 #[test]
517 fn ensure_returns_err_for_unknown_model_id() {
518 let tmp = TempDir::new().unwrap();
519 let r = ModelRegistry::with_cache_dir(tmp.path()).unwrap();
520 let err = r
521 .ensure_in_cache_only("ghost")
522 .expect_err("must be missing");
523 assert!(matches!(err, RegistryError::ModelNotFound { .. }));
524 }
525
526 #[test]
527 fn ensure_in_cache_only_succeeds_when_file_present() {
528 let tmp = TempDir::new().unwrap();
529 let manifest =
530 Manifest::from_toml_str(crate::models::tests_helpers::TINY_MANIFEST).unwrap();
531 let r = ModelRegistry::with_cache_dir(tmp.path())
532 .unwrap()
533 .with_manifest_override(manifest);
534
535 let cached = tmp.path().join("hello.bin");
536 std::fs::write(&cached, b"hello").unwrap();
537 let path = r.ensure_in_cache_only("hello_model").unwrap();
538 assert_eq!(path, cached);
539 }
540
541 #[test]
542 fn ensure_for_profile_uses_manifest_lookup() {
543 let tmp = TempDir::new().unwrap();
544 let manifest =
545 Manifest::from_toml_str(crate::models::tests_helpers::TINY_MANIFEST).unwrap();
546 let r = ModelRegistry::with_cache_dir(tmp.path())
547 .unwrap()
548 .with_manifest_override(manifest)
549 .with_require_signatures(false);
552
553 std::fs::write(tmp.path().join("hello.bin"), b"hello").unwrap();
554
555 let bundle = r.ensure_in_cache_only_for_profile(Profile::Mobile).unwrap();
556 assert_eq!(bundle.segmenter_path, tmp.path().join("hello.bin"));
557 assert_eq!(bundle.embedder_path, tmp.path().join("hello.bin"));
558 }
559
560 const TINY_MANIFEST_SIGNED: &str = r#"
564 schema = "polyvoice-models-v1"
565 [profiles.mobile]
566 segmenter = "hello_model"
567 embedder = "hello_model"
568 [profiles.balanced]
569 segmenter = "hello_model"
570 embedder = "hello_model"
571 [models.hello_model]
572 url = "file:///dev/null"
573 sha256 = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
574 size = 5
575 filename = "hello.bin"
576 signature = "untrusted comment: fixture\nRWQfixturesignature"
577 "#;
578
579 #[test]
580 fn strict_profile_resolution_rejects_unsigned_model() {
581 let tmp = TempDir::new().unwrap();
582 let manifest =
583 Manifest::from_toml_str(crate::models::tests_helpers::TINY_MANIFEST).unwrap();
584 let r = ModelRegistry::with_cache_dir(tmp.path())
585 .unwrap()
586 .with_manifest_override(manifest)
587 .with_require_signatures(true);
588
589 let err = r.ensure_for_profile(Profile::Mobile).expect_err("unsigned");
591 assert!(
592 matches!(err, RegistryError::UnsignedModel { ref model_id } if model_id == "hello_model")
593 );
594 let err = r
595 .ensure_in_cache_only_for_profile(Profile::Mobile)
596 .expect_err("unsigned");
597 assert!(matches!(err, RegistryError::UnsignedModel { .. }));
598 }
599
600 #[test]
601 fn strict_profile_resolution_accepts_signed_model() {
602 let tmp = TempDir::new().unwrap();
603 let manifest = Manifest::from_toml_str(TINY_MANIFEST_SIGNED).unwrap();
604 let r = ModelRegistry::with_cache_dir(tmp.path())
605 .unwrap()
606 .with_manifest_override(manifest)
607 .with_require_signatures(true);
608
609 std::fs::write(tmp.path().join("hello.bin"), b"hello").unwrap();
610 let bundle = r.ensure_in_cache_only_for_profile(Profile::Mobile).unwrap();
611 assert_eq!(bundle.segmenter_path, tmp.path().join("hello.bin"));
612 }
613
614 #[test]
615 fn every_profile_model_is_signed() {
616 let m = default_manifest();
620 for (profile_id, prof) in &m.profiles {
621 for model_id in [&prof.segmenter, &prof.embedder] {
622 let entry = m.models.get(model_id).unwrap_or_else(|| {
623 panic!("profile '{profile_id}' references missing model '{model_id}'")
624 });
625 assert!(
626 entry.signature.is_some(),
627 "profile model '{model_id}' (via '{profile_id}') has no signature — \
628 release profile resolution would fail"
629 );
630 }
631 }
632 }
633
634 #[test]
635 fn optional_sortformer_entry_present_but_not_in_profiles() {
636 let m = default_manifest();
637 let entry = m.model("sortformer_v2").expect("sortformer_v2 in manifest");
638 assert_eq!(entry.adapter_type.as_deref(), Some("sortformer-v2"));
639 assert_eq!(entry.license.as_deref(), Some("CC-BY-4.0"));
640 assert_eq!(entry.num_speakers, Some(4));
641 for (pid, prof) in &m.profiles {
643 assert_ne!(
644 prof.segmenter, "sortformer_v2",
645 "profile {pid} must not pull sortformer as segmenter"
646 );
647 assert_ne!(
648 prof.embedder, "sortformer_v2",
649 "profile {pid} must not pull sortformer as embedder"
650 );
651 }
652 }
653
654 #[test]
655 fn optional_vbx_plda_entries_present_but_not_in_profiles() {
656 let m = default_manifest();
657 let artifacts = vbx_plda_artifacts();
658 assert_eq!(VBX_PLDA_MODEL_IDS.len(), artifacts.len());
659 for (art, listed_id) in artifacts.iter().zip(VBX_PLDA_MODEL_IDS.iter()) {
660 assert_eq!(art.id, *listed_id);
661 let entry = m
662 .model(art.id)
663 .unwrap_or_else(|| panic!("missing manifest entry {}", art.id));
664 assert_eq!(entry.sha256, art.sha256, "{} sha256 mismatch", art.id);
667 assert_eq!(entry.size, Some(art.size), "{} size mismatch", art.id);
668 assert_eq!(entry.filename, art.filename, "{} filename mismatch", art.id);
669 assert_eq!(entry.adapter_type.as_deref(), Some("vbx-plda"));
670 assert_eq!(entry.license.as_deref(), Some("CC-BY-4.0"));
671 assert!(
673 entry.signature.is_none(),
674 "{} must stay unsigned until a release engineer signs it \
675 (profile resolution never pulls these)",
676 art.id
677 );
678 assert!(
679 entry.url.starts_with("https://"),
680 "{} url must be https",
681 art.id
682 );
683 for (pid, prof) in &m.profiles {
684 assert_ne!(
685 prof.segmenter.as_str(),
686 art.id,
687 "profile {pid} must not pull PLDA as segmenter"
688 );
689 assert_ne!(
690 prof.embedder.as_str(),
691 art.id,
692 "profile {pid} must not pull PLDA as embedder"
693 );
694 }
695 }
696 }
697
698 #[test]
699 fn ensure_vbx_plda_dir_uses_local_cache_without_network() {
700 let tmp = TempDir::new().unwrap();
703 let fixture_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/vbx-plda");
704 for art in VBX_PLDA_ARTIFACTS {
705 let src = fixture_dir.join(art.filename);
706 assert!(
707 src.is_file(),
708 "fixture missing: {} (run scripts/build-vbx-plda.py)",
709 src.display()
710 );
711 std::fs::copy(&src, tmp.path().join(art.filename)).unwrap();
712 }
713 let r = ModelRegistry::with_cache_dir(tmp.path()).unwrap();
714 let dir = r
715 .ensure_vbx_plda_dir()
716 .expect("cache-hit ensure must succeed offline");
717 assert_eq!(dir, tmp.path());
718 for art in VBX_PLDA_ARTIFACTS {
719 assert!(dir.join(art.filename).is_file());
720 }
721 }
722}