1use 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
12pub const MANIFEST_FILENAME: &str = "aurum-tts-manifest.json";
14
15pub const MAX_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
17
18pub fn custom_pack_cache_dir(cache_dir: &Path) -> PathBuf {
20 cache_dir.join("tts").join("custom")
21}
22
23pub 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 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
82fn 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
115pub 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
153pub 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 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 reverify_artifact_before_load(&dest, Some(&expect))?;
184 return Ok(dest);
185 }
186 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 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
234pub 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 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
308pub 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 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 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
397pub 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
418pub 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 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 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 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 let snap2 = stage_verified_for_load(&staged, Some(&expect), &cache, "config.json").unwrap();
595 assert_eq!(snap2, staged);
596 }
597}