Skip to main content

aurum_core/tts/
pack.rs

1//! Local TTS model-pack loading, trust modes, and digest verification (JOE-1619).
2
3use super::adapter::{
4    lookup_adapter, preflight_manifest, ModelPackManifest, TrustMode, MANIFEST_SCHEMA_VERSION,
5};
6use crate::error::{EnvironmentError, Result, UserError};
7use sha2::{Digest, Sha256};
8use std::fs;
9use std::io::Read;
10use std::path::{Path, PathBuf};
11
12/// Default manifest filename inside a pack directory.
13pub const MANIFEST_FILENAME: &str = "aurum-tts-manifest.json";
14
15/// Maximum single artifact size (512 MiB) for local packs.
16pub const MAX_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
17
18/// Isolated cache root for custom/local packs (never shadows built-ins).
19pub fn custom_pack_cache_dir(cache_dir: &Path) -> PathBuf {
20    cache_dir.join("tts").join("custom")
21}
22
23/// Resolve a pack directory → validated manifest + absolute pack root.
24pub fn load_pack_dir(
25    pack_dir: &Path,
26    allow_unverified: bool,
27) -> Result<(PathBuf, ModelPackManifest)> {
28    let root = canonicalize_local(pack_dir)?;
29    let mut manifest = load_manifest_no_symlink(&root)?;
30    if matches!(manifest.trust, TrustMode::LocalUnverified) && !allow_unverified {
31        return Err(UserError::InvalidConfig {
32            reason:
33                "local_unverified pack requires explicit opt-in (--allow-unverified / trust mode)"
34                    .into(),
35        }
36        .into());
37    }
38    // Builtin trust is only for compiled catalogue — local dirs force verified or unverified.
39    if matches!(manifest.trust, TrustMode::Builtin) {
40        manifest.trust = if allow_unverified {
41            TrustMode::LocalUnverified
42        } else {
43            TrustMode::Verified
44        };
45    }
46    preflight_manifest(&manifest)?;
47    verify_pack_artifacts(&root, &manifest)?;
48    Ok((root, manifest))
49}
50
51fn canonicalize_local(path: &Path) -> Result<PathBuf> {
52    let meta = fs::symlink_metadata(path).map_err(|e| UserError::InvalidConfig {
53        reason: format!("pack path {}: {e}", path.display()),
54    })?;
55    if meta.file_type().is_symlink() {
56        return Err(UserError::InvalidConfig {
57            reason: format!(
58                "refusing TTS pack path that is a symlink: {}\n  Hint: pass a real directory",
59                path.display()
60            ),
61        }
62        .into());
63    }
64    if !meta.is_dir() {
65        return Err(UserError::InvalidConfig {
66            reason: format!(
67                "TTS pack path is not a directory: {}\n  Hint: use a pack folder with {MANIFEST_FILENAME}",
68                path.display()
69            ),
70        }
71        .into());
72    }
73    fs::canonicalize(path).map_err(|e| {
74        EnvironmentError::DirectoryAccess {
75            path: path.display().to_string(),
76            reason: e.to_string(),
77        }
78        .into()
79    })
80}
81
82/// Open and parse the pack manifest with the same no-symlink regular-file policy
83/// as artifacts (JOE-1918 / F-005). Never follows a final symlink via `is_file()`.
84fn load_manifest_no_symlink(root: &Path) -> Result<ModelPackManifest> {
85    let manifest_path = root.join(MANIFEST_FILENAME);
86    let meta = fs::symlink_metadata(&manifest_path).map_err(|e| UserError::InvalidConfig {
87        reason: format!(
88            "TTS pack missing {MANIFEST_FILENAME} under {}: {e}\n  \
89             Hint: pass a model-pack directory with a manifest, not a bare .onnx file",
90            root.display()
91        ),
92    })?;
93    if meta.file_type().is_symlink() {
94        return Err(UserError::InvalidConfig {
95            reason: format!(
96                "refusing {MANIFEST_FILENAME} that is a symlink under {}\n  \
97                 Hint: packs must use a regular manifest file",
98                root.display()
99            ),
100        }
101        .into());
102    }
103    if !meta.is_file() {
104        return Err(UserError::InvalidConfig {
105            reason: format!(
106                "{MANIFEST_FILENAME} under {} is not a regular file",
107                root.display()
108            ),
109        }
110        .into());
111    }
112    ModelPackManifest::load_path(&manifest_path)
113}
114
115/// Re-hash a pack artifact immediately before native load (JOE-1918 TOCTOU close).
116///
117/// Rejects symlinks and, when `expect_sha256` is set, requires a matching digest
118/// of the bytes currently on disk.
119pub fn reverify_artifact_before_load(path: &Path, expect_sha256: Option<&str>) -> Result<()> {
120    let meta = fs::symlink_metadata(path).map_err(|e| UserError::InvalidConfig {
121        reason: format!("artifact missing at load: {}: {e}", path.display()),
122    })?;
123    if meta.file_type().is_symlink() {
124        return Err(UserError::InvalidConfig {
125            reason: format!(
126                "artifact became a symlink before load (refused): {}",
127                path.display()
128            ),
129        }
130        .into());
131    }
132    if !meta.is_file() {
133        return Err(UserError::InvalidConfig {
134            reason: format!("artifact is not a regular file at load: {}", path.display()),
135        }
136        .into());
137    }
138    if let Some(expect) = expect_sha256 {
139        let got = sha256_file(path)?;
140        if !got.eq_ignore_ascii_case(expect) {
141            return Err(UserError::InvalidConfig {
142                reason: format!(
143                    "artifact digest changed between verify and load\n  path {}\n  expected {expect}\n  got      {got}",
144                    path.display()
145                ),
146            }
147            .into());
148        }
149    }
150    Ok(())
151}
152
153/// Stage a **verified** pack artifact into a private cache snapshot for native load.
154///
155/// Closes the remaining reverify→ORT/NPZ reopen window (post-v0.0.18 F-005 re-open):
156/// after digest check, bytes are copied into `cache_root/tts/verified-snaps/<sha>/`
157/// and re-hashed there; native loaders must open the **snapshot** path, not the
158/// pack path. Without a digest (`local_unverified`), falls back to reverify-only
159/// on the original path (explicit residual for unverified packs).
160pub fn stage_verified_for_load(
161    source: &Path,
162    expect_sha256: Option<&str>,
163    cache_root: &Path,
164    leaf_name: &str,
165) -> Result<PathBuf> {
166    reverify_artifact_before_load(source, expect_sha256)?;
167    let Some(expect) = expect_sha256.map(|s| s.to_ascii_lowercase()) else {
168        // No digest: cannot form an immutable content-addressed snap; residual
169        // for local_unverified is documented (hostile FS out of Tier A).
170        return Ok(source.to_path_buf());
171    };
172    if !expect.chars().all(|c| c.is_ascii_hexdigit()) || expect.len() != 64 {
173        return Err(UserError::InvalidConfig {
174            reason: "expect_sha256 must be a 64-char hex digest for verified staging".into(),
175        }
176        .into());
177    }
178    let snap_dir = cache_root.join("tts").join("verified-snaps").join(&expect);
179    fs::create_dir_all(&snap_dir).map_err(EnvironmentError::Io)?;
180    let dest = snap_dir.join(sanitize_snap_leaf(leaf_name));
181    if dest.is_file() {
182        // Existing snap: re-check digest (fail closed if corrupted).
183        reverify_artifact_before_load(&dest, Some(&expect))?;
184        return Ok(dest);
185    }
186    // Exclusive partial → durable rename (JOE-1918 partial policy).
187    let partial = snap_dir.join(format!(
188        ".partial-{leaf}-{rand}",
189        leaf = sanitize_snap_leaf(leaf_name),
190        rand = std::process::id()
191    ));
192    {
193        let mut src = fs::File::open(source).map_err(EnvironmentError::Io)?;
194        let mut out = fs::OpenOptions::new()
195            .write(true)
196            .create_new(true)
197            .open(&partial)
198            .map_err(EnvironmentError::Io)?;
199        std::io::copy(&mut src, &mut out).map_err(EnvironmentError::Io)?;
200        out.sync_all().map_err(EnvironmentError::Io)?;
201    }
202    // Re-hash partial before publish.
203    let got = sha256_file(&partial)?;
204    if !got.eq_ignore_ascii_case(&expect) {
205        let _ = fs::remove_file(&partial);
206        return Err(UserError::InvalidConfig {
207            reason: format!(
208                "staged artifact digest mismatch (source may have been swapped during copy)\n  expected {expect}\n  got      {got}"
209            ),
210        }
211        .into());
212    }
213    fs::rename(&partial, &dest).map_err(EnvironmentError::Io)?;
214    reverify_artifact_before_load(&dest, Some(&expect))?;
215    Ok(dest)
216}
217
218fn sanitize_snap_leaf(name: &str) -> String {
219    let base = Path::new(name)
220        .file_name()
221        .and_then(|s| s.to_str())
222        .unwrap_or("artifact");
223    base.chars()
224        .map(|c| {
225            if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
226                c
227            } else {
228                '_'
229            }
230        })
231        .collect()
232}
233
234/// Verify digests/sizes for pack artifacts relative to `root` (JOE-1649).
235///
236/// Every artifact path is resolved with symlink rejection and proven to remain
237/// under the canonical pack root before open/hash.
238pub fn verify_pack_artifacts(root: &Path, manifest: &ModelPackManifest) -> Result<()> {
239    let adapter = lookup_adapter(&manifest.adapter_id)?;
240    for role in adapter.required_artifact_roles {
241        let art = manifest
242            .artifact(role)
243            .ok_or_else(|| UserError::InvalidConfig {
244                reason: format!("missing artifact role '{role}'"),
245            })?;
246        let path = resolve_pack_artifact(root, &art.filename)?;
247        // Size check uses symlink_metadata (no follow) then open for hash.
248        let meta = fs::symlink_metadata(&path).map_err(|e| UserError::InvalidConfig {
249            reason: format!("artifact {} missing: {e}", path.display()),
250        })?;
251        if meta.file_type().is_symlink() {
252            return Err(UserError::InvalidConfig {
253                reason: format!(
254                    "artifact {} is a symlink (refused); pack artifacts must be regular files",
255                    art.filename
256                ),
257            }
258            .into());
259        }
260        if !meta.is_file() {
261            return Err(UserError::InvalidConfig {
262                reason: format!("artifact {} is not a regular file", art.filename),
263            }
264            .into());
265        }
266        if meta.len() > MAX_ARTIFACT_BYTES {
267            return Err(UserError::InvalidConfig {
268                reason: format!(
269                    "artifact {} exceeds max size {MAX_ARTIFACT_BYTES}",
270                    art.filename
271                ),
272            }
273            .into());
274        }
275        if let Some(expect) = art.size_bytes {
276            if meta.len() != expect {
277                return Err(UserError::InvalidConfig {
278                    reason: format!(
279                        "artifact {} size mismatch: got {} expected {expect}",
280                        art.filename,
281                        meta.len()
282                    ),
283                }
284                .into());
285            }
286        }
287        if let Some(expect_hex) = &art.sha256 {
288            let got = sha256_file(&path)?;
289            if !got.eq_ignore_ascii_case(expect_hex) {
290                return Err(UserError::InvalidConfig {
291                    reason: format!(
292                        "artifact {} sha256 mismatch\n  expected {expect_hex}\n  got      {got}",
293                        art.filename
294                    ),
295                }
296                .into());
297            }
298        } else if matches!(manifest.trust, TrustMode::Verified) {
299            return Err(UserError::InvalidConfig {
300                reason: format!("verified pack missing sha256 for {}", art.filename),
301            }
302            .into());
303        }
304    }
305    Ok(())
306}
307
308/// Resolve `filename` under `root` with containment and symlink policy (JOE-1649).
309///
310/// Rejects absolute paths, `..` components, nested symlinks, and any canonical
311/// target outside the pack root.
312pub fn resolve_pack_artifact(root: &Path, filename: &str) -> Result<PathBuf> {
313    if filename.is_empty() {
314        return Err(UserError::InvalidConfig {
315            reason: "empty artifact filename".into(),
316        }
317        .into());
318    }
319    let rel = Path::new(filename);
320    if rel.is_absolute() {
321        return Err(UserError::InvalidConfig {
322            reason: format!("illegal absolute artifact path '{filename}'"),
323        }
324        .into());
325    }
326    for c in rel.components() {
327        use std::path::Component;
328        match c {
329            Component::Normal(_) => {}
330            Component::CurDir => {}
331            _ => {
332                return Err(UserError::InvalidConfig {
333                    reason: format!("illegal artifact path component in '{filename}'"),
334                }
335                .into());
336            }
337        }
338    }
339
340    // Walk component-by-component; reject any intermediate symlink.
341    let mut cur = root.to_path_buf();
342    for c in rel.components() {
343        use std::path::Component;
344        let Component::Normal(part) = c else {
345            continue;
346        };
347        cur.push(part);
348        let meta = fs::symlink_metadata(&cur).map_err(|e| UserError::InvalidConfig {
349            reason: format!("artifact path {}: {e}", cur.display()),
350        })?;
351        if meta.file_type().is_symlink() {
352            return Err(UserError::InvalidConfig {
353                reason: format!(
354                    "refusing symlink in pack path: {}\n  Hint: packs must be self-contained regular files",
355                    cur.display()
356                ),
357            }
358            .into());
359        }
360    }
361
362    // Canonical containment check (root already canonical from load_pack_dir).
363    let canon_root = fs::canonicalize(root).map_err(|e| EnvironmentError::DirectoryAccess {
364        path: root.display().to_string(),
365        reason: e.to_string(),
366    })?;
367    let canon_file = fs::canonicalize(&cur).map_err(|e| UserError::InvalidConfig {
368        reason: format!("cannot canonicalize artifact {}: {e}", cur.display()),
369    })?;
370    if !canon_file.starts_with(&canon_root) {
371        return Err(UserError::InvalidConfig {
372            reason: format!(
373                "artifact escapes pack root: {} (root {})",
374                canon_file.display(),
375                canon_root.display()
376            ),
377        }
378        .into());
379    }
380    Ok(cur)
381}
382
383pub fn sha256_file(path: &Path) -> Result<String> {
384    let mut f = fs::File::open(path).map_err(EnvironmentError::Io)?;
385    let mut hasher = Sha256::new();
386    let mut buf = [0u8; 64 * 1024];
387    loop {
388        let n = f.read(&mut buf).map_err(EnvironmentError::Io)?;
389        if n == 0 {
390            break;
391        }
392        hasher.update(&buf[..n]);
393    }
394    Ok(hex::encode(hasher.finalize()))
395}
396
397/// Write a reviewable manifest into a pack directory (does not download).
398///
399/// Uses the shared secure output transaction (JOE-1644/1649): no predictable
400/// shared `.tmp` path; replace mode so updates keep the previous file intact
401/// until successful commit.
402pub fn write_manifest(pack_dir: &Path, manifest: &ModelPackManifest) -> Result<PathBuf> {
403    manifest.validate_schema()?;
404    fs::create_dir_all(pack_dir).map_err(EnvironmentError::Io)?;
405    let path = pack_dir.join(MANIFEST_FILENAME);
406    let json = serde_json::to_string_pretty(manifest).map_err(|e| {
407        crate::error::TranscriptionError::internal(format!("manifest serialize: {e}"))
408    })?;
409    let mut body = json;
410    if !body.ends_with('\n') {
411        body.push('\n');
412    }
413    crate::output::OutputTransaction::new(&path, crate::output::CommitMode::Replace)
414        .commit_bytes(body.as_bytes())?;
415    Ok(path)
416}
417
418/// Build a minimal fake-sine pack for tests/conformance.
419pub fn write_fake_sine_pack(dir: &Path, model_id: &str) -> Result<ModelPackManifest> {
420    fs::create_dir_all(dir).map_err(EnvironmentError::Io)?;
421    let config = r#"{"adapter":"fake-sine-v1","freq_hz":440}"#;
422    let config_path = dir.join("config.json");
423    fs::write(&config_path, config).map_err(EnvironmentError::Io)?;
424    let sha = sha256_file(&config_path)?;
425    let size = fs::metadata(&config_path)
426        .map_err(EnvironmentError::Io)?
427        .len();
428    let manifest = ModelPackManifest {
429        schema_version: MANIFEST_SCHEMA_VERSION,
430        adapter_id: super::adapter::ADAPTER_FAKE_SINE_V1.into(),
431        adapter_version: 1,
432        model_id: model_id.into(),
433        sample_rate_hz: 24_000,
434        channels: 1,
435        max_phoneme_tokens: 256,
436        languages: vec!["en".into()],
437        license: "CC0-test".into(),
438        trust: TrustMode::Verified,
439        artifacts: vec![super::adapter::ManifestArtifact {
440            role: "config".into(),
441            filename: "config.json".into(),
442            sha256: Some(sha),
443            size_bytes: Some(size),
444        }],
445        voices: vec![super::adapter::ManifestVoice {
446            id: "Tone".into(),
447            internal_key: "tone".into(),
448            language: "en".into(),
449            notes: "440 Hz sine".into(),
450        }],
451        source: Some("aurum-test-fixture".into()),
452        notes: Some("conformance fixture".into()),
453    };
454    write_manifest(dir, &manifest)?;
455    Ok(manifest)
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use tempfile::tempdir;
462
463    #[test]
464    fn fake_pack_loads_and_verifies() {
465        let dir = tempdir().unwrap();
466        let pack = dir.path().join("pack");
467        write_fake_sine_pack(&pack, "fake-sine-demo").unwrap();
468        let (root, m) = load_pack_dir(&pack, false).unwrap();
469        assert_eq!(m.model_id, "fake-sine-demo");
470        assert!(root.join("config.json").is_file());
471    }
472
473    #[test]
474    fn bare_onnx_rejected() {
475        let dir = tempdir().unwrap();
476        let onnx = dir.path().join("model.onnx");
477        fs::write(&onnx, b"not-a-model").unwrap();
478        let err = load_pack_dir(dir.path(), true).unwrap_err();
479        assert!(err.to_string().contains("manifest") || err.to_string().contains("onnx"));
480    }
481
482    #[test]
483    fn digest_mismatch_fails() {
484        let dir = tempdir().unwrap();
485        let pack = dir.path().join("pack");
486        let mut m = write_fake_sine_pack(&pack, "x").unwrap();
487        m.artifacts[0].sha256 = Some("ab".repeat(32));
488        write_manifest(&pack, &m).unwrap();
489        assert!(load_pack_dir(&pack, false).is_err());
490    }
491
492    #[cfg(unix)]
493    #[test]
494    fn artifact_symlink_rejected() {
495        let dir = tempdir().unwrap();
496        let pack = dir.path().join("pack");
497        write_fake_sine_pack(&pack, "sym").unwrap();
498        let outside = dir.path().join("secret.bin");
499        fs::write(&outside, b"escaped-bytes").unwrap();
500        // Replace config.json with a symlink escaping the pack.
501        let config = pack.join("config.json");
502        fs::remove_file(&config).unwrap();
503        std::os::unix::fs::symlink(&outside, &config).unwrap();
504        let err = load_pack_dir(&pack, false).unwrap_err();
505        let msg = err.to_string();
506        assert!(
507            msg.contains("symlink") || msg.contains("escape"),
508            "expected symlink/escape rejection, got: {msg}"
509        );
510    }
511
512    #[test]
513    fn path_traversal_filename_rejected() {
514        let dir = tempdir().unwrap();
515        let pack = dir.path().join("pack");
516        write_fake_sine_pack(&pack, "trav").unwrap();
517        let err = resolve_pack_artifact(&pack, "../etc/passwd").unwrap_err();
518        assert!(err.to_string().contains("illegal") || err.to_string().contains("component"));
519    }
520
521    #[test]
522    fn manifest_write_is_transactional_replace() {
523        let dir = tempdir().unwrap();
524        let pack = dir.path().join("pack");
525        let m = write_fake_sine_pack(&pack, "tx").unwrap();
526        // Second write should replace without leaving predictable .tmp
527        write_manifest(&pack, &m).unwrap();
528        let leftovers: Vec<_> = fs::read_dir(&pack)
529            .unwrap()
530            .filter_map(|e| e.ok())
531            .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
532            .collect();
533        assert!(leftovers.is_empty(), "tmp left behind: {leftovers:?}");
534        assert!(pack.join(MANIFEST_FILENAME).is_file());
535    }
536
537    #[cfg(unix)]
538    #[test]
539    fn manifest_symlink_rejected() {
540        let dir = tempdir().unwrap();
541        let pack = dir.path().join("pack");
542        write_fake_sine_pack(&pack, "msym").unwrap();
543        let outside = dir.path().join("evil-manifest.json");
544        fs::write(&outside, b"{}").unwrap();
545        let manifest = pack.join(MANIFEST_FILENAME);
546        fs::remove_file(&manifest).unwrap();
547        std::os::unix::fs::symlink(&outside, &manifest).unwrap();
548        let err = load_pack_dir(&pack, true).unwrap_err();
549        assert!(
550            err.to_string().contains("symlink"),
551            "expected symlink rejection: {}",
552            err
553        );
554    }
555
556    #[test]
557    fn reverify_detects_post_verify_swap() {
558        let dir = tempdir().unwrap();
559        let pack = dir.path().join("pack");
560        let m = write_fake_sine_pack(&pack, "swap").unwrap();
561        let config = pack.join("config.json");
562        let expect = m.artifacts[0].sha256.clone().unwrap();
563        reverify_artifact_before_load(&config, Some(&expect)).unwrap();
564        fs::write(&config, b"mutated-after-verify").unwrap();
565        let err = reverify_artifact_before_load(&config, Some(&expect)).unwrap_err();
566        assert!(
567            err.to_string().contains("digest") || err.to_string().contains("changed"),
568            "expected digest change detection: {err}"
569        );
570    }
571
572    #[test]
573    fn stage_verified_isolates_from_source_swap() {
574        let dir = tempdir().unwrap();
575        let pack = dir.path().join("pack");
576        let m = write_fake_sine_pack(&pack, "stage").unwrap();
577        let config = pack.join("config.json");
578        let expect = m.artifacts[0].sha256.clone().unwrap();
579        let cache = dir.path().join("cache");
580        let staged =
581            stage_verified_for_load(&config, Some(&expect), &cache, "config.json").unwrap();
582        assert_ne!(staged, config);
583        assert!(staged.starts_with(cache.join("tts").join("verified-snaps")));
584        // Mutate pack path after staging — snap remains good.
585        fs::write(&config, b"mutated-after-stage").unwrap();
586        reverify_artifact_before_load(&staged, Some(&expect)).unwrap();
587        let again =
588            stage_verified_for_load(&config, Some(&expect), &cache, "config.json").unwrap_err();
589        assert!(
590            again.to_string().contains("digest") || again.to_string().contains("changed"),
591            "source mutation must fail re-stage: {again}"
592        );
593        // Existing snap still loadable.
594        let snap2 = stage_verified_for_load(&staged, Some(&expect), &cache, "config.json").unwrap();
595        assert_eq!(snap2, staged);
596    }
597}