1use argon2::{Algorithm, Argon2, Params, Version};
7pub use rusqlite::Connection as SqlConnection;
8use rusqlite::{Connection, OpenFlags, OptionalExtension};
9use std::fs;
10use std::path::Path;
11pub use zeroize::Zeroize;
12
13pub mod generators;
14pub mod jwks;
15pub use generators::{generate_certificate, generate_ssh_keypair};
16
17pub mod calendar;
21pub mod ics_feeds;
25pub mod uid_registry;
29
30pub mod nodes;
33pub mod nodes_apply;
34
35pub mod blast;
38pub mod config_history;
39
40pub mod stack;
43pub mod textdiff;
44pub mod secret_types;
47pub mod cxf;
49pub mod bundle_import;
51pub mod bundle_scope;
52pub mod catalogue;
53pub mod config_check;
54pub mod oauth;
55pub mod pgp;
56pub mod php_config;
57pub mod session_import;
58pub mod storage;
59pub mod templates;
60pub mod toml_import;
61pub mod type_emit;
62
63pub mod permex;
64pub mod pool;
65
66pub mod composite;
69
70pub mod entropy;
76
77#[cfg(feature = "tls")]
78pub mod tls;
79
80#[cfg(feature = "telemetry")]
81pub mod telemetry;
82pub use permex::{
83 eval as eval_perm_expr, parse as parse_perm_expr, EntryView, Expr as PermExpr,
84 Field as PermField,
85};
86
87pub mod users;
88
89pub mod totp;
95
96pub mod totp_import;
101pub use users::{
102 assign_user_class, authority_tier, class_authority_tier, create_user, create_user_class,
103 create_user_token, delete_user, delete_user_class, effective_permission_expr,
104 ensure_owner_user, filter_vault_for_user, get_class_permissions, get_permission_expr,
105 get_user_capabilities, get_user_permissions, glob_matches, init_users_schema,
106 list_user_classes, list_user_tokens, list_users, merge_user_vault_write, rename_user,
107 revoke_user_token, seed_default_admin, set_class_permissions, set_permission_expr,
108 set_user_password, set_user_permissions, token_user_id, update_user_class, user_authority_tier,
109 verify_user_password, verify_user_token, AdminSeed, ClassPermission, PermissionRecord,
110 TokenRecord, UserClass, UserRecord,
111};
112
113pub const SALT_LEN: usize = 16;
116pub const KEY_LEN: usize = 32;
117
118const A2_M_COST: u32 = 65_536;
119const A2_T_COST: u32 = 3;
120const A2_P_COST: u32 = 1;
121
122pub type VaultKey = [u8; KEY_LEN];
124
125pub fn derive_key(password: &str, salt: &[u8]) -> Result<VaultKey, String> {
130 let params =
131 Params::new(A2_M_COST, A2_T_COST, A2_P_COST, Some(KEY_LEN)).map_err(|e| e.to_string())?;
132 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
133 let mut key = [0u8; KEY_LEN];
134 argon2
135 .hash_password_into(password.as_bytes(), salt, &mut key)
136 .map_err(|e| e.to_string())?;
137 Ok(key)
138}
139
140pub fn restrict_to_owner(path: &Path) -> Result<(), String> {
146 #[cfg(unix)]
147 {
148 use std::os::unix::fs::PermissionsExt;
149 fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())?;
150 }
151 #[cfg(not(unix))]
152 {
153 let _ = path;
154 }
155 Ok(())
156}
157
158pub fn check_salt_pairing(db_path: &Path, salt_path: &Path) -> Result<(), String> {
169 let db_exists = fs::metadata(db_path).map(|m| m.len() > 0).unwrap_or(false);
170 if db_exists && !salt_path.exists() {
171 return Err(format!(
172 "{} exists but {} is missing.\n\
173 The salt is 16 random bytes written once and stored nowhere else — without \n\
174 it this database cannot be opened by anyone, and nothing can recompute it.\n\
175 Restore both from an archive (`unv backup restore-archive`), or restore the \n\
176 vault contents from a .vaultbak (`unv backup import`), which does not need \n\
177 the original salt.",
178 db_path.display(),
179 salt_path.display()
180 ));
181 }
182 Ok(())
183}
184
185pub fn read_or_create_salt(salt_path: &Path) -> Result<[u8; SALT_LEN], String> {
187 if salt_path.exists() {
188 let raw = fs::read(salt_path).map_err(|e| e.to_string())?;
189 raw.try_into()
190 .map_err(|_| "vault.salt is corrupt (wrong length)".to_string())
191 } else {
192 use rand::RngCore;
193 let mut s = [0u8; SALT_LEN];
194 rand::thread_rng().fill_bytes(&mut s);
195 if let Some(parent) = salt_path.parent() {
196 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
197 }
198 fs::write(salt_path, s).map_err(|e| e.to_string())?;
199 restrict_to_owner(salt_path)?;
203 Ok(s)
204 }
205}
206
207pub fn open_db(db_path: &Path, key: &VaultKey) -> Result<Connection, String> {
214 if let Some(p) = db_path.parent() {
215 fs::create_dir_all(p).map_err(|e| e.to_string())?;
216 }
217 let conn = Connection::open_with_flags(
218 db_path,
219 OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
220 )
221 .map_err(|e| e.to_string())?;
222 conn.execute_batch(&format!("PRAGMA key = \"x'{}'\";", hex::encode(key)))
223 .map_err(|e| e.to_string())?;
224 conn.execute_batch("SELECT count(*) FROM sqlite_master;")
225 .map_err(|_| "Wrong master password".to_string())?;
226 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
228 .map_err(|e| e.to_string())?;
229 conn.busy_timeout(std::time::Duration::from_secs(5))
233 .map_err(|e| e.to_string())?;
234 conn.execute_batch("PRAGMA journal_size_limit=67108864;")
235 .map_err(|e| e.to_string())?;
236 restrict_to_owner(db_path)?;
242 for suffix in ["-wal", "-shm"] {
243 let mut side = db_path.as_os_str().to_owned();
244 side.push(suffix);
245 let side = std::path::PathBuf::from(side);
246 if side.exists() {
247 restrict_to_owner(&side)?;
248 }
249 }
250 Ok(conn)
251}
252
253pub fn init_schema(conn: &Connection) -> Result<(), String> {
257 conn.execute_batch(
258 "CREATE TABLE IF NOT EXISTS vault (
259 id INTEGER PRIMARY KEY CHECK (id = 1),
260 data TEXT NOT NULL
261 );
262 CREATE TABLE IF NOT EXISTS vault_audit (
263 id INTEGER PRIMARY KEY AUTOINCREMENT,
264 action TEXT NOT NULL,
265 entry_provider TEXT,
266 timestamp TEXT NOT NULL,
267 details TEXT
268 );
269 CREATE TABLE IF NOT EXISTS vault_meta (
270 key TEXT PRIMARY KEY,
271 value TEXT NOT NULL
272 );",
273 )
274 .map_err(|e| e.to_string())?;
275 let _ = conn.execute_batch("ALTER TABLE vault_audit ADD COLUMN entry_hash TEXT;");
277 let _ = conn.execute_batch("ALTER TABLE vault_audit ADD COLUMN prev_hash TEXT;");
278 let _ = conn.execute_batch("ALTER TABLE vault_audit ADD COLUMN actor TEXT;");
281 let _ = conn.execute_batch(
284 "CREATE INDEX IF NOT EXISTS vault_audit_provider ON vault_audit (entry_provider);
285 CREATE INDEX IF NOT EXISTS vault_audit_time ON vault_audit (timestamp);",
286 );
287 storage::init_schema(conn)?;
289 users::init_users_schema(conn)?;
291 ics_feeds::init_schema(conn)?;
293 config_history::init_schema(conn)?;
294 Ok(())
295}
296
297pub fn entry_ck(entry: &serde_json::Value) -> String {
311 if let Some(id) = entry.get("id").and_then(|v| v.as_str()) {
312 if !id.is_empty() {
313 return format!("id\u{1}{id}");
314 }
315 }
316 let field = |k: &str| entry.get(k).and_then(|v| v.as_str()).unwrap_or("");
317 format!(
318 "legacy\u{1}{}\u{1}{}\u{1}{}",
319 field("provider"),
320 field("account_name"),
321 field("key_id"),
322 )
323}
324
325pub const VAULT_SCHEMA_VERSION: u32 = 3;
352
353pub const SCHEMA_ERR: &str = "VAULT_SCHEMA_TOO_NEW";
357
358pub fn vault_schema_version(conn: &Connection) -> Result<Option<u32>, String> {
361 let raw: Option<String> = conn
362 .query_row(
363 "SELECT value FROM vault_meta WHERE key = 'schema_version'",
364 [],
365 |r| r.get(0),
366 )
367 .optional()
368 .map_err(|e| e.to_string())?;
369 match raw {
370 None => Ok(None),
371 Some(s) => s
375 .trim()
376 .parse::<u32>()
377 .map(Some)
378 .map_err(|_| format!("{SCHEMA_ERR}: unreadable schema_version {s:?}")),
379 }
380}
381
382pub fn check_schema_version(conn: &Connection) -> Result<(), String> {
388 match vault_schema_version(conn)? {
389 Some(v) if v > VAULT_SCHEMA_VERSION => Err(format!(
390 "{SCHEMA_ERR}: this vault was written by a newer version of UnENVerse \
391 (vault schema v{v}, this build understands v{VAULT_SCHEMA_VERSION}). \
392 Upgrade UnENVerse to open it — writing it with this build would drop \
393 the fields it does not understand."
394 )),
395 _ => Ok(()),
396 }
397}
398
399pub fn load_vault(conn: &Connection) -> Result<Option<serde_json::Value>, String> {
406 check_schema_version(conn)?;
407 storage::migrate_if_needed(conn, &iso_now())?;
409 storage::load(conn)
410}
411
412pub const MERGED_SUFFIX: &str = "+merged";
416
417pub fn ensure_current_schema(conn: &Connection) -> Result<(), String> {
422 check_schema_version(conn)?;
423 storage::migrate_if_needed(conn, &iso_now())
424}
425
426pub fn load_vault_lite(conn: &Connection) -> Result<Option<serde_json::Value>, String> {
430 check_schema_version(conn)?;
431 storage::migrate_if_needed(conn, &iso_now())?;
432 storage::load_lite(conn)
433}
434
435pub const CONFLICT_ERR: &str = "VAULT_CONFLICT";
438
439#[derive(Debug, Default, Clone, Copy)]
445pub struct SaveCtx<'a> {
446 pub actor: Option<&'a str>,
449 pub expect_version: Option<&'a str>,
453}
454
455pub fn vault_version(conn: &Connection) -> Result<Option<String>, String> {
461 storage::version(conn)
462}
463
464const HISTORIED_SECRET_FIELDS: [(&str, &str); 3] = [
470 ("api_key", "api_key"),
471 ("api_secret", "api_secret"),
472 ("totp_secret", "totp_secret"),
473];
474
475fn historied_extra_vars(entry: &serde_json::Value) -> Vec<(String, String)> {
487 entry
488 .get("extra_vars")
489 .and_then(|v| v.as_array())
490 .map(|arr| {
491 arr.iter()
492 .filter(|xv| !xv.get("public").and_then(|p| p.as_bool()).unwrap_or(false))
493 .filter_map(|xv| {
494 let k = xv.get("key").and_then(|v| v.as_str())?;
495 let v = xv.get("value").and_then(|v| v.as_str())?;
496 if k.is_empty() {
497 return None;
498 }
499 Some((k.to_string(), v.to_string()))
500 })
501 .collect()
502 })
503 .unwrap_or_default()
504}
505
506pub fn apply_row_patch(
514 mut doc: serde_json::Value,
515 patch: &serde_json::Value,
516) -> Result<serde_json::Value, String> {
517 use serde_json::Value;
518 fn ids(v: Option<&Value>, what: &str) -> Result<Vec<String>, String> {
519 let mut out = Vec::new();
520 for d in v.and_then(Value::as_array).into_iter().flatten() {
521 match d.as_str() {
522 Some(id) if !id.is_empty() => out.push(id.to_string()),
523 _ => return Err(format!("{what} takes a list of ids")),
524 }
525 }
526 Ok(out)
527 }
528 fn puts(v: Option<&Value>, what: &str) -> Result<Vec<(String, Value)>, String> {
529 let mut out = Vec::new();
530 for e in v.and_then(Value::as_array).into_iter().flatten() {
531 match e.get("id").and_then(Value::as_str) {
532 Some(id) if !id.is_empty() => out.push((id.to_string(), e.clone())),
533 _ => return Err(format!("Every {what} needs a string id")),
534 }
535 }
536 Ok(out)
537 }
538 fn apply(
539 doc: &mut Value,
540 key: &str,
541 put: Vec<(String, Value)>,
542 del: Vec<String>,
543 ) -> Result<(), String> {
544 let list = doc
545 .get_mut(key)
546 .and_then(Value::as_array_mut)
547 .ok_or_else(|| format!("stored vault has no {key}"))?;
548 let id_of = |e: &Value| e.get("id").and_then(Value::as_str).map(str::to_string);
549 list.retain(|e| id_of(e).is_none_or(|id| !del.contains(&id)));
550 for (id, entry) in put {
551 match list
552 .iter()
553 .position(|e| id_of(e).as_deref() == Some(id.as_str()))
554 {
555 Some(i) => list[i] = entry,
556 None => list.push(entry),
557 }
558 }
559 Ok(())
560 }
561 let (ep, ed) = (
562 puts(patch.get("put"), "put entry")?,
563 ids(patch.get("delete"), "delete")?,
564 );
565 let (pp, pd) = (
566 puts(patch.get("projects_put"), "put project")?,
567 ids(patch.get("projects_delete"), "projects_delete")?,
568 );
569 let cats = match patch.get("categories") {
570 None | Some(Value::Null) => None,
571 Some(Value::Array(a)) if a.iter().all(Value::is_string) => Some(Value::Array(a.clone())),
572 Some(_) => return Err("categories takes a list of strings".into()),
573 };
574 apply(&mut doc, "api_keys", ep, ed)?;
575 if !pp.is_empty() || !pd.is_empty() {
576 if doc.get("projects").is_none() {
577 doc["projects"] = Value::Array(Vec::new());
578 }
579 apply(&mut doc, "projects", pp, pd)?;
580 }
581 if let Some(c) = cats {
582 doc["user_categories"] = c;
583 }
584 Ok(doc)
585}
586
587pub fn save_vault(
607 conn: &Connection,
608 data: serde_json::Value,
609 ctx: SaveCtx<'_>,
610) -> Result<String, String> {
611 conn.execute_batch("BEGIN IMMEDIATE")
612 .map_err(|e| e.to_string())?;
613 match save_vault_txn(conn, data, ctx) {
614 Ok(hash) => {
615 conn.execute_batch("COMMIT").map_err(|e| e.to_string())?;
616 Ok(hash)
617 }
618 Err(e) => {
619 let _ = conn.execute_batch("ROLLBACK");
620 Err(e)
621 }
622 }
623}
624
625fn save_vault_txn(
631 conn: &Connection,
632 data: serde_json::Value,
633 ctx: SaveCtx<'_>,
634) -> Result<String, String> {
635 let actor = ctx.actor;
636 let now_str = iso_now();
637
638 check_schema_version(conn)?;
641 storage::migrate_in_txn(conn, &now_str)?;
643
644 let snap = storage::snapshot(conn)?;
648 let (mut ents, merged) = storage::merge(conn, &snap, storage::split(data), ctx.expect_version)?;
649
650 let stored: std::collections::HashMap<&str, &str> = snap
651 .iter()
652 .filter(|((kind, _), _)| kind == "entry")
653 .map(|((_, key), (rev, _))| (key.as_str(), rev.as_str()))
654 .collect();
655 let kept: std::collections::HashSet<&str> = ents
656 .iter()
657 .filter(|e| e.kind == "entry")
658 .map(|e| e.key.as_str())
659 .collect();
660 for key in stored.keys() {
661 if !kept.contains(*key) {
662 if let Some(old) = storage::load_ent(conn, "entry", key)? {
663 append_audit(conn, "delete", &old.provider(), &now_str, None, actor)?;
664 }
665 }
666 }
667
668 for ent in ents.iter_mut().filter(|e| e.kind == "entry") {
669 match stored.get(ent.key.as_str()) {
670 Some(rev) if *rev == ent.rev => continue,
672 Some(_) => {
673 let Some(old) = storage::load_ent(conn, "entry", &ent.key)? else {
674 continue;
675 };
676 let old_e = old.full();
677 let mut entry = ent.full();
678 let provider = ent.provider();
679 let entry = &mut entry;
680 for (field, label) in HISTORIED_SECRET_FIELDS {
689 let new_val = entry.get(field).and_then(|v| v.as_str()).unwrap_or("");
690 let old_val = old_e.get(field).and_then(|v| v.as_str()).unwrap_or("");
691 if new_val == old_val || old_val.is_empty() {
692 continue;
693 }
694 let mut history: Vec<serde_json::Value> = entry
695 .get("version_history")
696 .and_then(|v| v.as_array())
697 .cloned()
698 .unwrap_or_else(|| {
699 old_e
700 .get("version_history")
701 .and_then(|v| v.as_array())
702 .cloned()
703 .unwrap_or_default()
704 });
705 let mut record = serde_json::json!({ "value": old_val, "saved_at": now_str });
706 if field != "api_key" {
707 record["field"] = serde_json::json!(field);
708 }
709 history.insert(0, record);
710 history.truncate(50);
714 if let Some(obj) = entry.as_object_mut() {
715 obj.insert(
716 "version_history".to_string(),
717 serde_json::Value::Array(history),
718 );
719 }
720 append_audit(
721 conn,
722 "update",
723 &provider,
724 &now_str,
725 Some(&format!("{label} rotated")),
726 actor,
727 )?;
728 }
729
730 let old_vars = historied_extra_vars(&old_e);
741 if !old_vars.is_empty() {
742 let new_vars: std::collections::HashMap<String, String> =
743 historied_extra_vars(entry).into_iter().collect();
744 for (key, old_val) in old_vars {
745 if old_val.is_empty() {
746 continue;
747 }
748 if new_vars.get(&key).map(String::as_str) == Some(old_val.as_str()) {
749 continue;
750 }
751 let mut history: Vec<serde_json::Value> = entry
752 .get("version_history")
753 .and_then(|v| v.as_array())
754 .cloned()
755 .unwrap_or_default();
756 history.insert(
757 0,
758 serde_json::json!({
759 "value": old_val,
760 "saved_at": now_str,
761 "field": format!("extra_vars/{key}"),
764 }),
765 );
766 history.truncate(50);
767 if let Some(obj) = entry.as_object_mut() {
768 obj.insert(
769 "version_history".to_string(),
770 serde_json::Value::Array(history),
771 );
772 }
773 append_audit(
774 conn,
775 "update",
776 &provider,
777 &now_str,
778 Some(&format!("{key} rotated")),
779 actor,
780 )?;
781 }
782 }
783 if let Some(h) = entry
785 .as_object_mut()
786 .and_then(|o| o.remove("version_history"))
787 {
788 ent.history = Some(h);
789 }
790 }
791 None => {
792 append_audit(conn, "add", &ent.provider(), &now_str, None, actor)?;
793 }
794 }
795 }
796
797 let token = storage::write(conn, &snap, &ents, &now_str)?;
799 conn.execute(
803 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', ?1)",
804 rusqlite::params![VAULT_SCHEMA_VERSION.to_string()],
805 )
806 .map_err(|e| e.to_string())?;
807
808 if merged {
815 Ok(format!("{token}{MERGED_SUFFIX}"))
816 } else {
817 Ok(token)
818 }
819}
820
821pub fn verify_vault_integrity(conn: &Connection) -> Result<bool, String> {
824 storage::verify(conn)
825}
826
827pub fn get_expiring_entries(
832 conn: &Connection,
833 within_days: u32,
834) -> Result<Vec<serde_json::Value>, String> {
835 let data = load_vault(conn)?.unwrap_or_else(|| serde_json::json!({ "api_keys": [] }));
836 Ok(expiring_from_value(&data, within_days))
837}
838
839pub fn get_expiring_entries_for_user(
843 conn: &Connection,
844 within_days: u32,
845 read: Option<&permex::Expr>,
846) -> Result<Vec<serde_json::Value>, String> {
847 let data = load_vault(conn)?.unwrap_or_else(|| serde_json::json!({ "api_keys": [] }));
848 let filtered = filter_vault_for_user(data, read);
849 Ok(expiring_from_value(&filtered, within_days))
850}
851
852fn expiring_from_value(data: &serde_json::Value, within_days: u32) -> Vec<serde_json::Value> {
854 let now = time::OffsetDateTime::now_utc();
855 let cutoff = now + time::Duration::days(within_days as i64);
856 let today_str = fmt_date(&now);
857 let cutoff_str = fmt_date(&cutoff);
858
859 data.get("api_keys")
860 .and_then(|k| k.as_array())
861 .cloned()
862 .unwrap_or_default()
863 .into_iter()
864 .filter(|entry| {
865 entry
866 .get("expires_at")
867 .and_then(|v| v.as_str())
868 .is_some_and(|s| {
869 let d = &s[..s.len().min(10)];
870 d >= today_str.as_str() && d <= cutoff_str.as_str()
871 })
872 })
873 .collect()
874}
875
876fn fmt_date(dt: &time::OffsetDateTime) -> String {
877 format!("{:04}-{:02}-{:02}", dt.year(), dt.month() as u8, dt.day())
878}
879
880#[derive(Debug, serde::Serialize, serde::Deserialize)]
884pub struct AuditRow {
885 pub id: i64,
886 pub action: String,
887 pub entry_provider: Option<String>,
888 pub timestamp: String,
889 pub details: Option<String>,
890 pub entry_hash: Option<String>,
891 pub prev_hash: Option<String>,
892 pub actor: Option<String>,
895}
896
897fn append_audit(
899 conn: &Connection,
900 action: &str,
901 provider: &str,
902 timestamp: &str,
903 details: Option<&str>,
904 actor: Option<&str>,
905) -> Result<(), String> {
906 let prev_hash: Option<String> = conn
907 .query_row(
908 "SELECT entry_hash FROM vault_audit ORDER BY id DESC LIMIT 1",
909 [],
910 |row| row.get(0),
911 )
912 .optional()
913 .map_err(|e| e.to_string())?
914 .flatten();
915
916 let entry_hash = compute_audit_hash(
917 action,
918 provider,
919 timestamp,
920 actor,
921 prev_hash.as_deref().unwrap_or("genesis"),
922 );
923
924 conn.execute(
925 "INSERT INTO vault_audit \
926 (action, entry_provider, timestamp, details, entry_hash, prev_hash, actor) \
927 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
928 rusqlite::params![action, provider, timestamp, details, entry_hash, prev_hash, actor],
929 )
930 .map_err(|e| e.to_string())?;
931 Ok(())
932}
933
934pub fn record_event(
936 conn: &Connection,
937 action: &str,
938 provider: &str,
939 details: Option<&str>,
940 actor: Option<&str>,
941) -> Result<(), String> {
942 append_audit(conn, action, provider, &iso_now(), details, actor)
943}
944
945fn compute_audit_hash(
955 action: &str,
956 provider: &str,
957 timestamp: &str,
958 actor: Option<&str>,
959 prev_hash: &str,
960) -> String {
961 use sha2::{Digest, Sha256};
962 let mut h = Sha256::new();
963 match actor {
964 Some(a) => {
965 for part in [
966 action, "|", provider, "|", timestamp, "|", a, "|", prev_hash,
967 ] {
968 h.update(part.as_bytes());
969 }
970 }
971 None => {
972 for part in [action, "|", provider, "|", timestamp, "|", prev_hash] {
973 h.update(part.as_bytes());
974 }
975 }
976 }
977 hex::encode(h.finalize())
978}
979
980pub fn load_audit(conn: &Connection) -> Result<Vec<AuditRow>, String> {
982 let mut stmt = conn
983 .prepare(
984 "SELECT id, action, entry_provider, timestamp, details, entry_hash, prev_hash, actor \
985 FROM vault_audit ORDER BY id DESC",
986 )
987 .map_err(|e| e.to_string())?;
988
989 let rows: Vec<Result<AuditRow, _>> = stmt
990 .query_map([], |row| {
991 Ok(AuditRow {
992 id: row.get(0)?,
993 action: row.get(1)?,
994 entry_provider: row.get(2)?,
995 timestamp: row.get(3)?,
996 details: row.get(4)?,
997 entry_hash: row.get(5)?,
998 prev_hash: row.get(6)?,
999 actor: row.get(7)?,
1000 })
1001 })
1002 .map_err(|e| e.to_string())?
1003 .collect();
1004 rows.into_iter()
1005 .map(|r| r.map_err(|e| e.to_string()))
1006 .collect()
1007}
1008
1009pub fn migrate_legacy_json(conn: &Connection, raw_json: &str) -> Result<(), String> {
1014 let doc: serde_json::Value = serde_json::from_str(raw_json).map_err(|e| e.to_string())?;
1015 save_vault(conn, doc, SaveCtx::default()).map(|_| ())
1016}
1017
1018pub fn new_uuid() -> String {
1029 use rand::RngCore;
1030 let mut b = [0u8; 16];
1031 rand::thread_rng().fill_bytes(&mut b);
1032 b[6] = (b[6] & 0x0f) | 0x40;
1033 b[8] = (b[8] & 0x3f) | 0x80;
1034 format!(
1035 "{}-{}-{}-{}-{}",
1036 hex::encode(&b[0..4]),
1037 hex::encode(&b[4..6]),
1038 hex::encode(&b[6..8]),
1039 hex::encode(&b[8..10]),
1040 hex::encode(&b[10..16]),
1041 )
1042}
1043
1044pub fn iso_now() -> String {
1045 let t = time::OffsetDateTime::now_utc();
1046 format!(
1047 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1048 t.year(),
1049 t.month() as u8,
1050 t.day(),
1051 t.hour(),
1052 t.minute(),
1053 t.second()
1054 )
1055}
1056
1057#[cfg(test)]
1060mod tests {
1061 #[test]
1062 fn a_row_patch_replaces_by_id_appends_deletes_and_refuses_bad_input() {
1063 use serde_json::json;
1064 let doc = json!({
1065 "api_keys": [{"id":"a","provider":"A"},{"id":"b","provider":"B"}],
1066 "user_categories": ["x"],
1067 "projects": [{"id":"p","name":"P"}]
1068 });
1069 let out = apply_row_patch(
1070 doc.clone(),
1071 &json!({
1072 "put": [{"id":"b","provider":"B2"},{"id":"c","provider":"C"}],
1073 "delete": ["a"],
1074 "projects_put": [{"id":"q","name":"Q"}],
1075 "projects_delete": ["p"],
1076 "categories": ["y","z"]
1077 }),
1078 )
1079 .unwrap();
1080 assert_eq!(
1081 out,
1082 json!({
1083 "api_keys": [{"id":"b","provider":"B2"},{"id":"c","provider":"C"}],
1084 "user_categories": ["y","z"],
1085 "projects": [{"id":"q","name":"Q"}]
1086 })
1087 );
1088 assert_eq!(apply_row_patch(doc.clone(), &json!({})).unwrap(), doc);
1090 for bad in [
1091 json!({"put": [{"provider":"no id"}]}),
1092 json!({"delete": [1]}),
1093 json!({"projects_put": [{"name":"no id"}]}),
1094 json!({"categories": [1]}),
1095 ] {
1096 assert!(apply_row_patch(doc.clone(), &bad).is_err(), "{bad}");
1097 }
1098 }
1099
1100 use super::*;
1101 use serde_json::json;
1102
1103 fn scratch(tag: &str) -> std::path::PathBuf {
1105 let nanos = std::time::SystemTime::now()
1106 .duration_since(std::time::UNIX_EPOCH)
1107 .unwrap()
1108 .as_nanos();
1109 let dir = std::env::temp_dir().join(format!("unenverse-test-{tag}-{nanos}"));
1110 fs::create_dir_all(&dir).unwrap();
1111 dir
1112 }
1113
1114 fn open_scratch(tag: &str) -> (Connection, std::path::PathBuf) {
1115 let dir = scratch(tag);
1116 let key = derive_key("correct horse battery staple", b"0123456789abcdef").unwrap();
1117 let conn = open_db(&dir.join("vault.db"), &key).unwrap();
1118 init_schema(&conn).unwrap();
1119 (conn, dir)
1120 }
1121
1122 fn history_of(conn: &Connection) -> Vec<serde_json::Value> {
1125 let raw = load_vault(conn).unwrap().unwrap_or(json!({}));
1126 raw["api_keys"][0]["version_history"]
1127 .as_array()
1128 .cloned()
1129 .unwrap_or_default()
1130 }
1131
1132 #[test]
1140 fn every_secret_carrying_value_is_versioned() {
1141 let (conn, _d) = open_scratch("historyfields");
1142 let base = json!({ "api_keys": [{
1143 "id": "e1", "provider": "Aws",
1144 "api_key": "key-v1", "api_secret": "secret-v1",
1145 "extra_vars": [
1146 { "key": "SESSION_TOKEN", "value": "tok-v1" },
1147 { "key": "REGION", "value": "eu-west-1", "public": true },
1148 ],
1149 }]});
1150 save_vault(&conn, base.clone(), SaveCtx::default()).unwrap();
1151 assert!(history_of(&conn).is_empty(), "nothing changed yet");
1152
1153 let mut next = base.clone();
1154 next["api_keys"][0]["api_key"] = json!("key-v2");
1155 next["api_keys"][0]["api_secret"] = json!("secret-v2");
1156 next["api_keys"][0]["extra_vars"][0]["value"] = json!("tok-v2");
1157 next["api_keys"][0]["extra_vars"][1]["value"] = json!("us-east-1");
1158 save_vault(&conn, next.clone(), SaveCtx::default()).unwrap();
1159
1160 let hist = history_of(&conn);
1161 let found: Vec<(String, String)> = hist
1162 .iter()
1163 .map(|h| {
1164 (
1165 h.get("field")
1166 .and_then(|v| v.as_str())
1167 .unwrap_or("api_key")
1168 .to_string(),
1169 h["value"].as_str().unwrap_or("").to_string(),
1170 )
1171 })
1172 .collect();
1173
1174 assert!(
1175 found.contains(&("api_key".into(), "key-v1".into())),
1176 "api_key still writes no discriminator — every pre-Phase-22 vault relies on that: {found:?}"
1177 );
1178 assert!(
1179 found.contains(&("api_secret".into(), "secret-v1".into())),
1180 "a replaced client secret is as unrecoverable as a replaced key: {found:?}"
1181 );
1182 assert!(
1183 found.contains(&("extra_vars/SESSION_TOKEN".into(), "tok-v1".into())),
1184 "a named variable is where an env_var entry's whole payload lives: {found:?}"
1185 );
1186 assert!(
1187 !found.iter().any(|(f, _)| f == "extra_vars/REGION"),
1188 "a var marked public is a region by declaration; filling a 50-record \
1189 history with them evicts the values that cannot be recovered: {found:?}"
1190 );
1191 }
1192
1193 #[test]
1198 fn deleting_a_variable_still_versions_it() {
1199 let (conn, _d) = open_scratch("historydelete");
1200 let base = json!({ "api_keys": [{
1201 "id": "e1", "provider": "Aws", "api_key": "k",
1202 "extra_vars": [{ "key": "SESSION_TOKEN", "value": "tok-v1" }],
1203 }]});
1204 save_vault(&conn, base.clone(), SaveCtx::default()).unwrap();
1205
1206 let mut next = base.clone();
1207 next["api_keys"][0]["extra_vars"] = json!([]);
1208 save_vault(&conn, next, SaveCtx::default()).unwrap();
1209
1210 let hist = history_of(&conn);
1211 assert_eq!(hist.len(), 1, "{hist:?}");
1212 assert_eq!(hist[0]["field"], json!("extra_vars/SESSION_TOKEN"));
1213 assert_eq!(hist[0]["value"], json!("tok-v1"));
1214 }
1215
1216 #[test]
1223 fn variables_are_matched_by_name_not_position() {
1224 let (conn, _d) = open_scratch("historyreorder");
1225 let base = json!({ "api_keys": [{
1226 "id": "e1", "provider": "Aws", "api_key": "k",
1227 "extra_vars": [
1228 { "key": "A", "value": "a1" },
1229 { "key": "B", "value": "b1" },
1230 ],
1231 }]});
1232 save_vault(&conn, base, SaveCtx::default()).unwrap();
1233
1234 let next = json!({ "api_keys": [{
1236 "id": "e1", "provider": "Aws", "api_key": "k",
1237 "extra_vars": [
1238 { "key": "B", "value": "b2" },
1239 { "key": "A", "value": "a1" },
1240 ],
1241 }]});
1242 save_vault(&conn, next, SaveCtx::default()).unwrap();
1243
1244 let hist = history_of(&conn);
1245 assert_eq!(hist.len(), 1, "only B changed: {hist:?}");
1246 assert_eq!(hist[0]["field"], json!("extra_vars/B"));
1247 assert_eq!(hist[0]["value"], json!("b1"));
1248 }
1249
1250 #[test]
1253 fn saving_stamps_the_schema_version() {
1254 let (conn, _d) = open_scratch("schemastamp");
1255 assert_eq!(
1256 vault_schema_version(&conn).unwrap(),
1257 None,
1258 "a fresh database carries no stamp until something is written"
1259 );
1260 save_vault(
1261 &conn,
1262 serde_json::json!({ "api_keys": [] }),
1263 SaveCtx::default(),
1264 )
1265 .unwrap();
1266 assert_eq!(
1267 vault_schema_version(&conn).unwrap(),
1268 Some(VAULT_SCHEMA_VERSION)
1269 );
1270 }
1271
1272 #[test]
1273 fn an_unstamped_vault_still_opens() {
1274 let (conn, _d) = open_scratch("schemalegacy");
1277 save_vault(
1278 &conn,
1279 serde_json::json!({ "api_keys": [] }),
1280 SaveCtx::default(),
1281 )
1282 .unwrap();
1283 conn.execute("DELETE FROM vault_meta WHERE key = 'schema_version'", [])
1284 .unwrap();
1285 assert!(load_vault(&conn).unwrap().is_some());
1286 }
1287
1288 #[test]
1289 fn a_future_schema_is_refused_for_both_read_and_write() {
1290 let (conn, _d) = open_scratch("schemafuture");
1295 save_vault(
1296 &conn,
1297 serde_json::json!({ "api_keys": [], "projects": [] }),
1298 SaveCtx::default(),
1299 )
1300 .unwrap();
1301 conn.execute(
1302 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', ?1)",
1303 rusqlite::params![(VAULT_SCHEMA_VERSION + 1).to_string()],
1304 )
1305 .unwrap();
1306
1307 let read = load_vault(&conn).unwrap_err();
1308 assert!(read.starts_with(SCHEMA_ERR), "load said: {read}");
1309 let write = save_vault(
1310 &conn,
1311 serde_json::json!({ "api_keys": [] }),
1312 SaveCtx::default(),
1313 )
1314 .unwrap_err();
1315 assert!(write.starts_with(SCHEMA_ERR), "save said: {write}");
1316
1317 conn.execute(
1319 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', '1')",
1320 [],
1321 )
1322 .unwrap();
1323 let v = load_vault(&conn).unwrap().unwrap();
1324 assert!(
1325 v.get("projects").is_some(),
1326 "the refused save wrote nothing"
1327 );
1328 }
1329
1330 #[test]
1331 fn an_unparseable_stamp_is_treated_as_unknown_not_as_absent() {
1332 let (conn, _d) = open_scratch("schemajunk");
1333 conn.execute(
1334 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', 'tomorrow')",
1335 [],
1336 )
1337 .unwrap();
1338 let e = load_vault(&conn).unwrap_err();
1339 assert!(e.starts_with(SCHEMA_ERR), "said: {e}");
1340 }
1341
1342 #[test]
1345 fn derive_key_is_deterministic_and_salt_sensitive() {
1346 let a = derive_key("hunter2", b"0123456789abcdef").unwrap();
1347 let b = derive_key("hunter2", b"0123456789abcdef").unwrap();
1348 let c = derive_key("hunter2", b"fedcba9876543210").unwrap();
1349 let d = derive_key("hunter3", b"0123456789abcdef").unwrap();
1350 assert_eq!(a, b, "same password + salt must derive the same key");
1351 assert_ne!(a, c, "different salt must derive a different key");
1352 assert_ne!(a, d, "different password must derive a different key");
1353 }
1354
1355 #[test]
1356 fn salt_is_persisted_and_reused() {
1357 let dir = scratch("salt");
1358 let path = dir.join("vault.salt");
1359 let first = read_or_create_salt(&path).unwrap();
1360 let second = read_or_create_salt(&path).unwrap();
1361 assert_eq!(first, second, "salt must be stable across reads");
1362 assert_eq!(first.len(), SALT_LEN);
1363 }
1364
1365 #[test]
1368 fn entry_ck_prefers_stable_id() {
1369 let a = json!({ "id": "abc", "provider": "GitHub", "account_name": "x" });
1370 let b = json!({ "id": "abc", "provider": "Renamed", "account_name": "y" });
1371 assert_eq!(entry_ck(&a), entry_ck(&b), "id must dominate other fields");
1372 }
1373
1374 #[test]
1375 fn entry_ck_legacy_distinguishes_key_id() {
1376 let a = json!({ "provider": "AWS", "account_name": "prod", "key_id": "one" });
1379 let b = json!({ "provider": "AWS", "account_name": "prod", "key_id": "two" });
1380 assert_ne!(entry_ck(&a), entry_ck(&b));
1381 }
1382
1383 #[test]
1384 fn entry_ck_ignores_empty_id() {
1385 let with_empty = json!({ "id": "", "provider": "P" });
1386 let without = json!({ "provider": "P" });
1387 assert_eq!(entry_ck(&with_empty), entry_ck(&without));
1388 }
1389
1390 #[test]
1393 fn save_then_load_roundtrips() {
1394 let (conn, _dir) = open_scratch("roundtrip");
1395 let data = json!({
1396 "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "ghp_aaa" }],
1397 "user_categories": ["dev"],
1398 "projects": [{ "id": "Universal", "name": "Universal" }],
1399 });
1400 save_vault(&conn, data.clone(), SaveCtx::default()).unwrap();
1401 let loaded = load_vault(&conn).unwrap().expect("vault should exist");
1402 assert_eq!(loaded["api_keys"][0]["provider"], "GitHub");
1403 assert_eq!(loaded["user_categories"][0], "dev");
1404 }
1405
1406 #[test]
1407 fn load_returns_none_for_fresh_vault() {
1408 let (conn, _dir) = open_scratch("fresh");
1409 assert!(load_vault(&conn).unwrap().is_none());
1410 }
1411
1412 #[test]
1413 fn changing_a_key_records_previous_value_in_history() {
1414 let (conn, _dir) = open_scratch("history");
1415 save_vault(
1416 &conn,
1417 json!({
1418 "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "old_value" }]
1419 }),
1420 SaveCtx::default(),
1421 )
1422 .unwrap();
1423 save_vault(
1424 &conn,
1425 json!({
1426 "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "new_value" }]
1427 }),
1428 SaveCtx::default(),
1429 )
1430 .unwrap();
1431
1432 let loaded = load_vault(&conn).unwrap().unwrap();
1433 let history = loaded["api_keys"][0]["version_history"].as_array().unwrap();
1434 assert_eq!(
1435 history.len(),
1436 1,
1437 "one rotation should append one history entry"
1438 );
1439 assert_eq!(history[0]["value"], "old_value");
1440 }
1441
1442 #[test]
1443 fn a_replaced_totp_seed_is_versioned_and_labelled() {
1444 let (conn, _dir) = open_scratch("totp-history");
1448 save_vault(
1449 &conn,
1450 json!({
1451 "api_keys": [{
1452 "id": "1", "provider": "GitHub",
1453 "api_key": "k1", "totp_secret": "JBSWY3DPEHPK3PXP",
1454 }]
1455 }),
1456 SaveCtx::default(),
1457 )
1458 .unwrap();
1459 save_vault(
1460 &conn,
1461 json!({
1462 "api_keys": [{
1463 "id": "1", "provider": "GitHub",
1464 "api_key": "k1", "totp_secret": "MZXW6YTBOI======",
1465 }]
1466 }),
1467 SaveCtx::default(),
1468 )
1469 .unwrap();
1470
1471 let loaded = load_vault(&conn).unwrap().unwrap();
1472 let history = loaded["api_keys"][0]["version_history"].as_array().unwrap();
1473 assert_eq!(history.len(), 1, "the seed change is one revision");
1474 assert_eq!(history[0]["value"], "JBSWY3DPEHPK3PXP");
1475 assert_eq!(history[0]["field"], "totp_secret");
1477 }
1478
1479 #[test]
1480 fn an_api_key_revision_still_carries_no_field_discriminator() {
1481 let (conn, _dir) = open_scratch("legacy-history-shape");
1485 save_vault(
1486 &conn,
1487 json!({ "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "v1" }] }),
1488 SaveCtx::default(),
1489 )
1490 .unwrap();
1491 save_vault(
1492 &conn,
1493 json!({ "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "v2" }] }),
1494 SaveCtx::default(),
1495 )
1496 .unwrap();
1497
1498 let loaded = load_vault(&conn).unwrap().unwrap();
1499 let history = loaded["api_keys"][0]["version_history"].as_array().unwrap();
1500 assert!(history[0].get("field").is_none(), "{:?}", history[0]);
1501 }
1502
1503 #[test]
1504 fn history_follows_the_id_not_the_provider_name() {
1505 let (conn, _dir) = open_scratch("rename");
1508 save_vault(
1509 &conn,
1510 json!({
1511 "api_keys": [{ "id": "1", "provider": "OldName", "api_key": "v1" }]
1512 }),
1513 SaveCtx::default(),
1514 )
1515 .unwrap();
1516 save_vault(
1517 &conn,
1518 json!({
1519 "api_keys": [{ "id": "1", "provider": "NewName", "api_key": "v2" }]
1520 }),
1521 SaveCtx::default(),
1522 )
1523 .unwrap();
1524
1525 let loaded = load_vault(&conn).unwrap().unwrap();
1526 let history = loaded["api_keys"][0]["version_history"].as_array().unwrap();
1527 assert_eq!(history[0]["value"], "v1", "history must survive a rename");
1528 }
1529
1530 #[test]
1531 fn integrity_hash_matches_after_save() {
1532 let (conn, _dir) = open_scratch("integrity");
1533 save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1534 assert!(verify_vault_integrity(&conn).unwrap());
1535 }
1536
1537 #[test]
1538 fn integrity_check_detects_tampering() {
1539 let (conn, _dir) = open_scratch("tamper");
1540 save_vault(
1541 &conn,
1542 json!({
1543 "api_keys": [{ "id": "1", "provider": "P", "api_key": "k" }]
1544 }),
1545 SaveCtx::default(),
1546 )
1547 .unwrap();
1548 conn.execute(
1550 "UPDATE vault_rows SET data = ?1 WHERE kind = 'entry'",
1551 rusqlite::params![r#"{"id":"1","provider":"EVIL","api_key":"k"}"#],
1552 )
1553 .unwrap();
1554 assert!(
1555 !verify_vault_integrity(&conn).unwrap(),
1556 "tampered data must fail the hash check"
1557 );
1558 }
1559
1560 #[test]
1561 fn empty_vault_is_trivially_intact() {
1562 let (conn, _dir) = open_scratch("empty-integrity");
1563 assert!(verify_vault_integrity(&conn).unwrap());
1564 }
1565
1566 #[test]
1569 fn version_changes_with_every_write() {
1570 let (conn, _dir) = open_scratch("version");
1571 assert!(
1572 vault_version(&conn).unwrap().is_none(),
1573 "empty vault has no version"
1574 );
1575 let v1 = save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1576 let v2 = save_vault(
1577 &conn,
1578 json!({
1579 "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1580 }),
1581 SaveCtx::default(),
1582 )
1583 .unwrap();
1584 assert_ne!(v1, v2);
1585 assert_eq!(vault_version(&conn).unwrap().as_deref(), Some(v2.as_str()));
1586 }
1587
1588 #[test]
1589 fn returned_version_is_the_stored_version() {
1590 let (conn, _dir) = open_scratch("version-match");
1593 let v = save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1594 assert_eq!(vault_version(&conn).unwrap().unwrap(), v);
1595 }
1596
1597 #[test]
1598 fn writing_at_the_expected_version_succeeds() {
1599 let (conn, _dir) = open_scratch("cas-ok");
1600 let v1 = save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1601 let res = save_vault(
1602 &conn,
1603 json!({
1604 "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1605 }),
1606 SaveCtx {
1607 actor: None,
1608 expect_version: Some(&v1),
1609 },
1610 );
1611 assert!(res.is_ok());
1612 }
1613
1614 #[test]
1615 fn writing_at_a_stale_version_is_refused() {
1616 let (conn, _dir) = open_scratch("cas-stale");
1619 let v1 = save_vault(
1620 &conn,
1621 json!({
1622 "api_keys": [{ "id": "1", "provider": "original", "api_key": "k" }]
1623 }),
1624 SaveCtx::default(),
1625 )
1626 .unwrap();
1627
1628 save_vault(
1630 &conn,
1631 json!({
1632 "api_keys": [{ "id": "1", "provider": "written-by-A", "api_key": "k" }]
1633 }),
1634 SaveCtx {
1635 actor: None,
1636 expect_version: Some(&v1),
1637 },
1638 )
1639 .unwrap();
1640
1641 let err = save_vault(
1643 &conn,
1644 json!({
1645 "api_keys": [{ "id": "1", "provider": "written-by-B", "api_key": "k" }]
1646 }),
1647 SaveCtx {
1648 actor: None,
1649 expect_version: Some(&v1),
1650 },
1651 )
1652 .expect_err("a stale write must be refused");
1653 assert!(
1654 err.starts_with(CONFLICT_ERR),
1655 "callers match on this prefix, got: {err}"
1656 );
1657
1658 let stored = load_vault(&conn).unwrap().unwrap();
1660 assert_eq!(stored["api_keys"][0]["provider"], "written-by-A");
1661 }
1662
1663 #[test]
1664 fn a_refused_write_leaves_no_trace() {
1665 let (conn, _dir) = open_scratch("cas-clean");
1668 let v1 = save_vault(
1669 &conn,
1670 json!({
1671 "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1672 }),
1673 SaveCtx::default(),
1674 )
1675 .unwrap();
1676 save_vault(
1678 &conn,
1679 json!({
1680 "api_keys": [{ "id": "1", "provider": "A-theirs", "api_key": "k" }]
1681 }),
1682 SaveCtx::default(),
1683 )
1684 .unwrap();
1685
1686 let audit_before = load_audit(&conn).unwrap().len();
1687 let version_before = vault_version(&conn).unwrap();
1688
1689 let err = save_vault(
1690 &conn,
1691 json!({
1692 "api_keys": [
1693 { "id": "1", "provider": "A-mine", "api_key": "k" },
1694 { "id": "9", "provider": "GHOST", "api_key": "k" }
1695 ]
1696 }),
1697 SaveCtx {
1698 actor: None,
1699 expect_version: Some(&v1),
1700 },
1701 )
1702 .expect_err("both sides edited entry 1");
1703 assert!(err.starts_with(CONFLICT_ERR), "{err}");
1704 assert!(
1705 err.contains("A-mine"),
1706 "the conflict names the entry: {err}"
1707 );
1708
1709 assert_eq!(
1710 load_audit(&conn).unwrap().len(),
1711 audit_before,
1712 "a refused write must not append audit rows"
1713 );
1714 assert_eq!(vault_version(&conn).unwrap(), version_before);
1715 assert!(!load_audit(&conn)
1716 .unwrap()
1717 .iter()
1718 .any(|r| r.entry_provider.as_deref() == Some("GHOST")));
1719 let stored = load_vault(&conn).unwrap().unwrap();
1720 assert_eq!(stored["api_keys"].as_array().unwrap().len(), 1);
1721 assert_eq!(stored["api_keys"][0]["provider"], "A-theirs");
1722 }
1723
1724 #[test]
1725 fn expecting_a_version_against_an_empty_vault_is_refused() {
1726 let (conn, _dir) = open_scratch("cas-empty");
1727 let err = save_vault(
1728 &conn,
1729 json!({ "api_keys": [] }),
1730 SaveCtx {
1731 actor: None,
1732 expect_version: Some("deadbeef"),
1733 },
1734 )
1735 .expect_err("nothing is stored, so no version can match");
1736 assert!(err.starts_with(CONFLICT_ERR));
1737 }
1738
1739 #[test]
1740 fn omitting_the_version_writes_unconditionally() {
1741 let (conn, _dir) = open_scratch("cas-force");
1743 save_vault(
1744 &conn,
1745 json!({
1746 "api_keys": [{ "id": "1", "provider": "first", "api_key": "k" }]
1747 }),
1748 SaveCtx::default(),
1749 )
1750 .unwrap();
1751 save_vault(
1752 &conn,
1753 json!({
1754 "api_keys": [{ "id": "1", "provider": "forced", "api_key": "k" }]
1755 }),
1756 SaveCtx::default(),
1757 )
1758 .unwrap();
1759 let stored = load_vault(&conn).unwrap().unwrap();
1760 assert_eq!(stored["api_keys"][0]["provider"], "forced");
1761 }
1762
1763 #[test]
1764 fn integrity_still_holds_after_a_refused_write() {
1765 let (conn, _dir) = open_scratch("cas-integrity");
1766 let v1 = save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1767 save_vault(
1768 &conn,
1769 json!({
1770 "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1771 }),
1772 SaveCtx::default(),
1773 )
1774 .unwrap();
1775 let _ = save_vault(
1776 &conn,
1777 json!({ "api_keys": [] }),
1778 SaveCtx {
1779 actor: None,
1780 expect_version: Some(&v1),
1781 },
1782 );
1783 assert!(
1784 verify_vault_integrity(&conn).unwrap(),
1785 "a rolled-back write must not desync data from its hash"
1786 );
1787 }
1788
1789 #[test]
1792 fn audit_rows_form_a_hash_chain() {
1793 let (conn, _dir) = open_scratch("audit");
1794 save_vault(
1795 &conn,
1796 json!({
1797 "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1798 }),
1799 SaveCtx::default(),
1800 )
1801 .unwrap();
1802 save_vault(
1803 &conn,
1804 json!({
1805 "api_keys": [
1806 { "id": "1", "provider": "A", "api_key": "k" },
1807 { "id": "2", "provider": "B", "api_key": "k2" }
1808 ]
1809 }),
1810 SaveCtx::default(),
1811 )
1812 .unwrap();
1813
1814 let mut rows = load_audit(&conn).unwrap();
1815 assert!(rows.len() >= 2, "expected an audit row per added entry");
1816 rows.sort_by_key(|r| r.id); assert!(rows[0].entry_hash.is_some());
1818 for pair in rows.windows(2) {
1820 assert_eq!(
1821 pair[1].prev_hash, pair[0].entry_hash,
1822 "row {} must chain to row {}",
1823 pair[1].id, pair[0].id
1824 );
1825 }
1826 }
1827
1828 #[test]
1829 fn deleting_an_entry_is_audited() {
1830 let (conn, _dir) = open_scratch("audit-delete");
1831 save_vault(
1832 &conn,
1833 json!({
1834 "api_keys": [{ "id": "1", "provider": "Doomed", "api_key": "k" }]
1835 }),
1836 SaveCtx::default(),
1837 )
1838 .unwrap();
1839 save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1840 let rows = load_audit(&conn).unwrap();
1841 assert!(rows
1842 .iter()
1843 .any(|r| r.action == "delete" && r.entry_provider.as_deref() == Some("Doomed")));
1844 }
1845
1846 #[test]
1847 fn audit_rows_record_the_acting_user() {
1848 let (conn, _dir) = open_scratch("audit-actor");
1849 save_vault(
1850 &conn,
1851 json!({
1852 "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1853 }),
1854 SaveCtx {
1855 actor: Some("user-123"),
1856 ..Default::default()
1857 },
1858 )
1859 .unwrap();
1860 let rows = load_audit(&conn).unwrap();
1861 let add = rows.iter().find(|r| r.action == "add").unwrap();
1862 assert_eq!(add.actor.as_deref(), Some("user-123"));
1863 }
1864
1865 #[test]
1866 fn actor_is_bound_into_the_hash_chain() {
1867 let with = compute_audit_hash("add", "P", "T", Some("alice"), "prev");
1870 let other = compute_audit_hash("add", "P", "T", Some("bob"), "prev");
1871 let without = compute_audit_hash("add", "P", "T", None, "prev");
1872 assert_ne!(with, other, "different actor must give a different hash");
1873 assert_ne!(with, without);
1874 }
1875
1876 #[test]
1877 fn actorless_rows_keep_the_v1_hash_format() {
1878 use sha2::{Digest, Sha256};
1881 let mut h = Sha256::new();
1882 for part in ["add", "|", "P", "|", "T", "|", "prev"] {
1883 h.update(part.as_bytes());
1884 }
1885 assert_eq!(
1886 compute_audit_hash("add", "P", "T", None, "prev"),
1887 hex::encode(h.finalize())
1888 );
1889 }
1890
1891 #[test]
1894 fn expiring_selects_only_the_window() {
1895 let now = time::OffsetDateTime::now_utc();
1896 let fmt = |d: i64| {
1897 let t = now + time::Duration::days(d);
1898 format!("{:04}-{:02}-{:02}", t.year(), t.month() as u8, t.day())
1899 };
1900 let data = json!({ "api_keys": [
1901 { "provider": "expired", "expires_at": fmt(-5) },
1902 { "provider": "soon", "expires_at": fmt(3) },
1903 { "provider": "far", "expires_at": fmt(365) },
1904 { "provider": "no-expiry" },
1905 ]});
1906 let found = expiring_from_value(&data, 30);
1907 let names: Vec<&str> = found
1908 .iter()
1909 .map(|e| e["provider"].as_str().unwrap())
1910 .collect();
1911 assert_eq!(
1912 names,
1913 vec!["soon"],
1914 "already-expired, far-future and never-expiring entries are all excluded"
1915 );
1916 }
1917}
1918
1919#[cfg(test)]
1920mod salt_pairing_tests {
1921 use super::*;
1922
1923 fn tmp(name: &str) -> std::path::PathBuf {
1924 let d = std::env::temp_dir().join(format!("unv-salt-{name}-{}", std::process::id()));
1925 let _ = fs::remove_dir_all(&d);
1926 fs::create_dir_all(&d).unwrap();
1927 d
1928 }
1929
1930 #[test]
1934 fn a_database_without_its_salt_is_refused_not_re_salted() {
1935 let d = tmp("orphan");
1936 let db = d.join("vault.db");
1937 let salt = d.join("vault.salt");
1938 fs::write(&db, b"pretend this is a SQLCipher file").unwrap();
1939
1940 let err = check_salt_pairing(&db, &salt).unwrap_err();
1941 assert!(err.contains("is missing"), "{err}");
1942 assert!(
1943 err.contains("nothing can recompute it"),
1944 "the message must not imply recovery is possible: {err}"
1945 );
1946 assert!(!salt.exists(), "the check must not create a salt");
1947 let _ = fs::remove_dir_all(&d);
1948 }
1949
1950 #[test]
1952 fn a_fresh_directory_is_fine() {
1953 let d = tmp("fresh");
1954 assert!(check_salt_pairing(&d.join("vault.db"), &d.join("vault.salt")).is_ok());
1955 let _ = fs::remove_dir_all(&d);
1956 }
1957
1958 #[test]
1960 fn an_empty_database_file_is_not_treated_as_a_vault() {
1961 let d = tmp("empty");
1962 let db = d.join("vault.db");
1963 fs::write(&db, b"").unwrap();
1964 assert!(check_salt_pairing(&db, &d.join("vault.salt")).is_ok());
1965 let _ = fs::remove_dir_all(&d);
1966 }
1967
1968 #[test]
1970 #[cfg(unix)]
1971 fn a_generated_salt_is_owner_only() {
1972 use std::os::unix::fs::PermissionsExt;
1973 let d = tmp("mode");
1974 let salt = d.join("vault.salt");
1975 read_or_create_salt(&salt).unwrap();
1976 let mode = fs::metadata(&salt).unwrap().permissions().mode() & 0o777;
1977 assert_eq!(mode, 0o600, "salt was created world-readable");
1978 let _ = fs::remove_dir_all(&d);
1979 }
1980
1981 #[test]
1983 #[cfg(unix)]
1984 fn a_created_database_is_owner_only() {
1985 use std::os::unix::fs::PermissionsExt;
1986 let d = tmp("dbmode");
1987 let db = d.join("vault.db");
1988 let key = derive_key(
1989 "correct-horse-battery",
1990 &read_or_create_salt(&d.join("vault.salt")).unwrap(),
1991 )
1992 .unwrap();
1993 let conn = open_db(&db, &key).unwrap();
1994 drop(conn);
1995 let mode = fs::metadata(&db).unwrap().permissions().mode() & 0o777;
1996 assert_eq!(mode, 0o600, "database was created world-readable");
1997 let _ = fs::remove_dir_all(&d);
1998 }
1999}
2000
2001fn env_bare_ok(v: &str) -> bool {
2003 !v.is_empty()
2004 && v.chars()
2005 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | ':' | '@' | '-'))
2006}
2007
2008pub fn env_quote(value: &str) -> String {
2016 if env_bare_ok(value) {
2017 return value.to_string();
2018 }
2019 let mut out = String::with_capacity(value.len() + 2);
2020 out.push('"');
2021 for ch in value.chars() {
2022 match ch {
2023 '\\' | '"' | '$' | '`' => {
2024 out.push('\\');
2025 out.push(ch);
2026 }
2027 '\n' => out.push_str("\\n"),
2028 '\r' => out.push_str("\\r"),
2029 '\t' => out.push_str("\\t"),
2030 _ => out.push(ch),
2031 }
2032 }
2033 out.push('"');
2034 out
2035}