1use std::path::{Path, PathBuf};
8use std::time::UNIX_EPOCH;
9
10use serde::{Deserialize, Serialize};
11
12use crate::primitives::private_document::{DocumentError, PrivateDocument};
13
14use super::{Profile, Role, SpeakerName, SpeakerNameError};
15
16const MAX_PROFILE_BYTES: u64 = 64 * 1024;
19
20pub struct ProfileStore {
22 directory: PathBuf,
23}
24
25impl ProfileStore {
26 pub fn open(directory: impl Into<PathBuf>) -> Self {
27 Self {
28 directory: directory.into(),
29 }
30 }
31
32 pub fn read_all(&self) -> Vec<Profile> {
38 let Ok(entries) = std::fs::read_dir(&self.directory) else {
39 return Vec::new();
40 };
41 let mut profiles = Vec::new();
42 for entry in entries.flatten() {
43 let path = entry.path();
44 if path.extension().is_none_or(|extension| extension != "json") {
45 tracing::warn!(
49 "a file in the speakers directory is not a profile this build reads"
50 );
51 continue;
52 }
53 match self.read(&path) {
54 Ok(Some(profile)) => profiles.push(profile),
55 Ok(None) => {}
56 Err(error) => tracing::warn!(%error, "skipping an unreadable speaker profile"),
57 }
58 }
59 profiles.sort_by(|a, b| a.name.cmp(&b.name));
60 profiles
61 }
62
63 pub fn read_for(&self, engine: &str, dimension: usize) -> Vec<Profile> {
66 self.read_all()
67 .into_iter()
68 .filter(|profile| profile.engine == engine && profile.vector.len() == dimension)
69 .collect()
70 }
71
72 pub fn save(&self, profile: &Profile, replace: Replace) -> Result<(), ProfileError> {
79 let document = self.document(&profile.name);
80 if replace == Replace::Never && !document.claim().map_err(ProfileError::Write)? {
81 return Err(ProfileError::AlreadyStored);
82 }
83 document
84 .prepare(&StoredProfile::from(profile))
85 .map_err(ProfileError::Write)?
86 .commit()
87 .map_err(ProfileError::Write)?
88 .confirm_durability()
89 .map_err(ProfileError::DurabilityUnconfirmed)
90 }
91
92 pub fn remove(&self, name: &SpeakerName) -> Result<bool, ProfileError> {
94 self.document(name).remove().map_err(ProfileError::Write)
95 }
96
97 fn document(&self, name: &SpeakerName) -> PrivateDocument {
98 PrivateDocument::new(
99 self.directory.join(format!("{name}.json")),
100 MAX_PROFILE_BYTES,
101 )
102 }
103
104 fn read(&self, path: &Path) -> Result<Option<Profile>, ProfileError> {
105 let name = path
106 .file_stem()
107 .and_then(|stem| stem.to_str())
108 .ok_or(ProfileError::UnnamedFile)?;
109 let name = SpeakerName::new(name).map_err(ProfileError::Name)?;
110
111 let document = PrivateDocument::new(path, MAX_PROFILE_BYTES);
112 let Some(stored) = document
113 .read::<StoredProfile>()
114 .map_err(ProfileError::Read)?
115 else {
116 return Ok(None);
117 };
118
119 let vector = super::normalized(stored.vector).ok_or(ProfileError::NoDirection)?;
120 Ok(Some(Profile {
121 name,
122 role: stored.role,
123 engine: stored.engine,
124 enrolled_at: UNIX_EPOCH
127 .checked_add(std::time::Duration::from_secs(stored.enrolled_at))
128 .ok_or(ProfileError::UnreadableMoment)?,
129 vector,
130 }))
131 }
132}
133
134#[derive(Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138struct StoredProfile {
139 role: Role,
140 engine: String,
141 enrolled_at: u64,
143 vector: Vec<f32>,
144}
145
146impl From<&Profile> for StoredProfile {
147 fn from(profile: &Profile) -> Self {
148 Self {
149 role: profile.role,
150 engine: profile.engine.clone(),
151 enrolled_at: profile
152 .enrolled_at
153 .duration_since(UNIX_EPOCH)
154 .unwrap_or_default()
155 .as_secs(),
156 vector: profile.vector.clone(),
157 }
158 }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum Replace {
164 Never,
165 Allowed,
166}
167
168#[derive(Debug, thiserror::Error)]
169pub enum ProfileError {
170 #[error("the profile could not be read")]
171 Read(#[source] DocumentError),
172
173 #[error("the profile could not be written")]
174 Write(#[source] DocumentError),
175
176 #[error("the profile is stored, but its durability could not be confirmed")]
177 DurabilityUnconfirmed(#[source] DocumentError),
178
179 #[error("a profile of that name is already stored")]
180 AlreadyStored,
181
182 #[error("the profile records a moment outside any calendar")]
183 UnreadableMoment,
184
185 #[error("the profile filename is not a usable speaker name")]
186 Name(#[source] SpeakerNameError),
187
188 #[error("the profile filename has no name in it")]
189 UnnamedFile,
190
191 #[error("the profile embedding has no direction")]
192 NoDirection,
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use std::os::unix::fs::PermissionsExt;
199
200 fn store() -> (tempfile::TempDir, ProfileStore) {
202 let home = tempfile::tempdir().expect("a temporary directory");
203 let store = ProfileStore::open(home.path());
204 (home, store)
205 }
206
207 fn profile(name: &str, role: Role, engine: &str, vector: Vec<f32>) -> Profile {
208 Profile {
209 name: SpeakerName::new(name).expect("the name is usable"),
210 role,
211 engine: engine.to_owned(),
212 enrolled_at: UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
213 vector,
214 }
215 }
216
217 #[test]
218 fn a_saved_profile_reads_back() {
219 let (_home, store) = store();
220 let alex = profile("alex", Role::Owner, "wespeaker", vec![1.0, 0.0]);
221 store
222 .save(&alex, Replace::Never)
223 .expect("the profile is written");
224
225 assert_eq!(store.read_all(), vec![alex]);
226 }
227
228 #[test]
229 fn profiles_come_back_sorted_by_name() {
230 let (_home, store) = store();
231 for name in ["zoe", "ada"] {
232 store
233 .save(
234 &profile(name, Role::Guest, "wespeaker", vec![0.0, 1.0]),
235 Replace::Never,
236 )
237 .expect("the profile is written");
238 }
239
240 let names: Vec<String> = store
241 .read_all()
242 .iter()
243 .map(|profile| profile.name.to_string())
244 .collect();
245 assert_eq!(names, ["ada", "zoe"]);
246 }
247
248 #[test]
249 fn a_profile_from_another_engine_is_not_offered_to_this_one() {
250 let (_home, store) = store();
251 store
252 .save(
253 &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]),
254 Replace::Never,
255 )
256 .expect("the profile is written");
257 store
258 .save(
259 &profile("zoe", Role::Owner, "other", vec![1.0, 0.0]),
260 Replace::Never,
261 )
262 .expect("the profile is written");
263
264 let usable = store.read_for("wespeaker", 2);
265 assert_eq!(usable.len(), 1);
266 assert_eq!(usable[0].name.as_str(), "ada");
267 }
268
269 #[test]
270 fn a_profile_of_another_width_is_not_offered_either() {
271 let (_home, store) = store();
272 store
273 .save(
274 &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0, 0.0]),
275 Replace::Never,
276 )
277 .expect("the profile is written");
278
279 assert!(store.read_for("wespeaker", 2).is_empty());
280 }
281
282 #[test]
283 fn one_unreadable_profile_does_not_cost_the_others() {
284 let (home, store) = store();
285 store
286 .save(
287 &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]),
288 Replace::Never,
289 )
290 .expect("the profile is written");
291 std::fs::write(home.path().join("broken.json"), b"{").expect("the file is written");
292
293 let names: Vec<String> = store
294 .read_all()
295 .iter()
296 .map(|profile| profile.name.to_string())
297 .collect();
298 assert_eq!(names, ["ada"]);
299 }
300
301 #[test]
302 fn an_empty_speakers_directory_means_nobody_enrolled() {
303 let home = tempfile::tempdir().expect("a temporary directory");
304 assert!(
305 ProfileStore::open(home.path().join("absent"))
306 .read_all()
307 .is_empty()
308 );
309 }
310
311 #[test]
312 fn removing_says_whether_anyone_was_enrolled() {
313 let (_home, store) = store();
314 let ada = SpeakerName::new("ada").expect("the name is usable");
315 store
316 .save(
317 &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]),
318 Replace::Never,
319 )
320 .expect("the profile is written");
321
322 assert!(store.remove(&ada).expect("the delete succeeds"));
323 assert!(!store.remove(&ada).expect("the delete succeeds"));
324 assert!(store.read_all().is_empty());
325 }
326
327 #[test]
328 fn a_second_profile_of_the_same_name_is_refused_unless_asked() {
329 let (_home, store) = store();
330 let first = profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]);
331 store
332 .save(&first, Replace::Never)
333 .expect("the profile is written");
334
335 let second = profile("ada", Role::Guest, "wespeaker", vec![0.0, 1.0]);
336 assert!(matches!(
337 store.save(&second, Replace::Never),
338 Err(ProfileError::AlreadyStored)
339 ));
340 assert_eq!(store.read_all(), vec![first]);
341
342 store
343 .save(&second, Replace::Allowed)
344 .expect("the profile is replaced");
345 assert_eq!(store.read_all(), vec![second]);
346 }
347
348 #[test]
349 fn a_profile_recording_an_impossible_moment_is_skipped() {
350 let (home, store) = store();
351 store
352 .save(
353 &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]),
354 Replace::Never,
355 )
356 .expect("the profile is written");
357 let tampered = home.path().join("zoe.json");
358 std::fs::write(
359 &tampered,
360 br#"{"role":"guest","engine":"wespeaker","enrolled_at":18446744073709551615,"vector":[1.0,0.0]}"#,
361 )
362 .expect("the file is written");
363 std::fs::set_permissions(&tampered, std::fs::Permissions::from_mode(0o600))
364 .expect("the mode is set");
365
366 let names: Vec<String> = store
367 .read_all()
368 .iter()
369 .map(|profile| profile.name.to_string())
370 .collect();
371 assert_eq!(names, ["ada"]);
372 }
373
374 #[test]
375 fn a_name_that_could_escape_the_directory_is_refused() {
376 for name in ["", " ada", "ada ", "../ada", "ada/zoe", ".hidden", "a\nb"] {
377 assert!(
378 SpeakerName::new(name).is_err(),
379 "{name:?} must not become a filename"
380 );
381 }
382 }
383
384 #[test]
385 fn an_ordinary_name_is_accepted() {
386 assert_eq!(
387 SpeakerName::new("Alex Morgan")
388 .expect("the name is usable")
389 .as_str(),
390 "Alex Morgan"
391 );
392 }
393}