1use std::collections::HashMap;
31use std::path::Path;
32
33use async_trait::async_trait;
34use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
35use sqlx::{Row, SqlitePool};
36use tracing::{debug, info};
37
38use crate::{
39 EncryptionKey, Result, Secret, SecretMetadata, SecretsError, SecretsProvider, SecretsStore,
40};
41
42const DEFAULT_DB_FILENAME: &str = "secrets.sqlite";
44
45const SCHEMA: &str = r"
47CREATE TABLE IF NOT EXISTS secrets (
48 storage_key TEXT PRIMARY KEY NOT NULL,
49 encrypted_value BLOB NOT NULL,
50 name TEXT NOT NULL,
51 version INTEGER NOT NULL DEFAULT 1,
52 created_at TEXT NOT NULL,
53 updated_at TEXT NOT NULL
54);
55CREATE INDEX IF NOT EXISTS idx_secrets_prefix ON secrets(storage_key);
56";
57
58pub struct PersistentSecretsStore {
65 pool: SqlitePool,
66 key: EncryptionKey,
67}
68
69impl PersistentSecretsStore {
70 pub async fn open(path: impl AsRef<Path>, key: EncryptionKey) -> Result<Self> {
87 let path = path.as_ref();
88
89 let looks_like_dir = path
104 .extension()
105 .and_then(|e| e.to_str())
106 .is_none_or(|s| !matches!(s, "sqlite" | "db" | "sqlite3"));
107 if looks_like_dir && !path.exists() {
108 std::fs::create_dir_all(path).map_err(|e| {
109 SecretsError::Storage(format!(
110 "Failed to create secrets directory {}: {e}",
111 path.display()
112 ))
113 })?;
114 }
115
116 let db_path = if path.is_dir() {
118 path.join(DEFAULT_DB_FILENAME)
119 } else {
120 path.to_path_buf()
121 };
122
123 if let Some(parent) = db_path.parent() {
125 std::fs::create_dir_all(parent)
126 .map_err(|e| SecretsError::Storage(format!("Failed to create directory: {e}")))?;
127 }
128
129 let options = SqliteConnectOptions::new()
131 .filename(&db_path)
132 .create_if_missing(true)
133 .journal_mode(SqliteJournalMode::Wal)
134 .synchronous(SqliteSynchronous::Normal)
135 .busy_timeout(std::time::Duration::from_secs(30));
136
137 let pool = SqlitePoolOptions::new()
139 .max_connections(5)
140 .connect_with(options)
141 .await
142 .map_err(|e| {
143 SecretsError::Storage(format!(
144 "Failed to open database at {}: {e}",
145 db_path.display()
146 ))
147 })?;
148
149 sqlx::query(SCHEMA)
151 .execute(&pool)
152 .await
153 .map_err(|e| SecretsError::Storage(format!("Failed to initialize schema: {e}")))?;
154
155 info!("Opened persistent secrets store at {}", db_path.display());
156
157 Ok(Self { pool, key })
158 }
159
160 #[inline]
164 fn make_key(scope: &str, name: &str) -> String {
165 format!("{scope}:{name}")
166 }
167
168 #[allow(clippy::cast_possible_wrap)]
170 fn now_iso8601() -> String {
171 let now = std::time::SystemTime::now()
172 .duration_since(std::time::UNIX_EPOCH)
173 .unwrap_or_default()
174 .as_secs();
175 chrono::DateTime::from_timestamp(now as i64, 0).map_or_else(
177 || "1970-01-01T00:00:00Z".to_string(),
178 |dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
179 )
180 }
181
182 fn parse_timestamp(s: &str) -> i64 {
184 chrono::DateTime::parse_from_rfc3339(s).map_or(0, |dt| dt.timestamp())
185 }
186}
187
188#[async_trait]
189impl SecretsProvider for PersistentSecretsStore {
190 async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
191 let storage_key = Self::make_key(scope, name);
192
193 let row = sqlx::query("SELECT encrypted_value FROM secrets WHERE storage_key = ?")
194 .bind(&storage_key)
195 .fetch_optional(&self.pool)
196 .await
197 .map_err(|e| SecretsError::Storage(format!("Failed to query secret: {e}")))?;
198
199 match row {
200 Some(row) => {
201 let encrypted_value: Vec<u8> = row
202 .try_get("encrypted_value")
203 .map_err(|e| SecretsError::Storage(format!("Failed to read value: {e}")))?;
204
205 let decrypted = self.key.decrypt(&encrypted_value)?;
206
207 let value = String::from_utf8(decrypted)
208 .map_err(|e| SecretsError::Decryption(format!("Invalid UTF-8: {e}")))?;
209
210 debug!("Retrieved secret: {}", storage_key);
211 Ok(Secret::new(value))
212 }
213 None => Err(SecretsError::NotFound {
214 name: name.to_string(),
215 }),
216 }
217 }
218
219 async fn get_secrets(&self, scope: &str, names: &[&str]) -> Result<HashMap<String, Secret>> {
220 let mut results = HashMap::with_capacity(names.len());
221
222 for name in names {
223 if let Ok(secret) = self.get_secret(scope, name).await {
226 results.insert((*name).to_string(), secret);
227 }
228 }
229
230 Ok(results)
231 }
232
233 async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
234 let prefix = format!("{scope}:%");
235
236 let rows = sqlx::query(
237 "SELECT name, version, created_at, updated_at FROM secrets WHERE storage_key LIKE ? ORDER BY name",
238 )
239 .bind(&prefix)
240 .fetch_all(&self.pool)
241 .await
242 .map_err(|e| SecretsError::Storage(format!("Failed to list secrets: {e}")))?;
243
244 let mut results = Vec::with_capacity(rows.len());
245
246 for row in rows {
247 let name: String = row
248 .try_get("name")
249 .map_err(|e| SecretsError::Storage(format!("Failed to read name: {e}")))?;
250 let version: i64 = row
251 .try_get("version")
252 .map_err(|e| SecretsError::Storage(format!("Failed to read version: {e}")))?;
253 let created_at: String = row
254 .try_get("created_at")
255 .map_err(|e| SecretsError::Storage(format!("Failed to read created_at: {e}")))?;
256 let updated_at: String = row
257 .try_get("updated_at")
258 .map_err(|e| SecretsError::Storage(format!("Failed to read updated_at: {e}")))?;
259
260 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
261 results.push(SecretMetadata {
262 name,
263 version: version as u32,
264 created_at: Self::parse_timestamp(&created_at),
265 updated_at: Self::parse_timestamp(&updated_at),
266 });
267 }
268
269 debug!("Listed {} secrets in scope: {}", results.len(), scope);
270 Ok(results)
271 }
272
273 async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
274 let storage_key = Self::make_key(scope, name);
275
276 let row = sqlx::query("SELECT 1 FROM secrets WHERE storage_key = ?")
277 .bind(&storage_key)
278 .fetch_optional(&self.pool)
279 .await
280 .map_err(|e| SecretsError::Storage(format!("Failed to check existence: {e}")))?;
281
282 Ok(row.is_some())
283 }
284}
285
286#[async_trait]
287impl SecretsStore for PersistentSecretsStore {
288 async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()> {
289 let storage_key = Self::make_key(scope, name);
290
291 let encrypted = self.key.encrypt(value.expose().as_bytes())?;
293
294 let now = Self::now_iso8601();
295
296 let existing = sqlx::query("SELECT version, created_at FROM secrets WHERE storage_key = ?")
298 .bind(&storage_key)
299 .fetch_optional(&self.pool)
300 .await
301 .map_err(|e| SecretsError::Storage(format!("Failed to check existing: {e}")))?;
302
303 if let Some(row) = existing {
304 let version: i64 = row.try_get("version").unwrap_or(1);
306 let new_version = version + 1;
307
308 sqlx::query(
309 "UPDATE secrets SET encrypted_value = ?, version = ?, updated_at = ? WHERE storage_key = ?",
310 )
311 .bind(&encrypted)
312 .bind(new_version)
313 .bind(&now)
314 .bind(&storage_key)
315 .execute(&self.pool)
316 .await
317 .map_err(|e| SecretsError::Storage(format!("Failed to update secret: {e}")))?;
318
319 debug!("Updated secret: {} (version {})", storage_key, new_version);
320 } else {
321 sqlx::query(
323 "INSERT INTO secrets (storage_key, encrypted_value, name, version, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)",
324 )
325 .bind(&storage_key)
326 .bind(&encrypted)
327 .bind(name)
328 .bind(&now)
329 .bind(&now)
330 .execute(&self.pool)
331 .await
332 .map_err(|e| SecretsError::Storage(format!("Failed to insert secret: {e}")))?;
333
334 debug!("Stored secret: {} (version 1)", storage_key);
335 }
336
337 Ok(())
338 }
339
340 async fn delete_secret(&self, scope: &str, name: &str) -> Result<()> {
341 let storage_key = Self::make_key(scope, name);
342
343 let result = sqlx::query("DELETE FROM secrets WHERE storage_key = ?")
344 .bind(&storage_key)
345 .execute(&self.pool)
346 .await
347 .map_err(|e| SecretsError::Storage(format!("Failed to delete secret: {e}")))?;
348
349 if result.rows_affected() == 0 {
350 return Err(SecretsError::NotFound {
351 name: name.to_string(),
352 });
353 }
354
355 debug!("Deleted secret: {}", storage_key);
356 Ok(())
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 async fn create_test_store() -> (PersistentSecretsStore, tempfile::TempDir) {
365 let temp_dir = tempfile::tempdir().unwrap();
366 let db_path = temp_dir.path().join("test_secrets.sqlite");
367 let key = EncryptionKey::generate();
368 let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
369 (store, temp_dir)
370 }
371
372 #[tokio::test]
373 async fn test_set_and_get_secret() {
374 let (store, _temp) = create_test_store().await;
375
376 let secret = Secret::new("super-secret-value");
377 store
378 .set_secret("deployment/myapp", "db-password", &secret)
379 .await
380 .unwrap();
381
382 let retrieved = store
383 .get_secret("deployment/myapp", "db-password")
384 .await
385 .unwrap();
386 assert_eq!(retrieved.expose(), "super-secret-value");
387 }
388
389 #[tokio::test]
390 async fn test_get_nonexistent_secret() {
391 let (store, _temp) = create_test_store().await;
392
393 let result = store.get_secret("deployment/myapp", "nonexistent").await;
394 assert!(matches!(result, Err(SecretsError::NotFound { .. })));
395 }
396
397 #[tokio::test]
398 async fn test_exists() {
399 let (store, _temp) = create_test_store().await;
400
401 assert!(!store.exists("scope", "name").await.unwrap());
402
403 let secret = Secret::new("value");
404 store.set_secret("scope", "name", &secret).await.unwrap();
405
406 assert!(store.exists("scope", "name").await.unwrap());
407 }
408
409 #[tokio::test]
410 async fn test_delete_secret() {
411 let (store, _temp) = create_test_store().await;
412
413 let secret = Secret::new("to-be-deleted");
414 store
415 .set_secret("scope", "deleteme", &secret)
416 .await
417 .unwrap();
418 assert!(store.exists("scope", "deleteme").await.unwrap());
419
420 store.delete_secret("scope", "deleteme").await.unwrap();
421 assert!(!store.exists("scope", "deleteme").await.unwrap());
422 }
423
424 #[tokio::test]
425 async fn test_delete_nonexistent() {
426 let (store, _temp) = create_test_store().await;
427
428 let result = store.delete_secret("scope", "nonexistent").await;
429 assert!(matches!(result, Err(SecretsError::NotFound { .. })));
430 }
431
432 #[tokio::test]
433 async fn test_list_secrets() {
434 let (store, _temp) = create_test_store().await;
435
436 store
438 .set_secret("scope1", "secret-a", &Secret::new("a"))
439 .await
440 .unwrap();
441 store
442 .set_secret("scope1", "secret-b", &Secret::new("b"))
443 .await
444 .unwrap();
445 store
446 .set_secret("scope2", "secret-c", &Secret::new("c"))
447 .await
448 .unwrap();
449
450 let list = store.list_secrets("scope1").await.unwrap();
452 assert_eq!(list.len(), 2);
453 assert_eq!(list[0].name, "secret-a");
454 assert_eq!(list[1].name, "secret-b");
455
456 let list = store.list_secrets("scope2").await.unwrap();
458 assert_eq!(list.len(), 1);
459 assert_eq!(list[0].name, "secret-c");
460 }
461
462 #[tokio::test]
463 async fn test_get_secrets_batch() {
464 let (store, _temp) = create_test_store().await;
465
466 store
467 .set_secret("scope", "a", &Secret::new("value-a"))
468 .await
469 .unwrap();
470 store
471 .set_secret("scope", "b", &Secret::new("value-b"))
472 .await
473 .unwrap();
474 store
475 .set_secret("scope", "c", &Secret::new("value-c"))
476 .await
477 .unwrap();
478
479 let results = store
480 .get_secrets("scope", &["a", "c", "nonexistent"])
481 .await
482 .unwrap();
483 assert_eq!(results.len(), 2);
484
485 assert_eq!(results.get("a").unwrap().expose(), "value-a");
486 assert_eq!(results.get("c").unwrap().expose(), "value-c");
487 assert!(!results.contains_key("nonexistent"));
488 }
489
490 #[tokio::test]
491 async fn test_update_increments_version() {
492 let (store, _temp) = create_test_store().await;
493
494 store
495 .set_secret("scope", "versioned", &Secret::new("v1"))
496 .await
497 .unwrap();
498
499 let list = store.list_secrets("scope").await.unwrap();
500 assert_eq!(list[0].version, 1);
501
502 store
503 .set_secret("scope", "versioned", &Secret::new("v2"))
504 .await
505 .unwrap();
506
507 let list = store.list_secrets("scope").await.unwrap();
508 assert_eq!(list[0].version, 2);
509 }
510
511 #[tokio::test]
512 async fn test_persistence() {
513 let temp_dir = tempfile::tempdir().unwrap();
514 let db_path = temp_dir.path().join("persist_test.sqlite");
515
516 let key_bytes = [42u8; 32];
518 let key = EncryptionKey::from_bytes(&key_bytes).unwrap();
519
520 {
522 let store = PersistentSecretsStore::open(&db_path, key.clone())
523 .await
524 .unwrap();
525 store
526 .set_secret("scope", "persistent", &Secret::new("persistent-value"))
527 .await
528 .unwrap();
529 }
530
531 {
533 let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
534 let secret = store.get_secret("scope", "persistent").await.unwrap();
535 assert_eq!(secret.expose(), "persistent-value");
536 }
537 }
538
539 #[tokio::test]
540 async fn test_wrong_key_fails_decryption() {
541 let temp_dir = tempfile::tempdir().unwrap();
542 let db_path = temp_dir.path().join("wrong_key_test.sqlite");
543
544 let key1 = EncryptionKey::generate();
546 {
547 let store = PersistentSecretsStore::open(&db_path, key1).await.unwrap();
548 store
549 .set_secret("scope", "secret", &Secret::new("value"))
550 .await
551 .unwrap();
552 }
553
554 let key2 = EncryptionKey::generate();
556 {
557 let store = PersistentSecretsStore::open(&db_path, key2).await.unwrap();
558 let result = store.get_secret("scope", "secret").await;
559 assert!(result.is_err()); }
561 }
562
563 #[tokio::test]
564 async fn test_open_with_directory() {
565 let temp_dir = tempfile::tempdir().unwrap();
566 let key = EncryptionKey::generate();
567
568 let store = PersistentSecretsStore::open(temp_dir.path(), key)
570 .await
571 .unwrap();
572
573 store
574 .set_secret("scope", "test", &Secret::new("value"))
575 .await
576 .unwrap();
577
578 let expected_path = temp_dir.path().join(DEFAULT_DB_FILENAME);
580 assert!(expected_path.exists());
581 }
582
583 #[test]
584 fn test_make_key() {
585 assert_eq!(
586 PersistentSecretsStore::make_key("scope", "name"),
587 "scope:name"
588 );
589 assert_eq!(
590 PersistentSecretsStore::make_key("deployment/app", "secret"),
591 "deployment/app:secret"
592 );
593 }
594
595 #[tokio::test]
596 async fn test_empty_secret() {
597 let (store, _temp) = create_test_store().await;
598
599 let secret = Secret::new("");
600 store.set_secret("scope", "empty", &secret).await.unwrap();
601
602 let retrieved = store.get_secret("scope", "empty").await.unwrap();
603 assert_eq!(retrieved.expose(), "");
604 }
605
606 #[tokio::test]
607 async fn test_unicode_secret() {
608 let (store, _temp) = create_test_store().await;
609
610 let secret = Secret::new("hello world");
611 store.set_secret("scope", "unicode", &secret).await.unwrap();
612
613 let retrieved = store.get_secret("scope", "unicode").await.unwrap();
614 assert_eq!(retrieved.expose(), "hello world");
615 }
616
617 #[tokio::test]
618 async fn test_large_secret() {
619 let (store, _temp) = create_test_store().await;
620
621 let large_value: String = "x".repeat(1024 * 1024);
623 let secret = Secret::new(&large_value);
624 store.set_secret("scope", "large", &secret).await.unwrap();
625
626 let retrieved = store.get_secret("scope", "large").await.unwrap();
627 assert_eq!(retrieved.expose().len(), 1024 * 1024);
628 }
629
630 #[tokio::test]
640 async fn open_on_fresh_dir_creates_directory() {
641 let tmp = tempfile::tempdir().unwrap();
642 let path = tmp.path().join("secrets");
645 assert!(
646 !path.exists(),
647 "precondition: path must not exist before open()"
648 );
649
650 let key = EncryptionKey::generate();
651 let _store = PersistentSecretsStore::open(&path, key)
652 .await
653 .expect("open() on non-existent dir path should succeed");
654
655 assert!(
658 path.is_dir(),
659 "open() should have created {} as a directory",
660 path.display()
661 );
662 assert!(
663 path.join(DEFAULT_DB_FILENAME).exists(),
664 "secrets.sqlite should exist inside {}",
665 path.display()
666 );
667 }
668
669 #[tokio::test]
679 async fn open_on_pre_existing_file_does_not_clobber() {
680 let tmp = tempfile::tempdir().unwrap();
681 let path = tmp.path().join("secrets");
682 std::fs::write(&path, b"legacy content not a sqlite db").unwrap();
683 assert!(path.is_file(), "precondition: path is a regular file");
684
685 let key = EncryptionKey::generate();
686 let result = PersistentSecretsStore::open(&path, key).await;
687
688 assert!(
694 result.is_err(),
695 "open() on a non-SQLite regular file should fail, not silently succeed"
696 );
697 let bytes = std::fs::read(&path).unwrap();
698 assert_eq!(
699 bytes, b"legacy content not a sqlite db",
700 "open() must not modify a pre-existing file at the secrets path"
701 );
702 }
703}