Skip to main content

aurum_core/tts/
byom.rs

1//! TTS BYOM inspect / verify / add helpers (JOE-1621).
2//!
3//! Never auto-trusts Hugging Face cards or bare ONNX filenames.
4
5use super::adapter::{
6    list_adapters, lookup_adapter, preflight_manifest, ManifestArtifact, ModelPackManifest,
7    TrustMode, MANIFEST_SCHEMA_VERSION,
8};
9use super::conformance::{kitten_builtin_manifest, run_pack_conformance, ConformanceReport};
10use super::pack::{load_pack_dir, write_manifest, MANIFEST_FILENAME};
11use crate::error::{Result, UserError};
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14
15/// Read-only inspect result (no network).
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct InspectReport {
18    pub path: String,
19    pub ok: bool,
20    pub adapter_id: Option<String>,
21    pub model_id: Option<String>,
22    pub trust: Option<String>,
23    pub languages: Vec<String>,
24    pub voices: Vec<String>,
25    pub artifacts: Vec<String>,
26    pub license: Option<String>,
27    pub incompatibilities: Vec<String>,
28    pub notes: Vec<String>,
29}
30
31/// Inspect a pack directory or manifest file.
32pub fn inspect_pack(path: &Path) -> InspectReport {
33    let mut report = InspectReport {
34        path: path.display().to_string(),
35        ok: false,
36        adapter_id: None,
37        model_id: None,
38        trust: None,
39        languages: vec![],
40        voices: vec![],
41        artifacts: vec![],
42        license: None,
43        incompatibilities: vec![],
44        notes: vec![
45            "No network access during inspect.".into(),
46            "Support is never inferred from filenames alone.".into(),
47        ],
48    };
49
50    // Bare files that are not the manifest: fail closed without treating the
51    // parent directory as a pack (parent may be a symlink like /tmp).
52    if path.is_file() {
53        let name = path
54            .file_name()
55            .and_then(|n| n.to_str())
56            .unwrap_or_default();
57        if name != MANIFEST_FILENAME {
58            if path.extension().and_then(|e| e.to_str()) == Some("onnx") {
59                report.incompatibilities.push(
60                    "Bare .onnx files are not supported; provide a pack directory with a manifest and known adapter id."
61                        .into(),
62                );
63            } else {
64                report.incompatibilities.push(format!(
65                    "expected a pack directory or {MANIFEST_FILENAME}, got file {}",
66                    path.display()
67                ));
68            }
69            return report;
70        }
71    }
72
73    let pack_dir = if path.is_file() {
74        path.parent().unwrap_or(path).to_path_buf()
75    } else {
76        path.to_path_buf()
77    };
78
79    match load_pack_dir(&pack_dir, true) {
80        Ok((_root, m)) => {
81            report.adapter_id = Some(m.adapter_id.clone());
82            report.model_id = Some(m.model_id.clone());
83            report.trust = Some(m.trust.as_str().into());
84            report.languages = m.languages.clone();
85            report.voices = m.voices.iter().map(|v| v.id.clone()).collect();
86            report.artifacts = m
87                .artifacts
88                .iter()
89                .map(|a| format!("{}:{}", a.role, a.filename))
90                .collect();
91            report.license = Some(m.license.clone());
92            if let Err(e) = preflight_manifest(&m) {
93                report.incompatibilities.push(e.to_string());
94            }
95            if matches!(m.trust, TrustMode::LocalUnverified) {
96                report.notes.push(
97                    "Trust=local_unverified: unsupported for production; user responsibility."
98                        .into(),
99                );
100            }
101            report.ok = report.incompatibilities.is_empty();
102        }
103        Err(e) => {
104            report.incompatibilities.push(e.to_string());
105            if path.extension().and_then(|e| e.to_str()) == Some("onnx") {
106                report.incompatibilities.push(
107                    "Bare .onnx files are not supported; provide a pack directory with a manifest and adapter id."
108                        .into(),
109                );
110            }
111        }
112    }
113    report
114}
115
116/// Full verify: load + artifact digests + conformance preflight.
117pub fn verify_pack(path: &Path, allow_unverified: bool) -> Result<ConformanceReport> {
118    let pack_dir = if path.is_file() {
119        path.parent()
120            .ok_or_else(|| UserError::InvalidConfig {
121                reason: "invalid pack path".into(),
122            })?
123            .to_path_buf()
124    } else {
125        path.to_path_buf()
126    };
127    let _ = load_pack_dir(&pack_dir, allow_unverified)?;
128    Ok(run_pack_conformance(&pack_dir))
129}
130
131/// Dry-run proposal for `aurum tts add` (no downloads, no config write).
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct AddProposal {
134    pub dry_run: bool,
135    pub adapter_id: String,
136    pub model_id: String,
137    pub trust: String,
138    pub manifest: ModelPackManifest,
139    pub warnings: Vec<String>,
140    pub next_steps: Vec<String>,
141}
142
143/// Build a proposed manifest for a local pack folder (user must confirm write).
144pub fn propose_add_local(
145    pack_dir: &Path,
146    adapter_id: &str,
147    model_id: &str,
148    trust: TrustMode,
149) -> Result<AddProposal> {
150    let adapter = lookup_adapter(adapter_id)?;
151    if matches!(trust, TrustMode::Builtin) {
152        return Err(UserError::InvalidConfig {
153            reason: "cannot register a custom pack as trust=builtin".into(),
154        }
155        .into());
156    }
157    // Built-in catalogue ids cannot be shadowed.
158    if model_id == super::catalogue::DEFAULT_TTS_MODEL
159        || super::catalogue::lookup_model(model_id)
160            .map(|m| m.shipped)
161            .unwrap_or(false)
162    {
163        return Err(UserError::InvalidConfig {
164            reason: format!(
165                "model id '{model_id}' is reserved for the built-in catalogue and cannot be registered as custom"
166            ),
167        }
168        .into());
169    }
170    // Prefer existing manifest if present.
171    let manifest_path = pack_dir.join(MANIFEST_FILENAME);
172    let mut warnings = vec![];
173    let mut manifest = if manifest_path.is_file() {
174        ModelPackManifest::load_path(&manifest_path)?
175    } else {
176        warnings.push(format!(
177            "no {MANIFEST_FILENAME} yet — generating a skeleton with adapter-required roles; \
178             fill digests/sizes before using trust=verified"
179        ));
180        let stub_artifacts: Vec<ManifestArtifact> = adapter
181            .required_artifact_roles
182            .iter()
183            .map(|role| ManifestArtifact {
184                role: (*role).into(),
185                filename: match *role {
186                    "onnx" => "model.onnx".into(),
187                    "voices" => "voices.npz".into(),
188                    "config" => "config.json".into(),
189                    other => format!("{other}.bin"),
190                },
191                sha256: None,
192                size_bytes: None,
193            })
194            .collect();
195        // Skeleton packs cannot claim verified trust without digests.
196        let skeleton_trust = if matches!(trust, TrustMode::Verified) {
197            warnings.push(
198                "requested trust=verified but digests are missing; skeleton uses \
199                 local_unverified until pins are filled"
200                    .into(),
201            );
202            TrustMode::LocalUnverified
203        } else {
204            trust
205        };
206        ModelPackManifest {
207            schema_version: MANIFEST_SCHEMA_VERSION,
208            adapter_id: adapter_id.into(),
209            adapter_version: adapter.version,
210            model_id: model_id.into(),
211            sample_rate_hz: adapter.sample_rate_hz,
212            channels: 1,
213            max_phoneme_tokens: 400,
214            languages: adapter.languages.iter().map(|s| (*s).to_string()).collect(),
215            license: "UNKNOWN — required".into(),
216            trust: skeleton_trust,
217            artifacts: stub_artifacts,
218            voices: vec![],
219            source: Some(format!("local:{}", pack_dir.display())),
220            notes: Some("generated by aurum tts add --dry-run".into()),
221        }
222    };
223    manifest.adapter_id = adapter_id.into();
224    manifest.model_id = model_id.into();
225    // Keep caller trust only when digests support it.
226    if matches!(trust, TrustMode::Verified)
227        && manifest
228            .artifacts
229            .iter()
230            .any(|a| a.sha256.as_ref().map(|s| s.len() != 64).unwrap_or(true))
231    {
232        if !matches!(manifest.trust, TrustMode::LocalUnverified) {
233            warnings.push(
234                "trust=verified requires sha256+size for every artifact; keeping \
235                 local_unverified until pins are complete"
236                    .into(),
237            );
238        }
239        manifest.trust = TrustMode::LocalUnverified;
240    } else {
241        manifest.trust = trust;
242    }
243    if manifest.license.contains("UNKNOWN") {
244        warnings.push("license field must be filled before production use".into());
245    }
246
247    Ok(AddProposal {
248        dry_run: true,
249        adapter_id: adapter_id.into(),
250        model_id: model_id.into(),
251        trust: manifest.trust.as_str().into(),
252        manifest,
253        warnings,
254        next_steps: vec![
255            "Review licenses and digests.".into(),
256            "Run: aurum tts verify <pack>".into(),
257            "On success, write manifest / add [[tts.custom_models]] in config.".into(),
258            "Custom models never become default without an explicit config change.".into(),
259        ],
260    })
261}
262
263/// Materialize a dry-run proposal to disk (manifest only; no network).
264pub fn write_add_manifest(pack_dir: &Path, proposal: &AddProposal) -> Result<PathBuf> {
265    if proposal.manifest.artifacts.is_empty() {
266        return Err(UserError::InvalidConfig {
267            reason: "cannot write manifest with zero artifacts; fill adapter roles first".into(),
268        }
269        .into());
270    }
271    if matches!(proposal.manifest.trust, TrustMode::Verified)
272        && proposal
273            .manifest
274            .artifacts
275            .iter()
276            .any(|a| a.sha256.as_ref().map(|s| s.len() != 64).unwrap_or(true))
277    {
278        return Err(UserError::InvalidConfig {
279            reason: "cannot write trust=verified manifest without sha256 for every artifact\n  \
280                     Hint: fill digests, or use --trust local_unverified"
281                .into(),
282        }
283        .into());
284    }
285    write_manifest(pack_dir, &proposal.manifest)
286}
287
288/// Human-readable inspect formatting.
289pub fn format_inspect(r: &InspectReport) -> String {
290    let mut out = String::new();
291    out.push_str(&format!("path: {}\n", r.path));
292    out.push_str(&format!("ok: {}\n", r.ok));
293    if let Some(a) = &r.adapter_id {
294        out.push_str(&format!("adapter: {a}\n"));
295    }
296    if let Some(m) = &r.model_id {
297        out.push_str(&format!("model: {m}\n"));
298    }
299    if let Some(t) = &r.trust {
300        out.push_str(&format!("trust: {t}\n"));
301    }
302    if let Some(l) = &r.license {
303        out.push_str(&format!("license: {l}\n"));
304    }
305    if !r.languages.is_empty() {
306        out.push_str(&format!("languages: {}\n", r.languages.join(", ")));
307    }
308    if !r.voices.is_empty() {
309        out.push_str(&format!("voices: {}\n", r.voices.join(", ")));
310    }
311    if !r.artifacts.is_empty() {
312        out.push_str("artifacts:\n");
313        for a in &r.artifacts {
314            out.push_str(&format!("  - {a}\n"));
315        }
316    }
317    if !r.incompatibilities.is_empty() {
318        out.push_str("incompatibilities:\n");
319        for i in &r.incompatibilities {
320            out.push_str(&format!("  - {i}\n"));
321        }
322    }
323    for n in &r.notes {
324        out.push_str(&format!("note: {n}\n"));
325    }
326    out
327}
328
329pub fn format_adapters() -> String {
330    let mut out = String::from("Supported TTS adapters:\n");
331    for a in list_adapters() {
332        out.push_str(&format!(
333            "  {} v{}  synth={}  {}\n",
334            a.id, a.version, a.synthesis_supported, a.description
335        ));
336    }
337    out.push_str("\nDefault synthesis adapter: kitten-onnx-v1 (built-in catalogue).\n");
338    out.push_str("Bare ONNX paths are not supported — use a pack + manifest.\n");
339    out
340}
341
342/// Export builtin Kitten manifest JSON for docs/examples.
343pub fn builtin_kitten_manifest_json() -> Result<String> {
344    let m = kitten_builtin_manifest();
345    serde_json::to_string_pretty(&m)
346        .map_err(|e| crate::error::TranscriptionError::internal(format!("manifest json: {e}")))
347}
348
349#[cfg(test)]
350mod tests {
351    use super::super::pack::write_fake_sine_pack;
352    use super::*;
353    use tempfile::tempdir;
354
355    #[test]
356    fn inspect_fake_pack() {
357        let dir = tempdir().unwrap();
358        let pack = dir.path().join("p");
359        write_fake_sine_pack(&pack, "fake").unwrap();
360        let r = inspect_pack(&pack);
361        assert!(r.ok, "{:?}", r.incompatibilities);
362        assert_eq!(r.adapter_id.as_deref(), Some("fake-sine-v1"));
363    }
364
365    #[test]
366    fn add_rejects_reserved_id() {
367        let dir = tempdir().unwrap();
368        let err = propose_add_local(
369            dir.path(),
370            "fake-sine-v1",
371            super::super::catalogue::DEFAULT_TTS_MODEL,
372            TrustMode::Verified,
373        )
374        .unwrap_err();
375        assert!(err.to_string().contains("reserved"));
376    }
377
378    #[test]
379    fn bare_onnx_inspect_fails_closed() {
380        let dir = tempdir().unwrap();
381        let onnx = dir.path().join("x.onnx");
382        std::fs::write(&onnx, b"x").unwrap();
383        let r = inspect_pack(&onnx);
384        assert!(!r.ok);
385    }
386}