1use std::io::Read;
5use std::path::{Path, PathBuf};
6
7use sha2::{Digest as _, Sha256};
8
9use crate::Error;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ModelFiles {
14 pub model: PathBuf,
16 pub voices: PathBuf,
18 pub digests: Vec<(PathBuf, String)>,
20}
21
22impl ModelFiles {
23 pub fn new(model: PathBuf, voices: PathBuf) -> Self {
25 Self { model, voices, digests: Vec::new() }
26 }
27
28 pub fn with_digests(mut self, digests: Vec<(PathBuf, String)>) -> Self {
30 self.digests = digests;
31 self
32 }
33
34 pub fn verify(&self) -> Result<(), Error> {
37 if !self.model.is_file() {
38 return Err(Error::ModelMissing(self.model.display().to_string()));
39 }
40 if !self.voices.is_dir() {
41 return Err(Error::ModelMissing(self.voices.display().to_string()));
42 }
43 for (path, digest) in &self.digests {
44 verify_digest(path, Some(digest))?;
45 }
46 Ok(())
47 }
48
49 pub fn installed_voices(&self) -> Result<Vec<String>, Error> {
51 let entries = std::fs::read_dir(&self.voices)
52 .map_err(|error| Error::ModelMissing(format!("{}: {error}", self.voices.display())))?;
53 let mut voices = Vec::new();
54 for entry in entries {
55 let entry = entry
56 .map_err(|error| Error::Load(format!("{}: {error}", self.voices.display())))?;
57 let path = entry.path();
58 let is_style = path.extension().is_some_and(|extension| extension == "bin");
59 if !is_style {
60 continue;
61 }
62 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
63 continue;
64 };
65 voices.push(stem.to_owned());
66 }
67 voices.sort();
68 Ok(voices)
69 }
70}
71
72pub fn verify_digest(path: &Path, expected: Option<&str>) -> Result<(), Error> {
74 let Some(expected) = expected else {
75 return Ok(());
76 };
77 let mut file = std::fs::File::open(path)
78 .map_err(|error| Error::ModelMissing(format!("{}: {error}", path.display())))?;
79 let mut hasher = Sha256::new();
80 let mut buffer = vec![0_u8; 1 << 20];
81 loop {
82 let read = file
83 .read(&mut buffer)
84 .map_err(|error| Error::Load(format!("{}: {error}", path.display())))?;
85 if read == 0 {
86 break;
87 }
88 hasher.update(&buffer[..read]);
89 }
90 let actual = hex::encode(hasher.finalize());
91 if actual.eq_ignore_ascii_case(expected) {
92 Ok(())
93 } else {
94 Err(Error::Load(format!(
95 "{}: the entry declares sha256 {expected}, and the file is {actual}",
96 path.display(),
97 )))
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::ModelFiles;
104
105 fn model(dir: &std::path::Path) -> ModelFiles {
106 ModelFiles::new(dir.join("model.onnx"), dir.join("voices"))
107 }
108
109 #[test]
110 fn the_installed_voices_are_the_style_file_stems_sorted() {
111 let dir = tempfile::tempdir().expect("a temp dir");
112 let voices = dir.path().join("voices");
113 std::fs::create_dir(&voices).expect("the voice dir is made");
114 for name in ["bf_emma.bin", "af_heart.bin", "am_adam.bin"] {
115 std::fs::write(voices.join(name), b"styles").expect("a style is written");
116 }
117 std::fs::write(voices.join("readme.txt"), b"not a voice").expect("a stray file");
118 let files = model(dir.path());
119 assert_eq!(
120 files.installed_voices().expect("the directory reads"),
121 vec!["af_heart".to_owned(), "am_adam".to_owned(), "bf_emma".to_owned()],
122 );
123 }
124
125 #[test]
126 fn a_file_whose_digest_does_not_match_is_refused_by_name() {
127 let dir = tempfile::tempdir().expect("a temp dir");
128 std::fs::create_dir(dir.path().join("voices")).expect("the voice dir is made");
129 let path = dir.path().join("model.onnx");
130 std::fs::write(&path, b"weights").expect("the file is written");
131 let files = model(dir.path()).with_digests(vec![(path.clone(), "00".repeat(32))]);
132 let error = files.verify().expect_err("a wrong digest is refused");
133 assert!(error.to_string().contains("model.onnx"), "got: {error}");
134 }
135
136 #[test]
137 fn a_file_whose_digest_matches_is_accepted() {
138 let dir = tempfile::tempdir().expect("a temp dir");
139 std::fs::create_dir(dir.path().join("voices")).expect("the voice dir is made");
140 let path = dir.path().join("model.onnx");
141 std::fs::write(&path, b"weights").expect("the file is written");
142 let digest = {
143 use sha2::{Digest as _, Sha256};
144 let mut hasher = Sha256::new();
145 hasher.update(b"weights");
146 hex::encode(hasher.finalize())
147 };
148 let files = model(dir.path()).with_digests(vec![(path, digest)]);
149 files.verify().expect("the declared digest matches");
150 }
151
152 #[test]
153 fn a_missing_model_file_is_refused_before_a_load() {
154 let dir = tempfile::tempdir().expect("a temp dir");
155 std::fs::create_dir(dir.path().join("voices")).expect("the voice dir is made");
156 let error = model(dir.path()).verify().expect_err("a missing model is refused");
157 assert!(error.to_string().contains("model.onnx"), "got: {error}");
158 }
159}