Skip to main content

zlayer_secrets/
persistent.rs

1//! Persistent secrets storage using `SQLite` (via `SQLx`).
2//!
3//! Provides encrypted local storage for secrets with a single table combining
4//! secret values and metadata:
5//! - `storage_key`: Primary key in `{scope}:{name}` format
6//! - `encrypted_value`: XChaCha20-Poly1305 encrypted secret bytes
7//! - `name`, `version`, `created_at`, `updated_at`: Metadata fields
8//!
9//! # Example
10//!
11//! ```no_run
12//! use zlayer_secrets::{EncryptionKey, PersistentSecretsStore, Secret};
13//! use zlayer_secrets::{SecretsProvider, SecretsStore};
14//!
15//! # async fn example() -> zlayer_secrets::Result<()> {
16//! let key = EncryptionKey::generate();
17//! let secrets_dir = zlayer_paths::ZLayerDirs::system_default().secrets();
18//! let store = PersistentSecretsStore::open(&secrets_dir, key).await?;
19//!
20//! // Store a secret
21//! let secret = Secret::new("my-password");
22//! store.set_secret("deployment/myapp", "db-password", &secret).await?;
23//!
24//! // Retrieve a secret
25//! let retrieved = store.get_secret("deployment/myapp", "db-password").await?;
26//! # Ok(())
27//! # }
28//! ```
29
30use 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
44/// Default database filename when a directory is provided.
45const DEFAULT_DB_FILENAME: &str = "secrets.sqlite";
46
47/// SQL schema for the secrets table.
48const 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
60/// Persistent secrets store backed by `SQLite` with encryption.
61///
62/// Secrets are encrypted using XChaCha20-Poly1305 before storage.
63/// Metadata is stored alongside secrets for inspection and auditing.
64///
65/// The store uses `SQLite` with WAL mode for concurrent access.
66pub struct PersistentSecretsStore {
67    pool: SqlitePool,
68    key: EncryptionKey,
69}
70
71impl PersistentSecretsStore {
72    /// Opens or creates a persistent secrets store at the given path.
73    ///
74    /// If `path` is a directory, the database file will be created as
75    /// `secrets.sqlite` inside that directory. If `path` is a file path,
76    /// it will be used directly.
77    ///
78    /// # Arguments
79    ///
80    /// * `path` - Path to the database file or directory
81    /// * `key` - Encryption key for encrypting/decrypting secrets
82    ///
83    /// # Errors
84    ///
85    /// Returns `SecretsError::Storage` if:
86    /// - The database cannot be created or opened
87    /// - Schema initialization fails
88    pub async fn open(path: impl AsRef<Path>, key: EncryptionKey) -> Result<Self> {
89        let path = path.as_ref();
90
91        // Fresh `--data-dir` boot race: if `path` does not exist yet AND its
92        // extension does not look like a `SQLite` file name, treat it as the
93        // canonical secrets *directory* and `mkdir -p` it now. This makes the
94        // `path.is_dir()` branch below take, so `db_path` ends up as
95        // `{path}/secrets.sqlite` and downstream code that also calls
96        // `fs::create_dir_all({data_dir}/secrets)` (e.g. the node keypair
97        // loader at `bin/zlayer/src/daemon.rs`) no longer trips EEXIST
98        // because we've already created the directory atomically.
99        //
100        // This intentionally does NOT touch the case where `path` exists as
101        // a regular file (legacy pre-0.11.20 layout). The
102        // `migrate_legacy_secrets_layout` step in `bin/zlayer/src/migrations.rs`
103        // owns that path and converts the file to a directory before we ever
104        // get here.
105        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        // If the path is an existing directory, append the default database filename
119        let db_path = if path.is_dir() {
120            path.join(DEFAULT_DB_FILENAME)
121        } else {
122            path.to_path_buf()
123        };
124
125        // Ensure parent directory exists
126        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        // Configure SQLite connection with WAL mode for better concurrency
132        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        // Create connection pool
140        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        // Initialize schema
152        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    /// Constructs a storage key from scope and name.
163    ///
164    /// Format: `{scope}:{name}`
165    #[inline]
166    fn make_key(scope: &str, name: &str) -> String {
167        format!("{scope}:{name}")
168    }
169
170    /// Get the current timestamp as ISO 8601 string.
171    #[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        // Convert to ISO 8601 format for SQLite TEXT storage
178        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    /// Parse ISO 8601 timestamp to Unix timestamp.
185    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            // For batch retrieval, we silently skip missing secrets
226            // rather than returning an error
227            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        // Encrypt the secret value
294        let encrypted = self.key.encrypt(value.expose().as_bytes())?;
295
296        let now = Self::now_iso8601();
297
298        // Check if secret exists to determine version
299        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            // Update existing secret
307            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            // Insert new secret
324            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        // Add secrets to different scopes
441        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        // List scope1 - should only see 2 secrets
455        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        // List scope2 - should only see 1 secret
461        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        // Use fixed key for persistence test
523        let key_bytes = [42u8; 32];
524        let key = EncryptionKey::from_bytes(&key_bytes).unwrap();
525
526        // Write data
527        {
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        // Reopen and verify
538        {
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        // Write with one key
553        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        // Try to read with different key
563        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()); // Should fail decryption
568        }
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        // Pass directory path instead of file path
579        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        // Verify database file was created
589        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        // 1MB secret
632        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    /// Regression test for the EEXIST race observed on fresh `--data-dir`
641    /// boots (see `## [0.11.22]` in `CHANGELOG.md`):
642    ///
643    /// 1. Caller passes `{data_dir}/secrets` to `open()` and the path does
644    ///    not exist yet.
645    /// 2. `open()` MUST `mkdir -p` the path BEFORE handing it to `SQLite`,
646    ///    so the subsequent `fs::create_dir_all({data_dir}/secrets)` call
647    ///    in `bin/zlayer/src/daemon.rs::load_or_generate_node_keypair` is
648    ///    a no-op instead of tripping `File exists (os error 17)`.
649    #[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        // Subpath that does not exist yet — mirrors the
655        // `{data_dir}/secrets` shape the daemon passes in.
656        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        // The path must now be a directory, and SQLite must have created
668        // `secrets.sqlite` *inside* it (not as the path itself).
669        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    /// Confirms the fresh-dir fix does NOT clobber a pre-existing regular
682    /// file at the target path. The legacy pre-0.11.20 layout stored a
683    /// `SQLite` database directly at `{data_dir}/secrets`, and
684    /// `bin/zlayer/src/migrations.rs::migrate_legacy_secrets_layout` is
685    /// responsible for converting that file to a directory *before*
686    /// `open()` is called. If migration is skipped (e.g. a test pointing
687    /// at a stray non-SQLite file), `open()` must NOT silently delete or
688    /// overwrite the file — it should pass the path through to `SQLite`,
689    /// which will reject the malformed database.
690    #[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        // SQLite should reject the non-DB file. The exact error code
703        // varies by libsqlite version, but it must be a Storage error and
704        // it must NOT have deleted/replaced the file. We assert the
705        // file's original bytes are intact, which is the load-bearing
706        // invariant (user data preservation).
707        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}