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 db_path = if path.is_dir() {
91 path.join(DEFAULT_DB_FILENAME)
92 } else {
93 path.to_path_buf()
94 };
95
96 if let Some(parent) = db_path.parent() {
98 std::fs::create_dir_all(parent)
99 .map_err(|e| SecretsError::Storage(format!("Failed to create directory: {e}")))?;
100 }
101
102 let options = SqliteConnectOptions::new()
104 .filename(&db_path)
105 .create_if_missing(true)
106 .journal_mode(SqliteJournalMode::Wal)
107 .synchronous(SqliteSynchronous::Normal)
108 .busy_timeout(std::time::Duration::from_secs(30));
109
110 let pool = SqlitePoolOptions::new()
112 .max_connections(5)
113 .connect_with(options)
114 .await
115 .map_err(|e| {
116 SecretsError::Storage(format!(
117 "Failed to open database at {}: {e}",
118 db_path.display()
119 ))
120 })?;
121
122 sqlx::query(SCHEMA)
124 .execute(&pool)
125 .await
126 .map_err(|e| SecretsError::Storage(format!("Failed to initialize schema: {e}")))?;
127
128 info!("Opened persistent secrets store at {}", db_path.display());
129
130 Ok(Self { pool, key })
131 }
132
133 #[inline]
137 fn make_key(scope: &str, name: &str) -> String {
138 format!("{scope}:{name}")
139 }
140
141 #[allow(clippy::cast_possible_wrap)]
143 fn now_iso8601() -> String {
144 let now = std::time::SystemTime::now()
145 .duration_since(std::time::UNIX_EPOCH)
146 .unwrap_or_default()
147 .as_secs();
148 chrono::DateTime::from_timestamp(now as i64, 0).map_or_else(
150 || "1970-01-01T00:00:00Z".to_string(),
151 |dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
152 )
153 }
154
155 fn parse_timestamp(s: &str) -> i64 {
157 chrono::DateTime::parse_from_rfc3339(s)
158 .map(|dt| dt.timestamp())
159 .unwrap_or(0)
160 }
161}
162
163#[async_trait]
164impl SecretsProvider for PersistentSecretsStore {
165 async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
166 let storage_key = Self::make_key(scope, name);
167
168 let row = sqlx::query("SELECT encrypted_value FROM secrets WHERE storage_key = ?")
169 .bind(&storage_key)
170 .fetch_optional(&self.pool)
171 .await
172 .map_err(|e| SecretsError::Storage(format!("Failed to query secret: {e}")))?;
173
174 match row {
175 Some(row) => {
176 let encrypted_value: Vec<u8> = row
177 .try_get("encrypted_value")
178 .map_err(|e| SecretsError::Storage(format!("Failed to read value: {e}")))?;
179
180 let decrypted = self.key.decrypt(&encrypted_value)?;
181
182 let value = String::from_utf8(decrypted)
183 .map_err(|e| SecretsError::Decryption(format!("Invalid UTF-8: {e}")))?;
184
185 debug!("Retrieved secret: {}", storage_key);
186 Ok(Secret::new(value))
187 }
188 None => Err(SecretsError::NotFound {
189 name: name.to_string(),
190 }),
191 }
192 }
193
194 async fn get_secrets(&self, scope: &str, names: &[&str]) -> Result<HashMap<String, Secret>> {
195 let mut results = HashMap::with_capacity(names.len());
196
197 for name in names {
198 if let Ok(secret) = self.get_secret(scope, name).await {
201 results.insert((*name).to_string(), secret);
202 }
203 }
204
205 Ok(results)
206 }
207
208 async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
209 let prefix = format!("{scope}:%");
210
211 let rows = sqlx::query(
212 "SELECT name, version, created_at, updated_at FROM secrets WHERE storage_key LIKE ? ORDER BY name",
213 )
214 .bind(&prefix)
215 .fetch_all(&self.pool)
216 .await
217 .map_err(|e| SecretsError::Storage(format!("Failed to list secrets: {e}")))?;
218
219 let mut results = Vec::with_capacity(rows.len());
220
221 for row in rows {
222 let name: String = row
223 .try_get("name")
224 .map_err(|e| SecretsError::Storage(format!("Failed to read name: {e}")))?;
225 let version: i64 = row
226 .try_get("version")
227 .map_err(|e| SecretsError::Storage(format!("Failed to read version: {e}")))?;
228 let created_at: String = row
229 .try_get("created_at")
230 .map_err(|e| SecretsError::Storage(format!("Failed to read created_at: {e}")))?;
231 let updated_at: String = row
232 .try_get("updated_at")
233 .map_err(|e| SecretsError::Storage(format!("Failed to read updated_at: {e}")))?;
234
235 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
236 results.push(SecretMetadata {
237 name,
238 version: version as u32,
239 created_at: Self::parse_timestamp(&created_at),
240 updated_at: Self::parse_timestamp(&updated_at),
241 });
242 }
243
244 debug!("Listed {} secrets in scope: {}", results.len(), scope);
245 Ok(results)
246 }
247
248 async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
249 let storage_key = Self::make_key(scope, name);
250
251 let row = sqlx::query("SELECT 1 FROM secrets WHERE storage_key = ?")
252 .bind(&storage_key)
253 .fetch_optional(&self.pool)
254 .await
255 .map_err(|e| SecretsError::Storage(format!("Failed to check existence: {e}")))?;
256
257 Ok(row.is_some())
258 }
259}
260
261#[async_trait]
262impl SecretsStore for PersistentSecretsStore {
263 async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()> {
264 let storage_key = Self::make_key(scope, name);
265
266 let encrypted = self.key.encrypt(value.expose().as_bytes())?;
268
269 let now = Self::now_iso8601();
270
271 let existing = sqlx::query("SELECT version, created_at FROM secrets WHERE storage_key = ?")
273 .bind(&storage_key)
274 .fetch_optional(&self.pool)
275 .await
276 .map_err(|e| SecretsError::Storage(format!("Failed to check existing: {e}")))?;
277
278 if let Some(row) = existing {
279 let version: i64 = row.try_get("version").unwrap_or(1);
281 let new_version = version + 1;
282
283 sqlx::query(
284 "UPDATE secrets SET encrypted_value = ?, version = ?, updated_at = ? WHERE storage_key = ?",
285 )
286 .bind(&encrypted)
287 .bind(new_version)
288 .bind(&now)
289 .bind(&storage_key)
290 .execute(&self.pool)
291 .await
292 .map_err(|e| SecretsError::Storage(format!("Failed to update secret: {e}")))?;
293
294 debug!("Updated secret: {} (version {})", storage_key, new_version);
295 } else {
296 sqlx::query(
298 "INSERT INTO secrets (storage_key, encrypted_value, name, version, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)",
299 )
300 .bind(&storage_key)
301 .bind(&encrypted)
302 .bind(name)
303 .bind(&now)
304 .bind(&now)
305 .execute(&self.pool)
306 .await
307 .map_err(|e| SecretsError::Storage(format!("Failed to insert secret: {e}")))?;
308
309 debug!("Stored secret: {} (version 1)", storage_key);
310 }
311
312 Ok(())
313 }
314
315 async fn delete_secret(&self, scope: &str, name: &str) -> Result<()> {
316 let storage_key = Self::make_key(scope, name);
317
318 let result = sqlx::query("DELETE FROM secrets WHERE storage_key = ?")
319 .bind(&storage_key)
320 .execute(&self.pool)
321 .await
322 .map_err(|e| SecretsError::Storage(format!("Failed to delete secret: {e}")))?;
323
324 if result.rows_affected() == 0 {
325 return Err(SecretsError::NotFound {
326 name: name.to_string(),
327 });
328 }
329
330 debug!("Deleted secret: {}", storage_key);
331 Ok(())
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 async fn create_test_store() -> (PersistentSecretsStore, tempfile::TempDir) {
340 let temp_dir = tempfile::tempdir().unwrap();
341 let db_path = temp_dir.path().join("test_secrets.sqlite");
342 let key = EncryptionKey::generate();
343 let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
344 (store, temp_dir)
345 }
346
347 #[tokio::test]
348 async fn test_set_and_get_secret() {
349 let (store, _temp) = create_test_store().await;
350
351 let secret = Secret::new("super-secret-value");
352 store
353 .set_secret("deployment/myapp", "db-password", &secret)
354 .await
355 .unwrap();
356
357 let retrieved = store
358 .get_secret("deployment/myapp", "db-password")
359 .await
360 .unwrap();
361 assert_eq!(retrieved.expose(), "super-secret-value");
362 }
363
364 #[tokio::test]
365 async fn test_get_nonexistent_secret() {
366 let (store, _temp) = create_test_store().await;
367
368 let result = store.get_secret("deployment/myapp", "nonexistent").await;
369 assert!(matches!(result, Err(SecretsError::NotFound { .. })));
370 }
371
372 #[tokio::test]
373 async fn test_exists() {
374 let (store, _temp) = create_test_store().await;
375
376 assert!(!store.exists("scope", "name").await.unwrap());
377
378 let secret = Secret::new("value");
379 store.set_secret("scope", "name", &secret).await.unwrap();
380
381 assert!(store.exists("scope", "name").await.unwrap());
382 }
383
384 #[tokio::test]
385 async fn test_delete_secret() {
386 let (store, _temp) = create_test_store().await;
387
388 let secret = Secret::new("to-be-deleted");
389 store
390 .set_secret("scope", "deleteme", &secret)
391 .await
392 .unwrap();
393 assert!(store.exists("scope", "deleteme").await.unwrap());
394
395 store.delete_secret("scope", "deleteme").await.unwrap();
396 assert!(!store.exists("scope", "deleteme").await.unwrap());
397 }
398
399 #[tokio::test]
400 async fn test_delete_nonexistent() {
401 let (store, _temp) = create_test_store().await;
402
403 let result = store.delete_secret("scope", "nonexistent").await;
404 assert!(matches!(result, Err(SecretsError::NotFound { .. })));
405 }
406
407 #[tokio::test]
408 async fn test_list_secrets() {
409 let (store, _temp) = create_test_store().await;
410
411 store
413 .set_secret("scope1", "secret-a", &Secret::new("a"))
414 .await
415 .unwrap();
416 store
417 .set_secret("scope1", "secret-b", &Secret::new("b"))
418 .await
419 .unwrap();
420 store
421 .set_secret("scope2", "secret-c", &Secret::new("c"))
422 .await
423 .unwrap();
424
425 let list = store.list_secrets("scope1").await.unwrap();
427 assert_eq!(list.len(), 2);
428 assert_eq!(list[0].name, "secret-a");
429 assert_eq!(list[1].name, "secret-b");
430
431 let list = store.list_secrets("scope2").await.unwrap();
433 assert_eq!(list.len(), 1);
434 assert_eq!(list[0].name, "secret-c");
435 }
436
437 #[tokio::test]
438 async fn test_get_secrets_batch() {
439 let (store, _temp) = create_test_store().await;
440
441 store
442 .set_secret("scope", "a", &Secret::new("value-a"))
443 .await
444 .unwrap();
445 store
446 .set_secret("scope", "b", &Secret::new("value-b"))
447 .await
448 .unwrap();
449 store
450 .set_secret("scope", "c", &Secret::new("value-c"))
451 .await
452 .unwrap();
453
454 let results = store
455 .get_secrets("scope", &["a", "c", "nonexistent"])
456 .await
457 .unwrap();
458 assert_eq!(results.len(), 2);
459
460 assert_eq!(results.get("a").unwrap().expose(), "value-a");
461 assert_eq!(results.get("c").unwrap().expose(), "value-c");
462 assert!(!results.contains_key("nonexistent"));
463 }
464
465 #[tokio::test]
466 async fn test_update_increments_version() {
467 let (store, _temp) = create_test_store().await;
468
469 store
470 .set_secret("scope", "versioned", &Secret::new("v1"))
471 .await
472 .unwrap();
473
474 let list = store.list_secrets("scope").await.unwrap();
475 assert_eq!(list[0].version, 1);
476
477 store
478 .set_secret("scope", "versioned", &Secret::new("v2"))
479 .await
480 .unwrap();
481
482 let list = store.list_secrets("scope").await.unwrap();
483 assert_eq!(list[0].version, 2);
484 }
485
486 #[tokio::test]
487 async fn test_persistence() {
488 let temp_dir = tempfile::tempdir().unwrap();
489 let db_path = temp_dir.path().join("persist_test.sqlite");
490
491 let key_bytes = [42u8; 32];
493 let key = EncryptionKey::from_bytes(&key_bytes).unwrap();
494
495 {
497 let store = PersistentSecretsStore::open(&db_path, key.clone())
498 .await
499 .unwrap();
500 store
501 .set_secret("scope", "persistent", &Secret::new("persistent-value"))
502 .await
503 .unwrap();
504 }
505
506 {
508 let store = PersistentSecretsStore::open(&db_path, key).await.unwrap();
509 let secret = store.get_secret("scope", "persistent").await.unwrap();
510 assert_eq!(secret.expose(), "persistent-value");
511 }
512 }
513
514 #[tokio::test]
515 async fn test_wrong_key_fails_decryption() {
516 let temp_dir = tempfile::tempdir().unwrap();
517 let db_path = temp_dir.path().join("wrong_key_test.sqlite");
518
519 let key1 = EncryptionKey::generate();
521 {
522 let store = PersistentSecretsStore::open(&db_path, key1).await.unwrap();
523 store
524 .set_secret("scope", "secret", &Secret::new("value"))
525 .await
526 .unwrap();
527 }
528
529 let key2 = EncryptionKey::generate();
531 {
532 let store = PersistentSecretsStore::open(&db_path, key2).await.unwrap();
533 let result = store.get_secret("scope", "secret").await;
534 assert!(result.is_err()); }
536 }
537
538 #[tokio::test]
539 async fn test_open_with_directory() {
540 let temp_dir = tempfile::tempdir().unwrap();
541 let key = EncryptionKey::generate();
542
543 let store = PersistentSecretsStore::open(temp_dir.path(), key)
545 .await
546 .unwrap();
547
548 store
549 .set_secret("scope", "test", &Secret::new("value"))
550 .await
551 .unwrap();
552
553 let expected_path = temp_dir.path().join(DEFAULT_DB_FILENAME);
555 assert!(expected_path.exists());
556 }
557
558 #[test]
559 fn test_make_key() {
560 assert_eq!(
561 PersistentSecretsStore::make_key("scope", "name"),
562 "scope:name"
563 );
564 assert_eq!(
565 PersistentSecretsStore::make_key("deployment/app", "secret"),
566 "deployment/app:secret"
567 );
568 }
569
570 #[tokio::test]
571 async fn test_empty_secret() {
572 let (store, _temp) = create_test_store().await;
573
574 let secret = Secret::new("");
575 store.set_secret("scope", "empty", &secret).await.unwrap();
576
577 let retrieved = store.get_secret("scope", "empty").await.unwrap();
578 assert_eq!(retrieved.expose(), "");
579 }
580
581 #[tokio::test]
582 async fn test_unicode_secret() {
583 let (store, _temp) = create_test_store().await;
584
585 let secret = Secret::new("hello world");
586 store.set_secret("scope", "unicode", &secret).await.unwrap();
587
588 let retrieved = store.get_secret("scope", "unicode").await.unwrap();
589 assert_eq!(retrieved.expose(), "hello world");
590 }
591
592 #[tokio::test]
593 async fn test_large_secret() {
594 let (store, _temp) = create_test_store().await;
595
596 let large_value: String = "x".repeat(1024 * 1024);
598 let secret = Secret::new(&large_value);
599 store.set_secret("scope", "large", &secret).await.unwrap();
600
601 let retrieved = store.get_secret("scope", "large").await.unwrap();
602 assert_eq!(retrieved.expose().len(), 1024 * 1024);
603 }
604}