1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::fs::OpenOptions;
5use std::num::NonZeroUsize;
6use std::path::{Path, PathBuf};
7use std::sync::{Mutex, MutexGuard};
8use std::time::Duration;
9
10use aes_gcm::aead::{Aead, KeyInit, Payload};
11use aes_gcm::{Aes256Gcm, Nonce};
12use hkdf::Hkdf;
13use hmac::{Hmac, Mac};
14use lru::LruCache;
15use rusqlite::{params, Connection, OptionalExtension};
16use sha2::Sha256;
17use veil_engine::{EntityKind, ScopeId, Vault};
18
19mod managed;
20pub use managed::{ManagedVault, RamVault, VaultInfo};
21
22pub const VERSION: &str = env!("CARGO_PKG_VERSION");
25
26pub type MasterKey = [u8; 32];
28
29#[derive(Debug, thiserror::Error)]
31pub enum Error {
32 #[error("database: {0}")]
34 Db(#[from] rusqlite::Error),
35 #[error("key file {path}: {error}")]
37 KeyFile {
38 path: PathBuf,
40 error: std::io::Error,
42 },
43 #[error("key file {0} must hold 32 bytes or 64 hex characters")]
45 KeyFormat(PathBuf),
46 #[error("keychain: {0}")]
48 Keychain(String),
49 #[error("the vault was created with a different key; refusing to open it")]
51 WrongKey,
52 #[error("decryption failed for a vault entry")]
54 Decrypt,
55 #[error("{0}")]
57 Io(#[from] std::io::Error),
58 #[error("internal lock poisoned")]
60 Poisoned,
61}
62
63impl From<Error> for veil_engine::VaultError {
64 fn from(e: Error) -> Self {
65 veil_engine::VaultError::new(e)
66 }
67}
68
69#[derive(Clone, Debug)]
71pub enum KeySource {
72 Keychain {
75 service: String,
77 account: String,
79 },
80 File(PathBuf),
83}
84
85pub const KEYCHAIN_SERVICE: &str = "Veil";
87
88impl KeySource {
89 pub fn default_keychain() -> Self {
91 KeySource::Keychain { service: KEYCHAIN_SERVICE.into(), account: "vault-master-key".into() }
92 }
93
94 pub fn load_or_create(&self) -> Result<MasterKey, Error> {
96 match self {
97 KeySource::Keychain { service, account } => {
98 let entry = keyring::Entry::new(service, account).map_err(|e| Error::Keychain(e.to_string()))?;
99 match entry.get_password() {
100 Ok(hex) => parse_hex(hex.trim()).ok_or_else(|| Error::Keychain("stored key is malformed".into())),
101 Err(keyring::Error::NoEntry) => {
102 let key = random_key();
103 entry.set_password(&to_hex(&key)).map_err(|e| Error::Keychain(e.to_string()))?;
104 Ok(key)
105 }
106 Err(e) => Err(Error::Keychain(e.to_string())),
107 }
108 }
109 KeySource::File(path) => {
110 let io = |error| Error::KeyFile { path: path.clone(), error };
111 match std::fs::read(path) {
112 Ok(bytes) => {
113 if let Ok(key) = <MasterKey>::try_from(bytes.as_slice()) {
114 return Ok(key);
115 }
116 std::str::from_utf8(&bytes)
117 .ok()
118 .and_then(|s| parse_hex(s.trim()))
119 .ok_or_else(|| Error::KeyFormat(path.clone()))
120 }
121 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
122 let key = random_key();
123 if let Some(dir) = path.parent() {
124 std::fs::create_dir_all(dir).map_err(io)?;
125 }
126 let mut f = private_file(path).map_err(io)?;
127 std::io::Write::write_all(&mut f, to_hex(&key).as_bytes()).map_err(io)?;
128 Ok(key)
129 }
130 Err(e) => Err(io(e)),
131 }
132 }
133 }
134 }
135}
136
137struct Keys {
138 enc: Aes256Gcm,
139 mac: [u8; 32],
140 scope: [u8; 32],
141}
142
143impl Keys {
144 fn derive(master: &MasterKey) -> Self {
145 let hk = Hkdf::<Sha256>::new(None, master);
146 let mut enc = [0u8; 32];
147 let mut mac = [0u8; 32];
148 let mut scope = [0u8; 32];
149 hk.expand(b"veil vault encryption v1", &mut enc).expect("32 bytes is a valid HKDF length");
150 hk.expand(b"veil vault lookup v1", &mut mac).expect("32 bytes is a valid HKDF length");
151 hk.expand(b"veil scope v1", &mut scope).expect("32 bytes is a valid HKDF length");
152 Keys { enc: Aes256Gcm::new_from_slice(&enc).expect("32-byte key"), mac, scope }
153 }
154
155 fn mac(&self, parts: &[&[u8]]) -> Vec<u8> {
156 let mut m = <Hmac<Sha256> as Mac>::new_from_slice(&self.mac).expect("HMAC accepts any key length");
157 for p in parts {
158 m.update(&(p.len() as u64).to_le_bytes());
159 m.update(p);
160 }
161 m.finalize().into_bytes().to_vec()
162 }
163}
164
165type ValueKey = (ScopeId, EntityKind, Vec<u8>);
166type NumberKey = (ScopeId, EntityKind, u64);
167
168struct Caches {
169 by_value: LruCache<ValueKey, (u64, i64)>,
170 by_number: LruCache<NumberKey, (String, i64)>,
171}
172
173const TOUCH_INTERVAL: i64 = 3600;
175
176pub struct SqliteVault {
179 conn: Mutex<Connection>,
180 caches: Mutex<Caches>,
181 keys: Keys,
182 ttl: Duration,
183 path: PathBuf,
184}
185
186impl SqliteVault {
187 pub fn open(path: &Path, master: &MasterKey, ttl: Duration) -> Result<Self, Error> {
190 if let Some(dir) = path.parent() {
191 std::fs::create_dir_all(dir)?;
192 }
193 if !path.exists() {
194 private_file(path)?;
195 }
196 let conn = Connection::open(path)?;
197 conn.execute_batch(
198 "PRAGMA journal_mode = WAL;
199 PRAGMA synchronous = NORMAL;
200 PRAGMA secure_delete = ON;
201 CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value BLOB NOT NULL);
202 CREATE TABLE IF NOT EXISTS entries (
203 scope TEXT NOT NULL, kind TEXT NOT NULL, n INTEGER NOT NULL,
204 value_mac BLOB NOT NULL, value_ct BLOB NOT NULL,
205 created_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL,
206 PRIMARY KEY (scope, kind, value_mac));
207 CREATE UNIQUE INDEX IF NOT EXISTS entries_n ON entries (scope, kind, n);
208 CREATE INDEX IF NOT EXISTS entries_used ON entries (last_used_at);
209 CREATE TABLE IF NOT EXISTS counters (
210 scope TEXT NOT NULL, kind TEXT NOT NULL, last INTEGER NOT NULL, PRIMARY KEY (scope, kind));
211 CREATE TABLE IF NOT EXISTS responses (
212 id_mac BLOB PRIMARY KEY, scope TEXT NOT NULL, created_at INTEGER NOT NULL);",
213 )?;
214 let keys = Keys::derive(master);
215 let check = keys.mac(&[b"veil key check"]);
216 let stored: Option<Vec<u8>> =
217 conn.query_row("SELECT value FROM meta WHERE key = 'key_check'", [], |r| r.get(0)).optional()?;
218 match stored {
219 Some(s) if s != check => return Err(Error::WrongKey),
220 Some(_) => {}
221 None => {
222 conn.execute("INSERT INTO meta (key, value) VALUES ('key_check', ?1)", params![check])?;
223 }
224 }
225 let cap = NonZeroUsize::new(4096).expect("non-zero");
226 Ok(SqliteVault {
227 conn: Mutex::new(conn),
228 caches: Mutex::new(Caches { by_value: LruCache::new(cap), by_number: LruCache::new(cap) }),
229 keys,
230 ttl,
231 path: path.to_owned(),
232 })
233 }
234
235 pub fn path(&self) -> &Path {
237 &self.path
238 }
239
240 fn conn(&self) -> Result<MutexGuard<'_, Connection>, Error> {
241 self.conn.lock().map_err(|_| Error::Poisoned)
242 }
243
244 fn caches(&self) -> Result<MutexGuard<'_, Caches>, Error> {
245 self.caches.lock().map_err(|_| Error::Poisoned)
246 }
247
248 fn aad(scope: &ScopeId, kind: &EntityKind, n: u64) -> Vec<u8> {
249 format!("{}\0{}\0{}", scope.as_str(), kind.as_str(), n).into_bytes()
250 }
251
252 fn encrypt(&self, scope: &ScopeId, kind: &EntityKind, n: u64, value: &str) -> Result<Vec<u8>, Error> {
253 let mut nonce = [0u8; 12];
254 getrandom::fill(&mut nonce).map_err(|e| Error::Io(std::io::Error::other(e.to_string())))?;
255 let aad = Self::aad(scope, kind, n);
256 let ct = self
257 .keys
258 .enc
259 .encrypt(Nonce::from_slice(&nonce), Payload { msg: value.as_bytes(), aad: &aad })
260 .map_err(|_| Error::Decrypt)?;
261 Ok([nonce.as_slice(), &ct].concat())
262 }
263
264 fn decrypt(&self, scope: &ScopeId, kind: &EntityKind, n: u64, blob: &[u8]) -> Result<String, Error> {
265 if blob.len() < 12 {
266 return Err(Error::Decrypt);
267 }
268 let aad = Self::aad(scope, kind, n);
269 let plain = self
270 .keys
271 .enc
272 .decrypt(Nonce::from_slice(&blob[..12]), Payload { msg: &blob[12..], aad: &aad })
273 .map_err(|_| Error::Decrypt)?;
274 String::from_utf8(plain).map_err(|_| Error::Decrypt)
275 }
276
277 fn touch(&self, conn: &Connection, scope: &ScopeId, kind: &EntityKind, n: u64, now: i64) -> Result<(), Error> {
278 conn.execute(
279 "UPDATE entries SET last_used_at = ?4 WHERE scope = ?1 AND kind = ?2 AND n = ?3 AND last_used_at < ?4 - ?5",
280 params![scope.as_str(), kind.as_str(), n as i64, now, TOUCH_INTERVAL],
281 )?;
282 Ok(())
283 }
284
285 fn get_or_create_inner(&self, scope: &ScopeId, kind: &EntityKind, value: &str) -> Result<u64, Error> {
286 let mac = self.keys.mac(&[scope.as_str().as_bytes(), kind.as_str().as_bytes(), value.as_bytes()]);
287 let key = (scope.clone(), kind.clone(), mac.clone());
288 let now = now();
289 if let Some(&(n, touched)) = self.caches()?.by_value.get(&key) {
290 if now - touched < TOUCH_INTERVAL {
291 return Ok(n);
292 }
293 }
294 let mut conn = self.conn()?;
295 let tx = conn.transaction()?;
296 let existing: Option<i64> = tx
297 .query_row(
298 "SELECT n FROM entries WHERE scope = ?1 AND kind = ?2 AND value_mac = ?3",
299 params![scope.as_str(), kind.as_str(), mac],
300 |r| r.get(0),
301 )
302 .optional()?;
303 let n = match existing {
304 Some(n) => {
305 self.touch(&tx, scope, kind, n as u64, now)?;
306 n as u64
307 }
308 None => {
309 let n: i64 = tx.query_row(
310 "INSERT INTO counters (scope, kind, last) VALUES (?1, ?2, 1)
311 ON CONFLICT (scope, kind) DO UPDATE SET last = last + 1 RETURNING last",
312 params![scope.as_str(), kind.as_str()],
313 |r| r.get(0),
314 )?;
315 let ct = self.encrypt(scope, kind, n as u64, value)?;
316 tx.execute(
317 "INSERT INTO entries (scope, kind, n, value_mac, value_ct, created_at, last_used_at)
318 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
319 params![scope.as_str(), kind.as_str(), n, mac, ct, now],
320 )?;
321 n as u64
322 }
323 };
324 tx.commit()?;
325 let mut caches = self.caches()?;
326 caches.by_value.put(key, (n, now));
327 caches.by_number.put((scope.clone(), kind.clone(), n), (value.to_owned(), now));
328 Ok(n)
329 }
330
331 fn lookup_inner(&self, scope: &ScopeId, kind: &EntityKind, n: u64) -> Result<Option<String>, Error> {
332 let key = (scope.clone(), kind.clone(), n);
333 let now = now();
334 if let Some((v, touched)) = self.caches()?.by_number.get(&key) {
335 if now - touched < TOUCH_INTERVAL {
336 return Ok(Some(v.clone()));
337 }
338 }
339 let conn = self.conn()?;
340 let blob: Option<Vec<u8>> = conn
341 .query_row(
342 "SELECT value_ct FROM entries WHERE scope = ?1 AND kind = ?2 AND n = ?3",
343 params![scope.as_str(), kind.as_str(), n as i64],
344 |r| r.get(0),
345 )
346 .optional()?;
347 let Some(blob) = blob else {
348 return Ok(None);
349 };
350 let value = self.decrypt(scope, kind, n, &blob)?;
351 self.touch(&conn, scope, kind, n, now)?;
352 self.caches()?.by_number.put(key, (value.clone(), now));
353 Ok(Some(value))
354 }
355
356 fn adopt_inner(&self, from: &ScopeId, into: &ScopeId) -> Result<bool, Error> {
357 if from == into {
358 return Ok(false);
359 }
360 let mut conn = self.conn()?;
361 let tx = conn.transaction()?;
362 let used: Option<i64> = tx
363 .query_row("SELECT 1 FROM counters WHERE scope = ?1 LIMIT 1", params![into.as_str()], |r| r.get(0))
364 .optional()?;
365 if used.is_some() {
366 return Ok(false);
367 }
368 tx.execute(
369 "INSERT INTO counters (scope, kind, last) SELECT ?2, kind, last FROM counters WHERE scope = ?1",
370 params![from.as_str(), into.as_str()],
371 )?;
372 let rows: Vec<(String, i64, Vec<u8>)> = {
373 let mut stmt = tx.prepare("SELECT kind, n, value_ct FROM entries WHERE scope = ?1")?;
374 let rows = stmt.query_map(params![from.as_str()], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
375 rows.collect::<Result<_, _>>()?
376 };
377 let now = now();
378 for (kind, n, blob) in rows {
379 let kind = EntityKind::new(&kind).map_err(|_| Error::Decrypt)?;
380 let value = self.decrypt(from, &kind, n as u64, &blob)?;
382 let mac = self.keys.mac(&[into.as_str().as_bytes(), kind.as_str().as_bytes(), value.as_bytes()]);
383 let ct = self.encrypt(into, &kind, n as u64, &value)?;
384 tx.execute(
385 "INSERT INTO entries (scope, kind, n, value_mac, value_ct, created_at, last_used_at)
386 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
387 params![into.as_str(), kind.as_str(), n, mac, ct, now],
388 )?;
389 }
390 tx.commit()?;
391 Ok(true)
392 }
393
394 fn clear_caches(&self) -> Result<(), Error> {
395 let mut c = self.caches()?;
396 c.by_value.clear();
397 c.by_number.clear();
398 Ok(())
399 }
400
401 fn purge_before(&self, cutoff: i64) -> Result<u64, Error> {
402 let conn = self.conn()?;
403 let n = conn.execute("DELETE FROM entries WHERE last_used_at < ?1", params![cutoff])?;
404 conn.execute("DELETE FROM responses WHERE created_at < ?1", params![cutoff])?;
405 drop(conn);
406 self.clear_caches()?;
407 Ok(n as u64)
408 }
409}
410
411impl Vault for SqliteVault {
412 fn get_or_create(&self, scope: &ScopeId, kind: &EntityKind, value: &str) -> Result<u64, veil_engine::VaultError> {
413 Ok(self.get_or_create_inner(scope, kind, value)?)
414 }
415
416 fn lookup(&self, scope: &ScopeId, kind: &EntityKind, n: u64) -> Result<Option<String>, veil_engine::VaultError> {
417 Ok(self.lookup_inner(scope, kind, n)?)
418 }
419
420 fn reserve(&self, scope: &ScopeId, kind: &EntityKind, n: u64) -> Result<(), veil_engine::VaultError> {
421 self.conn()?
422 .execute(
423 "INSERT INTO counters (scope, kind, last) VALUES (?1, ?2, ?3)
424 ON CONFLICT (scope, kind) DO UPDATE SET last = max(last, excluded.last)",
425 params![scope.as_str(), kind.as_str(), n as i64],
426 )
427 .map_err(Error::from)?;
428 Ok(())
429 }
430}
431
432impl ManagedVault for SqliteVault {
433 fn conversation_scope(&self, hint: &str) -> ScopeId {
434 ScopeId::keyed(&self.keys.scope, hint)
435 }
436
437 fn bind_scope(&self, id: &str, scope: &ScopeId) -> Result<(), Error> {
441 let id_mac = self.keys.mac(&[b"response", id.as_bytes()]);
442 self.conn()?.execute(
443 "INSERT OR REPLACE INTO responses (id_mac, scope, created_at) VALUES (?1, ?2, ?3)",
444 params![id_mac, scope.as_str(), now()],
445 )?;
446 Ok(())
447 }
448
449 fn bound_scope(&self, id: &str) -> Result<Option<ScopeId>, Error> {
450 let id_mac = self.keys.mac(&[b"response", id.as_bytes()]);
451 let scope: Option<String> = self
452 .conn()?
453 .query_row("SELECT scope FROM responses WHERE id_mac = ?1", params![id_mac], |r| r.get(0))
454 .optional()?;
455 Ok(scope.map(ScopeId::new))
456 }
457
458 fn info(&self) -> Result<VaultInfo, Error> {
459 let (entries, oldest): (i64, Option<i64>) =
460 self.conn()?
461 .query_row("SELECT count(*), min(created_at) FROM entries", [], |r| Ok((r.get(0)?, r.get(1)?)))?;
462 Ok(VaultInfo { entries: entries as u64, oldest_created_at: oldest, persistent: true })
463 }
464
465 fn clear(&self) -> Result<(), Error> {
466 self.conn()?.execute_batch("DELETE FROM entries; DELETE FROM responses; VACUUM;")?;
468 self.clear_caches()
469 }
470
471 fn purge_expired(&self) -> Result<u64, Error> {
472 let cutoff = now() - self.ttl.as_secs() as i64;
473 self.purge_before(cutoff)
474 }
475
476 fn adopt(&self, from: &ScopeId, into: &ScopeId) -> Result<bool, Error> {
477 self.adopt_inner(from, into)
478 }
479}
480
481fn now() -> i64 {
482 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
483}
484
485pub(crate) fn random_key() -> MasterKey {
486 let mut key = [0u8; 32];
487 getrandom::fill(&mut key).expect("system random source");
488 key
489}
490
491fn to_hex(b: &[u8]) -> String {
492 b.iter().map(|x| format!("{x:02x}")).collect()
493}
494
495fn parse_hex(s: &str) -> Option<MasterKey> {
496 if s.len() != 64 {
497 return None;
498 }
499 let mut key = [0u8; 32];
500 for (i, k) in key.iter_mut().enumerate() {
501 *k = u8::from_str_radix(s.get(i * 2..i * 2 + 2)?, 16).ok()?;
502 }
503 Some(key)
504}
505
506fn private_file(path: &Path) -> std::io::Result<std::fs::File> {
508 let mut opts = OpenOptions::new();
509 opts.write(true).create_new(true);
510 #[cfg(unix)]
511 std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
512 opts.open(path)
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 const DAY: Duration = Duration::from_secs(86_400);
520
521 fn kind(s: &str) -> EntityKind {
522 EntityKind::new(s).unwrap()
523 }
524
525 fn scope() -> ScopeId {
526 ScopeId::profile("default")
527 }
528
529 fn open(dir: &tempfile::TempDir, key: &MasterKey) -> SqliteVault {
530 SqliteVault::open(&dir.path().join("vault.db"), key, 7 * DAY).unwrap()
531 }
532
533 #[test]
534 fn adopt_copies_a_scope_into_an_unused_one() {
535 let dir = tempfile::tempdir().unwrap();
536 let key = random_key();
537 let v = open(&dir, &key);
538 let (draft, chat) = (v.conversation_scope("draft"), v.conversation_scope("chat"));
539 let email = kind("EMAIL");
540 assert_eq!(v.get_or_create(&draft, &email, "ana@example.org").unwrap(), 1);
541 assert_eq!(v.get_or_create(&draft, &email, "bob@example.org").unwrap(), 2);
542 assert!(v.adopt(&draft, &chat).unwrap());
543 assert_eq!(v.lookup(&chat, &email, 2).unwrap().as_deref(), Some("bob@example.org"));
544 assert_eq!(v.get_or_create(&chat, &email, "ana@example.org").unwrap(), 1);
546 assert_eq!(v.get_or_create(&chat, &email, "cy@example.org").unwrap(), 3);
547 let other = v.conversation_scope("other");
549 v.get_or_create(&other, &email, "cy@example.org").unwrap();
550 assert!(!v.adopt(&draft, &other).unwrap());
551 assert_eq!(v.lookup(&other, &email, 2).unwrap(), None);
552 assert!(!v.adopt(&draft, &chat).unwrap());
553 drop(v);
555 let v = open(&dir, &key);
556 assert_eq!(v.lookup(&chat, &email, 1).unwrap().as_deref(), Some("ana@example.org"));
557 }
558
559 #[test]
560 fn numbers_are_stable_and_survive_reopen() {
561 let dir = tempfile::tempdir().unwrap();
562 let key = random_key();
563 let v = open(&dir, &key);
564 assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "ana@example.com").unwrap(), 1);
565 assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "bob@example.com").unwrap(), 2);
566 assert_eq!(v.get_or_create(&scope(), &kind("PHONE"), "+40 721 555 019").unwrap(), 1);
567 assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "ana@example.com").unwrap(), 1);
568 drop(v);
569 let v = open(&dir, &key);
570 assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "ana@example.com").unwrap(), 1);
571 assert_eq!(v.lookup(&scope(), &kind("EMAIL"), 2).unwrap().as_deref(), Some("bob@example.com"));
572 assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "new@example.com").unwrap(), 3);
573 assert_eq!(v.lookup(&ScopeId::profile("other"), &kind("EMAIL"), 1).unwrap(), None);
574 }
575
576 #[test]
577 fn wrong_key_is_refused() {
578 let dir = tempfile::tempdir().unwrap();
579 drop(open(&dir, &random_key()));
580 let err = SqliteVault::open(&dir.path().join("vault.db"), &random_key(), DAY).err().unwrap();
581 assert!(matches!(err, Error::WrongKey));
582 }
583
584 #[test]
585 fn values_are_not_stored_in_plain_text() {
586 let dir = tempfile::tempdir().unwrap();
587 let v = open(&dir, &random_key());
588 v.get_or_create(&scope(), &kind("EMAIL"), "canary-7f3a9c@example.com").unwrap();
589 drop(v);
590 for entry in std::fs::read_dir(dir.path()).unwrap() {
591 let bytes = std::fs::read(entry.unwrap().path()).unwrap();
592 assert!(!bytes.windows(13).any(|w| w == b"canary-7f3a9c"), "plain value found on disk");
593 }
594 }
595
596 #[test]
597 fn ciphertexts_are_bound_to_their_slot() {
598 let dir = tempfile::tempdir().unwrap();
599 let v = open(&dir, &random_key());
600 v.get_or_create(&scope(), &kind("EMAIL"), "a@example.com").unwrap();
601 v.get_or_create(&scope(), &kind("EMAIL"), "b@example.com").unwrap();
602 {
604 let c = v.conn.lock().unwrap();
605 c.execute_batch(
606 "UPDATE entries SET n = -1 WHERE n = 1; UPDATE entries SET n = 1 WHERE n = 2; UPDATE entries SET n = 2 WHERE n = -1;",
607 )
608 .unwrap();
609 }
610 v.clear_caches().unwrap();
611 assert!(v.lookup(&scope(), &kind("EMAIL"), 1).is_err());
612 }
613
614 #[test]
615 fn reserve_skips_numbers() {
616 let dir = tempfile::tempdir().unwrap();
617 let v = open(&dir, &random_key());
618 v.reserve(&scope(), &kind("EMAIL"), 5).unwrap();
619 v.reserve(&scope(), &kind("EMAIL"), 2).unwrap();
620 assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "a@example.com").unwrap(), 6);
621 }
622
623 #[test]
624 fn purge_and_clear_keep_counters() {
625 let dir = tempfile::tempdir().unwrap();
626 let v = open(&dir, &random_key());
627 v.get_or_create(&scope(), &kind("EMAIL"), "a@example.com").unwrap();
628 v.bind_scope("resp_1", &scope()).unwrap();
629 assert_eq!(v.purge_expired().unwrap(), 0);
630 assert_eq!(v.purge_before(now() + 1).unwrap(), 1);
631 assert_eq!(v.info().unwrap().entries, 0);
632 assert_eq!(v.lookup(&scope(), &kind("EMAIL"), 1).unwrap(), None);
633 assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "a@example.com").unwrap(), 2);
634 v.clear().unwrap();
635 assert_eq!(v.info().unwrap().entries, 0);
636 assert_eq!(v.get_or_create(&scope(), &kind("EMAIL"), "a@example.com").unwrap(), 3);
637 }
638
639 #[test]
640 fn responses_and_conversation_scopes() {
641 let dir = tempfile::tempdir().unwrap();
642 let key = random_key();
643 let v = open(&dir, &key);
644 let conv = v.conversation_scope("system prompt + first message");
645 assert!(conv.as_str().starts_with("conv:"));
646 assert!(!conv.as_str().contains("system"));
647 assert_eq!(conv, open(&tempfile::tempdir().unwrap(), &key).conversation_scope("system prompt + first message"));
648 v.bind_scope("resp_abc", &conv).unwrap();
649 assert_eq!(v.bound_scope("resp_abc").unwrap(), Some(conv));
650 assert_eq!(v.bound_scope("resp_other").unwrap(), None);
651 }
652
653 #[test]
654 fn key_file_is_created_once_and_private() {
655 let dir = tempfile::tempdir().unwrap();
656 let path = dir.path().join("keys/vault.key");
657 let a = KeySource::File(path.clone()).load_or_create().unwrap();
658 let b = KeySource::File(path.clone()).load_or_create().unwrap();
659 assert_eq!(a, b);
660 #[cfg(unix)]
661 {
662 use std::os::unix::fs::PermissionsExt;
663 assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
664 }
665 std::fs::write(&path, "not a key").unwrap();
666 assert!(matches!(KeySource::File(path).load_or_create(), Err(Error::KeyFormat(_))));
667 }
668
669 #[test]
670 fn works_with_the_engine() {
671 use std::sync::Arc;
672 use veil_engine::Engine;
673 let dir = tempfile::tempdir().unwrap();
674 let key = random_key();
675 let text = "Mail ana@example.com about card 4111 1111 1111 1111";
676 let redacted = {
677 let e = Engine::builder().vault(Arc::new(open(&dir, &key))).build().unwrap();
678 e.redact_text(&scope(), text).unwrap().text
679 };
680 assert_eq!(redacted, "Mail [EMAIL_1] about card [CARD_1]");
681 let e = Engine::builder().vault(Arc::new(open(&dir, &key))).build().unwrap();
682 assert_eq!(e.restore_text(&scope(), &redacted).text, text);
683 }
684}