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) -> Result<StoredProfiles, ProfileError> {
36 let entries = match std::fs::read_dir(&self.directory) {
37 Ok(entries) => entries,
38 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
39 return Ok(StoredProfiles::default());
40 }
41 Err(error) => return Err(ProfileError::List(error)),
42 };
43 let mut stored = StoredProfiles::default();
44 for entry in entries {
45 let path = entry.map_err(ProfileError::List)?.path();
46 if path
48 .file_name()
49 .is_some_and(|name| name.as_encoded_bytes().starts_with(b"."))
50 {
51 continue;
52 }
53 if path.extension().is_none_or(|extension| extension != "json") {
54 tracing::warn!(
56 "a file in the speakers directory is not a profile this build reads"
57 );
58 continue;
59 }
60 match self.read(&path) {
61 Ok(Some(profile)) => stored.profiles.push(profile),
62 Ok(None) => {}
63 Err(error) => stored.unreadable.push(error),
64 }
65 }
66 stored.profiles.sort_by(|a, b| a.name.cmp(&b.name));
67 Ok(stored)
68 }
69
70 pub fn read_for(&self, engine: &str, dimension: usize) -> Result<StoredProfiles, ProfileError> {
73 let mut stored = self.read_all()?;
74 stored
75 .profiles
76 .retain(|profile| profile.engine == engine && profile.vector.len() == dimension);
77 Ok(stored)
78 }
79
80 pub fn save(&self, profile: &Profile, replace: Replace) -> Result<(), ProfileError> {
87 let document = self.document(&profile.name);
88 if replace == Replace::Never && !document.claim().map_err(ProfileError::Write)? {
89 return Err(ProfileError::AlreadyStored);
90 }
91 document
92 .prepare(&StoredProfile::from(profile))
93 .map_err(ProfileError::Write)?
94 .commit()
95 .map_err(ProfileError::Write)?
96 .confirm_durability()
97 .map_err(ProfileError::DurabilityUnconfirmed)
98 }
99
100 pub fn remove(&self, name: &SpeakerName) -> Result<bool, ProfileError> {
102 self.document(name).remove().map_err(ProfileError::Write)
103 }
104
105 pub fn contains(&self, name: &SpeakerName) -> bool {
108 self.path(name).exists()
109 }
110
111 fn document(&self, name: &SpeakerName) -> PrivateDocument {
112 PrivateDocument::new(self.path(name), MAX_PROFILE_BYTES)
113 }
114
115 fn path(&self, name: &SpeakerName) -> PathBuf {
116 self.directory.join(format!("{name}.json"))
117 }
118
119 fn read(&self, path: &Path) -> Result<Option<Profile>, ProfileError> {
120 let name = path
121 .file_stem()
122 .and_then(|stem| stem.to_str())
123 .ok_or(ProfileError::UnnamedFile)?;
124 let name = SpeakerName::new(name).map_err(ProfileError::Name)?;
125
126 let document = PrivateDocument::new(path, MAX_PROFILE_BYTES);
127 let Some(stored) = document
128 .read::<StoredProfile>()
129 .map_err(ProfileError::Read)?
130 else {
131 return Ok(None);
132 };
133
134 let vector = super::normalized(stored.vector).ok_or(ProfileError::NoDirection)?;
135 Ok(Some(Profile {
136 name,
137 role: stored.role,
138 engine: stored.engine,
139 enrolled_at: UNIX_EPOCH
142 .checked_add(std::time::Duration::from_secs(stored.enrolled_at))
143 .ok_or(ProfileError::UnreadableMoment)?,
144 vector,
145 }))
146 }
147}
148
149#[derive(Debug, Default)]
150pub struct StoredProfiles {
151 pub profiles: Vec<Profile>,
153 pub unreadable: Vec<ProfileError>,
156}
157
158#[derive(Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162struct StoredProfile {
163 role: Role,
164 engine: String,
165 enrolled_at: u64,
167 vector: Vec<f32>,
168}
169
170impl From<&Profile> for StoredProfile {
171 fn from(profile: &Profile) -> Self {
172 Self {
173 role: profile.role,
174 engine: profile.engine.clone(),
175 enrolled_at: profile
176 .enrolled_at
177 .duration_since(UNIX_EPOCH)
178 .unwrap_or_default()
179 .as_secs(),
180 vector: profile.vector.clone(),
181 }
182 }
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum Replace {
188 Never,
189 Allowed,
190}
191
192#[derive(Debug, thiserror::Error)]
193pub enum ProfileError {
194 #[error("the speakers directory could not be listed")]
195 List(#[source] std::io::Error),
196
197 #[error("the profile could not be read")]
198 Read(#[source] DocumentError),
199
200 #[error("the profile could not be written")]
201 Write(#[source] DocumentError),
202
203 #[error("the profile is stored, but its durability could not be confirmed")]
204 DurabilityUnconfirmed(#[source] DocumentError),
205
206 #[error("a profile of that name is already stored")]
207 AlreadyStored,
208
209 #[error("the profile records a moment outside any calendar")]
210 UnreadableMoment,
211
212 #[error("the profile filename is not a usable speaker name")]
213 Name(#[source] SpeakerNameError),
214
215 #[error("the profile filename has no name in it")]
216 UnnamedFile,
217
218 #[error("the profile embedding has no direction")]
219 NoDirection,
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use std::os::unix::fs::PermissionsExt;
226
227 fn profiles(store: &ProfileStore) -> Vec<Profile> {
228 store.read_all().expect("the directory lists").profiles
229 }
230
231 fn names(store: &ProfileStore) -> Vec<String> {
232 profiles(store)
233 .iter()
234 .map(|profile| profile.name.to_string())
235 .collect()
236 }
237
238 fn store() -> (tempfile::TempDir, ProfileStore) {
240 let home = tempfile::tempdir().expect("a temporary directory");
241 let store = ProfileStore::open(home.path());
242 (home, store)
243 }
244
245 fn profile(name: &str, role: Role, engine: &str, vector: Vec<f32>) -> Profile {
246 Profile {
247 name: SpeakerName::new(name).expect("the name is usable"),
248 role,
249 engine: engine.to_owned(),
250 enrolled_at: UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000),
251 vector,
252 }
253 }
254
255 #[test]
256 fn a_saved_profile_reads_back() {
257 let (_home, store) = store();
258 let alex = profile("alex", Role::Owner, "wespeaker", vec![1.0, 0.0]);
259 store
260 .save(&alex, Replace::Never)
261 .expect("the profile is written");
262
263 assert_eq!(profiles(&store), vec![alex]);
264 }
265
266 #[test]
267 fn profiles_come_back_sorted_by_name() {
268 let (_home, store) = store();
269 for name in ["zoe", "ada"] {
270 store
271 .save(
272 &profile(name, Role::Guest, "wespeaker", vec![0.0, 1.0]),
273 Replace::Never,
274 )
275 .expect("the profile is written");
276 }
277
278 let names = names(&store);
279 assert_eq!(names, ["ada", "zoe"]);
280 }
281
282 #[test]
283 fn a_profile_from_another_engine_is_not_offered_to_this_one() {
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 store
292 .save(
293 &profile("zoe", Role::Owner, "other", vec![1.0, 0.0]),
294 Replace::Never,
295 )
296 .expect("the profile is written");
297
298 let usable = store
299 .read_for("wespeaker", 2)
300 .expect("the directory lists")
301 .profiles;
302 assert_eq!(usable.len(), 1);
303 assert_eq!(usable[0].name.as_str(), "ada");
304 }
305
306 #[test]
307 fn a_profile_of_another_width_is_not_offered_either() {
308 let (_home, store) = store();
309 store
310 .save(
311 &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0, 0.0]),
312 Replace::Never,
313 )
314 .expect("the profile is written");
315
316 let stored = store.read_for("wespeaker", 2).expect("the directory lists");
317 assert!(stored.profiles.is_empty());
318 }
319
320 #[test]
321 fn one_unreadable_profile_does_not_hide_the_others() {
322 let (home, store) = store();
323 store
324 .save(
325 &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]),
326 Replace::Never,
327 )
328 .expect("the profile is written");
329 std::fs::write(home.path().join("broken.json"), b"{").expect("the file is written");
330
331 let stored = store.read_all().expect("the directory lists");
332 let names: Vec<&str> = stored
333 .profiles
334 .iter()
335 .map(|profile| profile.name.as_str())
336 .collect();
337 assert_eq!(names, ["ada"]);
338 assert_eq!(stored.unreadable.len(), 1);
339 }
340
341 #[test]
342 fn an_empty_profiles_directory_means_nobody_enrolled() {
343 let home = tempfile::tempdir().expect("a temporary directory");
344 assert!(profiles(&ProfileStore::open(home.path().join("absent"))).is_empty());
345 }
346
347 #[test]
348 fn removing_says_whether_anyone_was_enrolled() {
349 let (_home, store) = store();
350 let ada = SpeakerName::new("ada").expect("the name is usable");
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
358 assert!(store.remove(&ada).expect("the delete succeeds"));
359 assert!(!store.remove(&ada).expect("the delete succeeds"));
360 assert!(profiles(&store).is_empty());
361 }
362
363 #[test]
364 fn a_second_profile_of_the_same_name_is_refused_unless_asked() {
365 let (_home, store) = store();
366 let first = profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]);
367 store
368 .save(&first, Replace::Never)
369 .expect("the profile is written");
370
371 let second = profile("ada", Role::Guest, "wespeaker", vec![0.0, 1.0]);
372 assert!(matches!(
373 store.save(&second, Replace::Never),
374 Err(ProfileError::AlreadyStored)
375 ));
376 assert_eq!(profiles(&store), vec![first]);
377
378 store
379 .save(&second, Replace::Allowed)
380 .expect("the profile is replaced");
381 assert_eq!(profiles(&store), vec![second]);
382 }
383
384 #[test]
385 fn a_profile_recording_an_impossible_moment_is_skipped() {
386 let (home, store) = store();
387 store
388 .save(
389 &profile("ada", Role::Owner, "wespeaker", vec![1.0, 0.0]),
390 Replace::Never,
391 )
392 .expect("the profile is written");
393 let tampered = home.path().join("zoe.json");
394 std::fs::write(
395 &tampered,
396 br#"{"role":"guest","engine":"wespeaker","enrolled_at":18446744073709551615,"vector":[1.0,0.0]}"#,
397 )
398 .expect("the file is written");
399 std::fs::set_permissions(&tampered, std::fs::Permissions::from_mode(0o600))
400 .expect("the mode is set");
401
402 let names = names(&store);
403 assert_eq!(names, ["ada"]);
404 }
405
406 #[test]
407 fn a_name_that_could_escape_the_directory_is_refused() {
408 for name in ["", " ada", "ada ", "../ada", "ada/zoe", ".hidden", "a\nb"] {
409 assert!(
410 SpeakerName::new(name).is_err(),
411 "{name:?} must not become a filename"
412 );
413 }
414 }
415
416 #[test]
417 fn a_name_that_reads_like_an_anonymous_speaker_is_refused() {
418 assert!(SpeakerName::new("Speaker 2").is_err());
419 assert!(SpeakerName::new("Speaker Two").is_ok());
420 }
421
422 #[test]
423 fn an_ordinary_name_is_accepted() {
424 assert_eq!(
425 SpeakerName::new("Alex Morgan")
426 .expect("the name is usable")
427 .as_str(),
428 "Alex Morgan"
429 );
430 }
431}