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