Skip to main content

starpod_vault/
lib.rs

1pub mod env;
2pub mod known_hosts;
3#[cfg(feature = "secret-proxy")]
4pub mod opaque;
5mod schema;
6
7use std::path::Path;
8use std::str::FromStr;
9
10use aes_gcm::aead::{Aead, KeyInit, OsRng};
11use aes_gcm::{AeadCore, Aes256Gcm, Nonce};
12use chrono::Utc;
13use rand::RngCore;
14use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
15use sqlx::{Row, SqlitePool};
16use tracing::debug;
17
18use starpod_core::{Result, StarpodError};
19
20// ── Vault entry metadata ─────────────────────────────────────────────────────
21
22/// Metadata for a vault entry (returned by [`Vault::get_entry`] / [`Vault::list_entries`]).
23///
24/// Does **not** include the decrypted value — only classification and timestamps.
25#[derive(Debug, Clone)]
26pub struct VaultEntry {
27    pub key: String,
28    /// Whether this entry holds a secret that should be opaque-ified when the
29    /// proxy is enabled. `true` (default) for API keys/tokens, `false` for
30    /// non-sensitive config like `SENTRY_DSN`.
31    pub is_secret: bool,
32    /// Hostnames where this secret may be sent (e.g. `["api.openai.com"]`).
33    /// `None` means unrestricted.
34    pub allowed_hosts: Option<Vec<String>>,
35    pub created_at: String,
36    pub updated_at: String,
37}
38
39// ── System keys ──────────────────────────────────────────────────────────────
40
41/// Environment variable names that are system-managed secrets.
42///
43/// These keys hold LLM provider credentials, service tokens, and platform
44/// secrets. The agent must never read or overwrite them at runtime.
45///
46/// Used by [`is_system_key`] and the `EnvGet` tool to block agent access.
47///
48/// # Keys
49///
50/// | Category | Keys |
51/// |----------|------|
52/// | LLM providers | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`, `DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY` |
53/// | Services | `BRAVE_API_KEY`, `TELEGRAM_BOT_TOKEN` |
54///
55/// Note: `STARPOD_API_KEY` is NOT a vault secret — it is pre-seeded into the
56/// auth database (`core.db`) at build time via `bootstrap_admin()`.
57pub const SYSTEM_KEYS: &[&str] = &[
58    // LLM providers
59    "ANTHROPIC_API_KEY",
60    "OPENAI_API_KEY",
61    "GEMINI_API_KEY",
62    "GROQ_API_KEY",
63    "DEEPSEEK_API_KEY",
64    "OPENROUTER_API_KEY",
65    // Services
66    "BRAVE_API_KEY",
67    "TELEGRAM_BOT_TOKEN",
68];
69
70/// Returns `true` if `key` is a system-managed secret that the agent
71/// must not read or modify at runtime.
72///
73/// Comparison is case-insensitive.
74///
75/// ```
76/// assert!(starpod_vault::is_system_key("ANTHROPIC_API_KEY"));
77/// assert!(starpod_vault::is_system_key("anthropic_api_key"));
78/// assert!(!starpod_vault::is_system_key("MY_CUSTOM_VAR"));
79/// ```
80pub fn is_system_key(key: &str) -> bool {
81    let upper = key.to_uppercase();
82    SYSTEM_KEYS.iter().any(|&k| k == upper)
83}
84
85/// Encrypted credential vault backed by SQLite + AES-256-GCM.
86pub struct Vault {
87    pool: SqlitePool,
88    cipher: Aes256Gcm,
89}
90
91impl Vault {
92    /// Open (or create) a vault at the given database path.
93    ///
94    /// `master_key` must be exactly 32 bytes.
95    pub async fn new(db_path: &Path, master_key: &[u8; 32]) -> Result<Self> {
96        // Ensure parent directory exists
97        if let Some(parent) = db_path.parent() {
98            std::fs::create_dir_all(parent)?;
99        }
100
101        let opts =
102            SqliteConnectOptions::from_str(&format!("sqlite://{}?mode=rwc", db_path.display()))
103                .map_err(|e| StarpodError::Database(format!("Invalid DB path: {}", e)))?
104                .pragma("journal_mode", "WAL")
105                .pragma("busy_timeout", "5000")
106                .pragma("synchronous", "NORMAL");
107
108        let pool = SqlitePoolOptions::new()
109            .max_connections(1)
110            .connect_with(opts)
111            .await
112            .map_err(|e| StarpodError::Database(format!("Failed to open vault db: {}", e)))?;
113
114        schema::run_migrations(&pool).await?;
115
116        let cipher = Aes256Gcm::new_from_slice(master_key)
117            .map_err(|e| StarpodError::Vault(format!("Invalid master key: {}", e)))?;
118
119        Ok(Self { pool, cipher })
120    }
121
122    /// Create a Vault from an existing pool (for testing).
123    #[cfg(test)]
124    async fn from_pool(pool: SqlitePool, master_key: &[u8; 32]) -> Result<Self> {
125        schema::run_migrations(&pool).await?;
126        let cipher = Aes256Gcm::new_from_slice(master_key)
127            .map_err(|e| StarpodError::Vault(format!("Invalid master key: {}", e)))?;
128        Ok(Self { pool, cipher })
129    }
130
131    /// Retrieve and decrypt a value by key. Returns `None` if the key doesn't exist.
132    pub async fn get(&self, key: &str, user_id: Option<&str>) -> Result<Option<String>> {
133        let row = sqlx::query("SELECT encrypted_value, nonce FROM vault_entries WHERE key = ?1")
134            .bind(key)
135            .fetch_optional(&self.pool)
136            .await
137            .map_err(|e| StarpodError::Database(format!("Query failed: {}", e)))?;
138
139        let row = match row {
140            Some(r) => r,
141            None => return Ok(None),
142        };
143
144        let ciphertext: Vec<u8> = row.get("encrypted_value");
145        let nonce_bytes: Vec<u8> = row.get("nonce");
146
147        let nonce = Nonce::from_slice(&nonce_bytes);
148        let plaintext = self
149            .cipher
150            .decrypt(nonce, ciphertext.as_ref())
151            .map_err(|e| StarpodError::Vault(format!("Decryption failed: {}", e)))?;
152
153        let value = String::from_utf8(plaintext)
154            .map_err(|e| StarpodError::Vault(format!("Invalid UTF-8 in decrypted value: {}", e)))?;
155
156        self.audit(key, "get", user_id).await?;
157        debug!(key = %key, "Vault get");
158
159        Ok(Some(value))
160    }
161
162    /// Encrypt and store a value. Overwrites if the key already exists.
163    pub async fn set(&self, key: &str, value: &str, user_id: Option<&str>) -> Result<()> {
164        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
165        let ciphertext = self
166            .cipher
167            .encrypt(&nonce, value.as_bytes())
168            .map_err(|e| StarpodError::Vault(format!("Encryption failed: {}", e)))?;
169
170        let now = Utc::now().to_rfc3339();
171
172        sqlx::query(
173            "INSERT INTO vault_entries (key, encrypted_value, nonce, created_at, updated_at)
174             VALUES (?1, ?2, ?3, ?4, ?4)
175             ON CONFLICT(key) DO UPDATE SET
176                 encrypted_value = excluded.encrypted_value,
177                 nonce = excluded.nonce,
178                 updated_at = excluded.updated_at",
179        )
180        .bind(key)
181        .bind(&ciphertext)
182        .bind(nonce.as_slice())
183        .bind(&now)
184        .execute(&self.pool)
185        .await
186        .map_err(|e| StarpodError::Database(format!("Insert failed: {}", e)))?;
187
188        self.audit(key, "set", user_id).await?;
189        debug!(key = %key, "Vault set");
190
191        Ok(())
192    }
193
194    /// Delete a key from the vault.
195    pub async fn delete(&self, key: &str, user_id: Option<&str>) -> Result<()> {
196        sqlx::query("DELETE FROM vault_entries WHERE key = ?1")
197            .bind(key)
198            .execute(&self.pool)
199            .await
200            .map_err(|e| StarpodError::Database(format!("Delete failed: {}", e)))?;
201
202        self.audit(key, "delete", user_id).await?;
203        debug!(key = %key, "Vault delete");
204
205        Ok(())
206    }
207
208    /// List all keys in the vault (without decrypting values).
209    pub async fn list_keys(&self) -> Result<Vec<String>> {
210        let rows = sqlx::query("SELECT key FROM vault_entries ORDER BY key")
211            .fetch_all(&self.pool)
212            .await
213            .map_err(|e| StarpodError::Database(format!("Query failed: {}", e)))?;
214
215        let keys: Vec<String> = rows.iter().map(|row| row.get("key")).collect();
216        Ok(keys)
217    }
218
219    /// Append an entry to the audit log.
220    pub async fn audit(&self, key: &str, action: &str, user_id: Option<&str>) -> Result<()> {
221        let now = Utc::now().to_rfc3339();
222        sqlx::query(
223            "INSERT INTO vault_audit (key, action, timestamp, user_id) VALUES (?1, ?2, ?3, ?4)",
224        )
225        .bind(key)
226        .bind(action)
227        .bind(&now)
228        .bind(user_id)
229        .execute(&self.pool)
230        .await
231        .map_err(|e| StarpodError::Database(format!("Audit log failed: {}", e)))?;
232        Ok(())
233    }
234
235    /// Log an env var access by the agent (e.g. via EnvGet tool).
236    ///
237    /// Records a `"env_read"` entry in the audit log without decrypting
238    /// anything — just tracks that the agent accessed this key.
239    pub async fn log_env_read(&self, key: &str, user_id: Option<&str>) -> Result<()> {
240        self.audit(key, "env_read", user_id).await
241    }
242
243    // ── Metadata-aware API (Phase 1: secret proxy) ──────────────────
244
245    /// Store a value with secret classification and allowed-host metadata.
246    ///
247    /// Like [`set`](Self::set) but also persists `is_secret` and
248    /// `allowed_hosts` for the proxy to enforce host binding.
249    pub async fn set_with_meta(
250        &self,
251        key: &str,
252        value: &str,
253        is_secret: bool,
254        allowed_hosts: Option<&[String]>,
255        user_id: Option<&str>,
256    ) -> Result<()> {
257        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
258        let ciphertext = self
259            .cipher
260            .encrypt(&nonce, value.as_bytes())
261            .map_err(|e| StarpodError::Vault(format!("Encryption failed: {}", e)))?;
262
263        let now = Utc::now().to_rfc3339();
264        let hosts_json: Option<String> =
265            allowed_hosts.map(|h| serde_json::to_string(h).unwrap_or_default());
266
267        sqlx::query(
268            "INSERT INTO vault_entries (key, encrypted_value, nonce, is_secret, allowed_hosts, created_at, updated_at)
269             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
270             ON CONFLICT(key) DO UPDATE SET
271                 encrypted_value = excluded.encrypted_value,
272                 nonce = excluded.nonce,
273                 is_secret = excluded.is_secret,
274                 allowed_hosts = excluded.allowed_hosts,
275                 updated_at = excluded.updated_at",
276        )
277        .bind(key)
278        .bind(&ciphertext)
279        .bind(nonce.as_slice())
280        .bind(is_secret as i32)
281        .bind(&hosts_json)
282        .bind(&now)
283        .execute(&self.pool)
284        .await
285        .map_err(|e| StarpodError::Database(format!("Insert failed: {}", e)))?;
286
287        self.audit(key, "set", user_id).await?;
288        debug!(key = %key, is_secret = %is_secret, "Vault set_with_meta");
289
290        Ok(())
291    }
292
293    /// Update only the metadata (`is_secret`, `allowed_hosts`) for an existing entry.
294    ///
295    /// Does **not** touch the encrypted value or nonce. Returns `Ok(false)` if the
296    /// key does not exist.
297    pub async fn update_meta(
298        &self,
299        key: &str,
300        is_secret: bool,
301        allowed_hosts: Option<&[String]>,
302    ) -> Result<bool> {
303        let now = Utc::now().to_rfc3339();
304        let hosts_json: Option<String> =
305            allowed_hosts.map(|h| serde_json::to_string(h).unwrap_or_default());
306
307        let result = sqlx::query(
308            "UPDATE vault_entries SET is_secret = ?1, allowed_hosts = ?2, updated_at = ?3
309             WHERE key = ?4",
310        )
311        .bind(is_secret as i32)
312        .bind(&hosts_json)
313        .bind(&now)
314        .bind(key)
315        .execute(&self.pool)
316        .await
317        .map_err(|e| StarpodError::Database(format!("Update failed: {}", e)))?;
318
319        if result.rows_affected() > 0 {
320            self.audit(key, "update_meta", None).await?;
321            debug!(key = %key, is_secret = %is_secret, "Vault update_meta");
322            Ok(true)
323        } else {
324            Ok(false)
325        }
326    }
327
328    /// Retrieve metadata for a single vault entry (without decrypting the value).
329    pub async fn get_entry(&self, key: &str) -> Result<Option<VaultEntry>> {
330        let row = sqlx::query(
331            "SELECT key, is_secret, allowed_hosts, created_at, updated_at
332             FROM vault_entries WHERE key = ?1",
333        )
334        .bind(key)
335        .fetch_optional(&self.pool)
336        .await
337        .map_err(|e| StarpodError::Database(format!("Query failed: {}", e)))?;
338
339        Ok(row.map(|r| VaultEntry {
340            key: r.get("key"),
341            is_secret: r.get::<i32, _>("is_secret") != 0,
342            allowed_hosts: r
343                .get::<Option<String>, _>("allowed_hosts")
344                .and_then(|s| serde_json::from_str(&s).ok()),
345            created_at: r.get("created_at"),
346            updated_at: r.get("updated_at"),
347        }))
348    }
349
350    /// List all vault entries with metadata (no decrypted values).
351    pub async fn list_entries(&self) -> Result<Vec<VaultEntry>> {
352        let rows = sqlx::query(
353            "SELECT key, is_secret, allowed_hosts, created_at, updated_at
354             FROM vault_entries ORDER BY key",
355        )
356        .fetch_all(&self.pool)
357        .await
358        .map_err(|e| StarpodError::Database(format!("Query failed: {}", e)))?;
359
360        Ok(rows
361            .iter()
362            .map(|r| VaultEntry {
363                key: r.get("key"),
364                is_secret: r.get::<i32, _>("is_secret") != 0,
365                allowed_hosts: r
366                    .get::<Option<String>, _>("allowed_hosts")
367                    .and_then(|s| serde_json::from_str(&s).ok()),
368                created_at: r.get("created_at"),
369                updated_at: r.get("updated_at"),
370            })
371            .collect())
372    }
373
374    /// Expose the cipher for opaque token operations.
375    #[cfg(feature = "secret-proxy")]
376    pub fn cipher(&self) -> &Aes256Gcm {
377        &self.cipher
378    }
379}
380
381/// Derive or load the 32-byte master key for a vault instance.
382///
383/// On first call, generates a random key and stores it at `db_dir/.vault_key`.
384/// On subsequent calls, reads from that file. The key file is per-instance
385/// and should never be committed to version control.
386pub fn derive_master_key(db_dir: &Path) -> Result<[u8; 32]> {
387    let key_path = db_dir.join(".vault_key");
388
389    if key_path.exists() {
390        let data = std::fs::read(&key_path)
391            .map_err(|e| StarpodError::Vault(format!("Failed to read vault key: {}", e)))?;
392        if data.len() != 32 {
393            return Err(StarpodError::Vault(format!(
394                "Vault key file has invalid length ({} bytes, expected 32)",
395                data.len()
396            )));
397        }
398        let mut key = [0u8; 32];
399        key.copy_from_slice(&data);
400        Ok(key)
401    } else {
402        std::fs::create_dir_all(db_dir)
403            .map_err(|e| StarpodError::Vault(format!("Failed to create db dir: {}", e)))?;
404        let mut key = [0u8; 32];
405        rand::thread_rng().fill_bytes(&mut key);
406        std::fs::write(&key_path, key)
407            .map_err(|e| StarpodError::Vault(format!("Failed to write vault key: {}", e)))?;
408        // Best-effort: restrict permissions on Unix
409        #[cfg(unix)]
410        {
411            use std::os::unix::fs::PermissionsExt;
412            let _ = std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600));
413        }
414        debug!("Generated new vault master key at {}", key_path.display());
415        Ok(key)
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    fn test_key() -> [u8; 32] {
424        [0xAB; 32]
425    }
426
427    async fn setup() -> Vault {
428        let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
429        Vault::from_pool(pool, &test_key()).await.unwrap()
430    }
431
432    #[tokio::test]
433    async fn test_set_and_get() {
434        let vault = setup().await;
435        vault.set("api_key", "sk-secret-123", None).await.unwrap();
436        let val = vault.get("api_key", None).await.unwrap();
437        assert_eq!(val.as_deref(), Some("sk-secret-123"));
438    }
439
440    #[tokio::test]
441    async fn test_get_nonexistent() {
442        let vault = setup().await;
443        let val = vault.get("nope", None).await.unwrap();
444        assert_eq!(val, None);
445    }
446
447    #[tokio::test]
448    async fn test_overwrite() {
449        let vault = setup().await;
450        vault.set("token", "old", None).await.unwrap();
451        vault.set("token", "new", None).await.unwrap();
452        let val = vault.get("token", None).await.unwrap();
453        assert_eq!(val.as_deref(), Some("new"));
454    }
455
456    #[tokio::test]
457    async fn test_delete() {
458        let vault = setup().await;
459        vault.set("temp", "value", None).await.unwrap();
460        vault.delete("temp", None).await.unwrap();
461        let val = vault.get("temp", None).await.unwrap();
462        assert_eq!(val, None);
463    }
464
465    #[tokio::test]
466    async fn test_list_keys() {
467        let vault = setup().await;
468        vault.set("beta", "2", None).await.unwrap();
469        vault.set("alpha", "1", None).await.unwrap();
470        vault.set("gamma", "3", None).await.unwrap();
471
472        let keys = vault.list_keys().await.unwrap();
473        assert_eq!(keys, vec!["alpha", "beta", "gamma"]);
474    }
475
476    #[tokio::test]
477    async fn test_wrong_key_cannot_decrypt() {
478        let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
479
480        // Set with one key
481        let vault1 = Vault::from_pool(pool.clone(), &[0xAA; 32]).await.unwrap();
482        vault1.set("secret", "hidden", None).await.unwrap();
483
484        // Try to read with a different key
485        let vault2 = Vault::from_pool(pool, &[0xBB; 32]).await.unwrap();
486        let result = vault2.get("secret", None).await;
487        assert!(result.is_err(), "Should fail to decrypt with wrong key");
488    }
489
490    #[tokio::test]
491    async fn test_audit_log() {
492        let vault = setup().await;
493        vault.set("k1", "v1", None).await.unwrap();
494        vault.get("k1", None).await.unwrap();
495        vault.delete("k1", None).await.unwrap();
496
497        // Check audit log directly
498        let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM vault_audit")
499            .fetch_one(&vault.pool)
500            .await
501            .unwrap();
502        assert_eq!(count.0, 3); // set + get + delete
503    }
504
505    #[tokio::test]
506    async fn test_audit_log_tracks_user_id() {
507        let vault = setup().await;
508
509        vault.set("k1", "v1", Some("alice")).await.unwrap();
510        vault.get("k1", Some("bob")).await.unwrap();
511        vault.delete("k1", None).await.unwrap();
512        vault.log_env_read("HOME", Some("charlie")).await.unwrap();
513
514        let rows = sqlx::query_as::<_, (String, Option<String>)>(
515            "SELECT action, user_id FROM vault_audit ORDER BY id",
516        )
517        .fetch_all(&vault.pool)
518        .await
519        .unwrap();
520
521        assert_eq!(rows.len(), 4);
522        assert_eq!(rows[0], ("set".to_string(), Some("alice".to_string())));
523        assert_eq!(rows[1], ("get".to_string(), Some("bob".to_string())));
524        assert_eq!(rows[2], ("delete".to_string(), None));
525        assert_eq!(
526            rows[3],
527            ("env_read".to_string(), Some("charlie".to_string()))
528        );
529    }
530
531    // ── derive_master_key tests ───────────────────────────────────
532
533    #[test]
534    fn test_derive_master_key_creates_new() {
535        let tmp = tempfile::TempDir::new().unwrap();
536        let db_dir = tmp.path().join("db");
537        // db_dir doesn't exist yet — derive_master_key should create it
538        let key = derive_master_key(&db_dir).unwrap();
539        assert_eq!(key.len(), 32);
540        assert!(db_dir.join(".vault_key").exists());
541    }
542
543    #[test]
544    fn test_derive_master_key_reads_existing() {
545        let tmp = tempfile::TempDir::new().unwrap();
546        let db_dir = tmp.path().join("db");
547
548        let key1 = derive_master_key(&db_dir).unwrap();
549        let key2 = derive_master_key(&db_dir).unwrap();
550        // Same key on second call
551        assert_eq!(key1, key2);
552    }
553
554    #[test]
555    fn test_derive_master_key_rejects_wrong_length() {
556        let tmp = tempfile::TempDir::new().unwrap();
557        let db_dir = tmp.path().join("db");
558        std::fs::create_dir_all(&db_dir).unwrap();
559        // Write a key with wrong length
560        std::fs::write(db_dir.join(".vault_key"), [0u8; 16]).unwrap();
561
562        let result = derive_master_key(&db_dir);
563        assert!(result.is_err());
564        assert!(result.unwrap_err().to_string().contains("invalid length"));
565    }
566
567    #[test]
568    fn test_derive_master_key_different_dirs_different_keys() {
569        let tmp = tempfile::TempDir::new().unwrap();
570        let key1 = derive_master_key(&tmp.path().join("a")).unwrap();
571        let key2 = derive_master_key(&tmp.path().join("b")).unwrap();
572        assert_ne!(key1, key2);
573    }
574
575    // ── is_system_key tests ──────────────────────────────────────
576
577    #[test]
578    fn test_system_keys_are_recognized() {
579        for key in super::SYSTEM_KEYS {
580            assert!(super::is_system_key(key), "{} should be a system key", key);
581        }
582    }
583
584    #[test]
585    fn test_system_keys_case_insensitive() {
586        assert!(super::is_system_key("anthropic_api_key"));
587        assert!(super::is_system_key("Telegram_Bot_Token"));
588    }
589
590    #[test]
591    fn test_non_system_keys() {
592        assert!(!super::is_system_key("HOME"));
593        assert!(!super::is_system_key("DB_PASSWORD"));
594        assert!(!super::is_system_key("MY_SECRET"));
595        assert!(!super::is_system_key("CUSTOM_TOKEN"));
596    }
597
598    // ── set_with_meta / get_entry / list_entries tests ───────────
599
600    #[tokio::test]
601    async fn test_set_with_meta_and_get_entry() {
602        let vault = setup().await;
603        let hosts = vec!["api.github.com".to_string()];
604        vault
605            .set_with_meta("GH_TOKEN", "ghp_abc", true, Some(&hosts), None)
606            .await
607            .unwrap();
608
609        let entry = vault.get_entry("GH_TOKEN").await.unwrap().unwrap();
610        assert_eq!(entry.key, "GH_TOKEN");
611        assert!(entry.is_secret);
612        assert_eq!(
613            entry.allowed_hosts,
614            Some(vec!["api.github.com".to_string()])
615        );
616
617        // Value should also be retrievable
618        let val = vault.get("GH_TOKEN", None).await.unwrap();
619        assert_eq!(val.as_deref(), Some("ghp_abc"));
620    }
621
622    #[tokio::test]
623    async fn test_set_with_meta_non_secret() {
624        let vault = setup().await;
625        vault
626            .set_with_meta("SENTRY_DSN", "https://sentry.io/123", false, None, None)
627            .await
628            .unwrap();
629
630        let entry = vault.get_entry("SENTRY_DSN").await.unwrap().unwrap();
631        assert!(!entry.is_secret);
632        assert!(entry.allowed_hosts.is_none());
633    }
634
635    #[tokio::test]
636    async fn test_set_with_meta_overwrites() {
637        let vault = setup().await;
638        vault
639            .set_with_meta("KEY", "old", true, None, None)
640            .await
641            .unwrap();
642        vault
643            .set_with_meta(
644                "KEY",
645                "new",
646                false,
647                Some(&["example.com".to_string()]),
648                None,
649            )
650            .await
651            .unwrap();
652
653        let entry = vault.get_entry("KEY").await.unwrap().unwrap();
654        assert!(!entry.is_secret);
655        assert_eq!(entry.allowed_hosts, Some(vec!["example.com".to_string()]));
656        assert_eq!(
657            vault.get("KEY", None).await.unwrap().as_deref(),
658            Some("new")
659        );
660    }
661
662    #[tokio::test]
663    async fn test_plain_set_preserves_defaults() {
664        let vault = setup().await;
665        // Plain set() should get is_secret=1, allowed_hosts=NULL
666        vault.set("TOKEN", "val", None).await.unwrap();
667
668        let entry = vault.get_entry("TOKEN").await.unwrap().unwrap();
669        assert!(entry.is_secret); // default 1
670        assert!(entry.allowed_hosts.is_none()); // default NULL
671    }
672
673    #[tokio::test]
674    async fn test_list_entries() {
675        let vault = setup().await;
676        vault
677            .set_with_meta("B_KEY", "v", true, None, None)
678            .await
679            .unwrap();
680        vault
681            .set_with_meta(
682                "A_KEY",
683                "v",
684                false,
685                Some(&["api.example.com".to_string()]),
686                None,
687            )
688            .await
689            .unwrap();
690
691        let entries = vault.list_entries().await.unwrap();
692        assert_eq!(entries.len(), 2);
693        assert_eq!(entries[0].key, "A_KEY");
694        assert!(!entries[0].is_secret);
695        assert_eq!(entries[1].key, "B_KEY");
696        assert!(entries[1].is_secret);
697    }
698
699    #[tokio::test]
700    async fn test_get_entry_nonexistent() {
701        let vault = setup().await;
702        assert!(vault.get_entry("NOPE").await.unwrap().is_none());
703    }
704
705    // ── update_meta tests ────────────────────────────────────────
706
707    #[tokio::test]
708    async fn test_update_meta_changes_is_secret() {
709        let vault = setup().await;
710        vault.set("TOKEN", "val", None).await.unwrap();
711
712        // Default is_secret = true
713        let entry = vault.get_entry("TOKEN").await.unwrap().unwrap();
714        assert!(entry.is_secret);
715
716        // Flip to non-secret
717        assert!(vault.update_meta("TOKEN", false, None).await.unwrap());
718
719        let entry = vault.get_entry("TOKEN").await.unwrap().unwrap();
720        assert!(!entry.is_secret);
721
722        // Value should be unchanged
723        assert_eq!(
724            vault.get("TOKEN", None).await.unwrap().as_deref(),
725            Some("val")
726        );
727    }
728
729    #[tokio::test]
730    async fn test_update_meta_changes_hosts() {
731        let vault = setup().await;
732        vault.set("KEY", "v", None).await.unwrap();
733
734        // Initially no hosts
735        let entry = vault.get_entry("KEY").await.unwrap().unwrap();
736        assert!(entry.allowed_hosts.is_none());
737
738        // Set hosts
739        let hosts = vec!["api.example.com".to_string()];
740        assert!(vault.update_meta("KEY", true, Some(&hosts)).await.unwrap());
741
742        let entry = vault.get_entry("KEY").await.unwrap().unwrap();
743        assert_eq!(
744            entry.allowed_hosts,
745            Some(vec!["api.example.com".to_string()])
746        );
747    }
748
749    #[tokio::test]
750    async fn test_update_meta_clears_hosts() {
751        let vault = setup().await;
752        vault
753            .set_with_meta("KEY", "v", true, Some(&["host.com".to_string()]), None)
754            .await
755            .unwrap();
756
757        // Clear hosts by passing None
758        assert!(vault.update_meta("KEY", true, None).await.unwrap());
759
760        let entry = vault.get_entry("KEY").await.unwrap().unwrap();
761        assert!(entry.allowed_hosts.is_none());
762    }
763
764    #[tokio::test]
765    async fn test_update_meta_nonexistent_key() {
766        let vault = setup().await;
767        // Returns false for missing key
768        assert!(!vault.update_meta("NOPE", true, None).await.unwrap());
769    }
770
771    #[tokio::test]
772    async fn test_update_meta_audit_logged() {
773        let vault = setup().await;
774        vault.set("KEY", "v", None).await.unwrap();
775        vault.update_meta("KEY", false, None).await.unwrap();
776
777        let rows = sqlx::query_as::<_, (String,)>("SELECT action FROM vault_audit ORDER BY id")
778            .fetch_all(&vault.pool)
779            .await
780            .unwrap();
781
782        assert_eq!(rows.len(), 2);
783        assert_eq!(rows[0].0, "set");
784        assert_eq!(rows[1].0, "update_meta");
785    }
786
787    // ── Metadata stress tests ────────────────────────────────────
788
789    #[tokio::test]
790    async fn test_set_with_meta_many_hosts() {
791        let vault = setup().await;
792        let hosts: Vec<String> = (0..200).map(|i| format!("host-{i}.example.com")).collect();
793        vault
794            .set_with_meta("KEY", "v", true, Some(&hosts), None)
795            .await
796            .unwrap();
797
798        let entry = vault.get_entry("KEY").await.unwrap().unwrap();
799        assert_eq!(entry.allowed_hosts.unwrap().len(), 200);
800    }
801
802    #[tokio::test]
803    async fn test_set_with_meta_unicode_hosts() {
804        let vault = setup().await;
805        let hosts = vec!["api.例え.jp".to_string(), "api.مثال.com".to_string()];
806        vault
807            .set_with_meta("KEY", "v", true, Some(&hosts), None)
808            .await
809            .unwrap();
810
811        let entry = vault.get_entry("KEY").await.unwrap().unwrap();
812        assert_eq!(entry.allowed_hosts.unwrap(), hosts);
813    }
814
815    #[tokio::test]
816    async fn test_set_with_meta_empty_hosts_vec() {
817        let vault = setup().await;
818        // Empty vec (not None) — should store as "[]"
819        vault
820            .set_with_meta("KEY", "v", true, Some(&[]), None)
821            .await
822            .unwrap();
823
824        let entry = vault.get_entry("KEY").await.unwrap().unwrap();
825        assert_eq!(entry.allowed_hosts, Some(vec![]));
826    }
827
828    #[tokio::test]
829    async fn test_rapid_meta_updates() {
830        let vault = setup().await;
831        vault.set("KEY", "v", None).await.unwrap();
832
833        // Rapidly toggle is_secret
834        for i in 0..50 {
835            let is_secret = i % 2 == 0;
836            vault.update_meta("KEY", is_secret, None).await.unwrap();
837        }
838
839        // Final state should be is_secret = false (49 is odd, 49%2=1, so false)
840        let entry = vault.get_entry("KEY").await.unwrap().unwrap();
841        assert!(!entry.is_secret);
842
843        // Value should be untouched through all meta updates
844        assert_eq!(vault.get("KEY", None).await.unwrap().as_deref(), Some("v"));
845    }
846
847    #[tokio::test]
848    async fn test_set_overwrite_does_not_clobber_metadata() {
849        let vault = setup().await;
850        // Set with metadata
851        vault
852            .set_with_meta("KEY", "v1", false, Some(&["host.com".to_string()]), None)
853            .await
854            .unwrap();
855
856        // Plain set() overwrites value but should NOT touch is_secret/allowed_hosts
857        vault.set("KEY", "v2", None).await.unwrap();
858
859        let entry = vault.get_entry("KEY").await.unwrap().unwrap();
860        // is_secret and allowed_hosts should be unchanged from the set_with_meta call
861        assert!(!entry.is_secret);
862        assert_eq!(entry.allowed_hosts, Some(vec!["host.com".to_string()]));
863        // Value should be updated
864        assert_eq!(vault.get("KEY", None).await.unwrap().as_deref(), Some("v2"));
865    }
866
867    #[tokio::test]
868    async fn test_special_chars_in_key_name() {
869        let vault = setup().await;
870        // Keys with unusual but valid characters
871        for key in &["MY_KEY_123", "a", "A_B_C_D_E_F"] {
872            vault
873                .set_with_meta(key, "val", true, None, None)
874                .await
875                .unwrap();
876            let entry = vault.get_entry(key).await.unwrap().unwrap();
877            assert_eq!(entry.key, *key);
878        }
879    }
880
881    #[tokio::test]
882    async fn test_list_entries_empty() {
883        let vault = setup().await;
884        let entries = vault.list_entries().await.unwrap();
885        assert!(entries.is_empty());
886    }
887
888    #[tokio::test]
889    async fn test_delete_cleans_up_metadata() {
890        let vault = setup().await;
891        vault
892            .set_with_meta("KEY", "v", true, Some(&["h.com".to_string()]), None)
893            .await
894            .unwrap();
895        vault.delete("KEY", None).await.unwrap();
896
897        // Entry should be completely gone
898        assert!(vault.get_entry("KEY").await.unwrap().is_none());
899        assert!(vault.get("KEY", None).await.unwrap().is_none());
900        assert!(vault.list_entries().await.unwrap().is_empty());
901    }
902
903    #[tokio::test]
904    async fn test_concurrent_set_with_meta() {
905        // SQLite WAL mode should handle concurrent writes
906        let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
907        let vault = Vault::from_pool(pool, &test_key()).await.unwrap();
908
909        let mut handles = vec![];
910        for i in 0..20 {
911            let vault_pool = vault.pool.clone();
912            let cipher = vault.cipher.clone();
913            handles.push(tokio::spawn(async move {
914                let v = Vault {
915                    pool: vault_pool,
916                    cipher,
917                };
918                let key = format!("KEY_{i}");
919                v.set_with_meta(&key, &format!("val_{i}"), true, None, None)
920                    .await
921                    .unwrap();
922            }));
923        }
924
925        for h in handles {
926            h.await.unwrap();
927        }
928
929        let entries = vault.list_entries().await.unwrap();
930        assert_eq!(entries.len(), 20);
931    }
932}