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#[derive(Debug, Clone)]
26pub struct VaultEntry {
27 pub key: String,
28 pub is_secret: bool,
32 pub allowed_hosts: Option<Vec<String>>,
35 pub created_at: String,
36 pub updated_at: String,
37}
38
39pub const SYSTEM_KEYS: &[&str] = &[
58 "ANTHROPIC_API_KEY",
60 "OPENAI_API_KEY",
61 "GEMINI_API_KEY",
62 "GROQ_API_KEY",
63 "DEEPSEEK_API_KEY",
64 "OPENROUTER_API_KEY",
65 "BRAVE_API_KEY",
67 "TELEGRAM_BOT_TOKEN",
68];
69
70pub fn is_system_key(key: &str) -> bool {
81 let upper = key.to_uppercase();
82 SYSTEM_KEYS.iter().any(|&k| k == upper)
83}
84
85pub struct Vault {
87 pool: SqlitePool,
88 cipher: Aes256Gcm,
89}
90
91impl Vault {
92 pub async fn new(db_path: &Path, master_key: &[u8; 32]) -> Result<Self> {
96 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 #[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 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 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 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 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 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 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 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 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 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 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 #[cfg(feature = "secret-proxy")]
376 pub fn cipher(&self) -> &Aes256Gcm {
377 &self.cipher
378 }
379}
380
381pub 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 #[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 let vault1 = Vault::from_pool(pool.clone(), &[0xAA; 32]).await.unwrap();
482 vault1.set("secret", "hidden", None).await.unwrap();
483
484 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 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); }
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 #[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 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 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 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 #[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 #[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 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 vault.set("TOKEN", "val", None).await.unwrap();
667
668 let entry = vault.get_entry("TOKEN").await.unwrap().unwrap();
669 assert!(entry.is_secret); assert!(entry.allowed_hosts.is_none()); }
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 #[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 let entry = vault.get_entry("TOKEN").await.unwrap().unwrap();
714 assert!(entry.is_secret);
715
716 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 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 let entry = vault.get_entry("KEY").await.unwrap().unwrap();
736 assert!(entry.allowed_hosts.is_none());
737
738 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 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 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 #[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 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 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 let entry = vault.get_entry("KEY").await.unwrap().unwrap();
841 assert!(!entry.is_secret);
842
843 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 vault
852 .set_with_meta("KEY", "v1", false, Some(&["host.com".to_string()]), None)
853 .await
854 .unwrap();
855
856 vault.set("KEY", "v2", None).await.unwrap();
858
859 let entry = vault.get_entry("KEY").await.unwrap().unwrap();
860 assert!(!entry.is_secret);
862 assert_eq!(entry.allowed_hosts, Some(vec!["host.com".to_string()]));
863 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 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 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 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}