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