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
38use crate::{
39    EncryptionKey, Result, Secret, SecretMetadata, SecretsError, SecretsProvider, SecretsStore,
40};
41
42/// Default database filename when a directory is provided.
43const DEFAULT_DB_FILENAME: &str = "secrets.sqlite";
44
45/// SQL schema for the secrets table.
46const 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
58/// Persistent secrets store backed by `SQLite` with encryption.
59///
60/// Secrets are encrypted using XChaCha20-Poly1305 before storage.
61/// Metadata is stored alongside secrets for inspection and auditing.
62///
63/// The store uses `SQLite` with WAL mode for concurrent access.
64pub struct PersistentSecretsStore {
65    pool: SqlitePool,
66    key: EncryptionKey,
67}
68
69impl PersistentSecretsStore {
70    /// Opens or creates a persistent secrets store at the given path.
71    ///
72    /// If `path` is a directory, the database file will be created as
73    /// `secrets.sqlite` inside that directory. If `path` is a file path,
74    /// it will be used directly.
75    ///
76    /// # Arguments
77    ///
78    /// * `path` - Path to the database file or directory
79    /// * `key` - Encryption key for encrypting/decrypting secrets
80    ///
81    /// # Errors
82    ///
83    /// Returns `SecretsError::Storage` if:
84    /// - The database cannot be created or opened
85    /// - Schema initialization fails
86    pub async fn open(path: impl AsRef<Path>, key: EncryptionKey) -> Result<Self> {
87        let path = path.as_ref();
88
89        // Fresh `--data-dir` boot race: if `path` does not exist yet AND its
90        // extension does not look like a `SQLite` file name, treat it as the
91        // canonical secrets *directory* and `mkdir -p` it now. This makes the
92        // `path.is_dir()` branch below take, so `db_path` ends up as
93        // `{path}/secrets.sqlite` and downstream code that also calls
94        // `fs::create_dir_all({data_dir}/secrets)` (e.g. the node keypair
95        // loader at `bin/zlayer/src/daemon.rs`) no longer trips EEXIST
96        // because we've already created the directory atomically.
97        //
98        // This intentionally does NOT touch the case where `path` exists as
99        // a regular file (legacy pre-0.11.20 layout). The
100        // `migrate_legacy_secrets_layout` step in `bin/zlayer/src/migrations.rs`
101        // owns that path and converts the file to a directory before we ever
102        // get here.
103        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        // If the path is an existing directory, append the default database filename
117        let db_path = if path.is_dir() {
118            path.join(DEFAULT_DB_FILENAME)
119        } else {
120            path.to_path_buf()
121        };
122
123        // Ensure parent directory exists
124        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        // Configure SQLite connection with WAL mode for better concurrency
130        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        // Create connection pool
138        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        // Initialize schema
150        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    /// Constructs a storage key from scope and name.
161    ///
162    /// Format: `{scope}:{name}`
163    #[inline]
164    fn make_key(scope: &str, name: &str) -> String {
165        format!("{scope}:{name}")
166    }
167
168    /// Get the current timestamp as ISO 8601 string.
169    #[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        // Convert to ISO 8601 format for SQLite TEXT storage
176        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    /// Parse ISO 8601 timestamp to Unix timestamp.
183    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            // For batch retrieval, we silently skip missing secrets
224            // rather than returning an error
225            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        // Encrypt the secret value
292        let encrypted = self.key.encrypt(value.expose().as_bytes())?;
293
294        let now = Self::now_iso8601();
295
296        // Check if secret exists to determine version
297        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            // Update existing secret
305            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            // Insert new secret
322            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        // Add secrets to different scopes
437        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        // List scope1 - should only see 2 secrets
451        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        // List scope2 - should only see 1 secret
457        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        // Use fixed key for persistence test
517        let key_bytes = [42u8; 32];
518        let key = EncryptionKey::from_bytes(&key_bytes).unwrap();
519
520        // Write data
521        {
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        // Reopen and verify
532        {
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        // Write with one key
545        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        // Try to read with different key
555        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()); // Should fail decryption
560        }
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        // Pass directory path instead of file path
569        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        // Verify database file was created
579        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        // 1MB secret
622        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    /// Regression test for the EEXIST race observed on fresh `--data-dir`
631    /// boots (see `## [0.11.22]` in `CHANGELOG.md`):
632    ///
633    /// 1. Caller passes `{data_dir}/secrets` to `open()` and the path does
634    ///    not exist yet.
635    /// 2. `open()` MUST `mkdir -p` the path BEFORE handing it to `SQLite`,
636    ///    so the subsequent `fs::create_dir_all({data_dir}/secrets)` call
637    ///    in `bin/zlayer/src/daemon.rs::load_or_generate_node_keypair` is
638    ///    a no-op instead of tripping `File exists (os error 17)`.
639    #[tokio::test]
640    async fn open_on_fresh_dir_creates_directory() {
641        let tmp = tempfile::tempdir().unwrap();
642        // Subpath that does not exist yet — mirrors the
643        // `{data_dir}/secrets` shape the daemon passes in.
644        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        // The path must now be a directory, and SQLite must have created
656        // `secrets.sqlite` *inside* it (not as the path itself).
657        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    /// Confirms the fresh-dir fix does NOT clobber a pre-existing regular
670    /// file at the target path. The legacy pre-0.11.20 layout stored a
671    /// `SQLite` database directly at `{data_dir}/secrets`, and
672    /// `bin/zlayer/src/migrations.rs::migrate_legacy_secrets_layout` is
673    /// responsible for converting that file to a directory *before*
674    /// `open()` is called. If migration is skipped (e.g. a test pointing
675    /// at a stray non-SQLite file), `open()` must NOT silently delete or
676    /// overwrite the file — it should pass the path through to `SQLite`,
677    /// which will reject the malformed database.
678    #[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        // SQLite should reject the non-DB file. The exact error code
689        // varies by libsqlite version, but it must be a Storage error and
690        // it must NOT have deleted/replaced the file. We assert the
691        // file's original bytes are intact, which is the load-bearing
692        // invariant (user data preservation).
693        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}