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 restrict_to_owner(db_path)?;
235 for suffix in ["-wal", "-shm"] {
236 let mut side = db_path.as_os_str().to_owned();
237 side.push(suffix);
238 let side = std::path::PathBuf::from(side);
239 if side.exists() {
240 restrict_to_owner(&side)?;
241 }
242 }
243 Ok(conn)
244}
245
246pub fn init_schema(conn: &Connection) -> Result<(), String> {
250 conn.execute_batch(
251 "CREATE TABLE IF NOT EXISTS vault (
252 id INTEGER PRIMARY KEY CHECK (id = 1),
253 data TEXT NOT NULL
254 );
255 CREATE TABLE IF NOT EXISTS vault_audit (
256 id INTEGER PRIMARY KEY AUTOINCREMENT,
257 action TEXT NOT NULL,
258 entry_provider TEXT,
259 timestamp TEXT NOT NULL,
260 details TEXT
261 );
262 CREATE TABLE IF NOT EXISTS vault_meta (
263 key TEXT PRIMARY KEY,
264 value TEXT NOT NULL
265 );",
266 )
267 .map_err(|e| e.to_string())?;
268 let _ = conn.execute_batch("ALTER TABLE vault_audit ADD COLUMN entry_hash TEXT;");
270 let _ = conn.execute_batch("ALTER TABLE vault_audit ADD COLUMN prev_hash TEXT;");
271 let _ = conn.execute_batch("ALTER TABLE vault_audit ADD COLUMN actor TEXT;");
274 let _ = conn.execute_batch(
277 "CREATE INDEX IF NOT EXISTS vault_audit_provider ON vault_audit (entry_provider);
278 CREATE INDEX IF NOT EXISTS vault_audit_time ON vault_audit (timestamp);",
279 );
280 storage::init_schema(conn)?;
282 users::init_users_schema(conn)?;
284 ics_feeds::init_schema(conn)?;
286 config_history::init_schema(conn)?;
287 Ok(())
288}
289
290pub fn entry_ck(entry: &serde_json::Value) -> String {
304 if let Some(id) = entry.get("id").and_then(|v| v.as_str()) {
305 if !id.is_empty() {
306 return format!("id\u{1}{id}");
307 }
308 }
309 let field = |k: &str| entry.get(k).and_then(|v| v.as_str()).unwrap_or("");
310 format!(
311 "legacy\u{1}{}\u{1}{}\u{1}{}",
312 field("provider"),
313 field("account_name"),
314 field("key_id"),
315 )
316}
317
318pub const VAULT_SCHEMA_VERSION: u32 = 3;
345
346pub const SCHEMA_ERR: &str = "VAULT_SCHEMA_TOO_NEW";
350
351pub fn vault_schema_version(conn: &Connection) -> Result<Option<u32>, String> {
354 let raw: Option<String> = conn
355 .query_row(
356 "SELECT value FROM vault_meta WHERE key = 'schema_version'",
357 [],
358 |r| r.get(0),
359 )
360 .optional()
361 .map_err(|e| e.to_string())?;
362 match raw {
363 None => Ok(None),
364 Some(s) => s
368 .trim()
369 .parse::<u32>()
370 .map(Some)
371 .map_err(|_| format!("{SCHEMA_ERR}: unreadable schema_version {s:?}")),
372 }
373}
374
375pub fn check_schema_version(conn: &Connection) -> Result<(), String> {
381 match vault_schema_version(conn)? {
382 Some(v) if v > VAULT_SCHEMA_VERSION => Err(format!(
383 "{SCHEMA_ERR}: this vault was written by a newer version of UnENVerse \
384 (vault schema v{v}, this build understands v{VAULT_SCHEMA_VERSION}). \
385 Upgrade UnENVerse to open it — writing it with this build would drop \
386 the fields it does not understand."
387 )),
388 _ => Ok(()),
389 }
390}
391
392pub fn load_vault(conn: &Connection) -> Result<Option<serde_json::Value>, String> {
399 check_schema_version(conn)?;
400 storage::migrate_if_needed(conn, &iso_now())?;
402 storage::load(conn)
403}
404
405pub const MERGED_SUFFIX: &str = "+merged";
409
410pub fn ensure_current_schema(conn: &Connection) -> Result<(), String> {
415 check_schema_version(conn)?;
416 storage::migrate_if_needed(conn, &iso_now())
417}
418
419pub fn load_vault_lite(conn: &Connection) -> Result<Option<serde_json::Value>, String> {
423 check_schema_version(conn)?;
424 storage::migrate_if_needed(conn, &iso_now())?;
425 storage::load_lite(conn)
426}
427
428pub const CONFLICT_ERR: &str = "VAULT_CONFLICT";
431
432#[derive(Debug, Default, Clone, Copy)]
438pub struct SaveCtx<'a> {
439 pub actor: Option<&'a str>,
442 pub expect_version: Option<&'a str>,
446}
447
448pub fn vault_version(conn: &Connection) -> Result<Option<String>, String> {
454 storage::version(conn)
455}
456
457const HISTORIED_SECRET_FIELDS: [(&str, &str); 3] = [
463 ("api_key", "api_key"),
464 ("api_secret", "api_secret"),
465 ("totp_secret", "totp_secret"),
466];
467
468fn historied_extra_vars(entry: &serde_json::Value) -> Vec<(String, String)> {
480 entry
481 .get("extra_vars")
482 .and_then(|v| v.as_array())
483 .map(|arr| {
484 arr.iter()
485 .filter(|xv| !xv.get("public").and_then(|p| p.as_bool()).unwrap_or(false))
486 .filter_map(|xv| {
487 let k = xv.get("key").and_then(|v| v.as_str())?;
488 let v = xv.get("value").and_then(|v| v.as_str())?;
489 if k.is_empty() {
490 return None;
491 }
492 Some((k.to_string(), v.to_string()))
493 })
494 .collect()
495 })
496 .unwrap_or_default()
497}
498
499pub fn apply_row_patch(
507 mut doc: serde_json::Value,
508 patch: &serde_json::Value,
509) -> Result<serde_json::Value, String> {
510 use serde_json::Value;
511 fn ids(v: Option<&Value>, what: &str) -> Result<Vec<String>, String> {
512 let mut out = Vec::new();
513 for d in v.and_then(Value::as_array).into_iter().flatten() {
514 match d.as_str() {
515 Some(id) if !id.is_empty() => out.push(id.to_string()),
516 _ => return Err(format!("{what} takes a list of ids")),
517 }
518 }
519 Ok(out)
520 }
521 fn puts(v: Option<&Value>, what: &str) -> Result<Vec<(String, Value)>, String> {
522 let mut out = Vec::new();
523 for e in v.and_then(Value::as_array).into_iter().flatten() {
524 match e.get("id").and_then(Value::as_str) {
525 Some(id) if !id.is_empty() => out.push((id.to_string(), e.clone())),
526 _ => return Err(format!("Every {what} needs a string id")),
527 }
528 }
529 Ok(out)
530 }
531 fn apply(
532 doc: &mut Value,
533 key: &str,
534 put: Vec<(String, Value)>,
535 del: Vec<String>,
536 ) -> Result<(), String> {
537 let list = doc
538 .get_mut(key)
539 .and_then(Value::as_array_mut)
540 .ok_or_else(|| format!("stored vault has no {key}"))?;
541 let id_of = |e: &Value| e.get("id").and_then(Value::as_str).map(str::to_string);
542 list.retain(|e| id_of(e).is_none_or(|id| !del.contains(&id)));
543 for (id, entry) in put {
544 match list
545 .iter()
546 .position(|e| id_of(e).as_deref() == Some(id.as_str()))
547 {
548 Some(i) => list[i] = entry,
549 None => list.push(entry),
550 }
551 }
552 Ok(())
553 }
554 let (ep, ed) = (
555 puts(patch.get("put"), "put entry")?,
556 ids(patch.get("delete"), "delete")?,
557 );
558 let (pp, pd) = (
559 puts(patch.get("projects_put"), "put project")?,
560 ids(patch.get("projects_delete"), "projects_delete")?,
561 );
562 let cats = match patch.get("categories") {
563 None | Some(Value::Null) => None,
564 Some(Value::Array(a)) if a.iter().all(Value::is_string) => Some(Value::Array(a.clone())),
565 Some(_) => return Err("categories takes a list of strings".into()),
566 };
567 apply(&mut doc, "api_keys", ep, ed)?;
568 if !pp.is_empty() || !pd.is_empty() {
569 if doc.get("projects").is_none() {
570 doc["projects"] = Value::Array(Vec::new());
571 }
572 apply(&mut doc, "projects", pp, pd)?;
573 }
574 if let Some(c) = cats {
575 doc["user_categories"] = c;
576 }
577 Ok(doc)
578}
579
580pub fn save_vault(
600 conn: &Connection,
601 data: serde_json::Value,
602 ctx: SaveCtx<'_>,
603) -> Result<String, String> {
604 conn.execute_batch("BEGIN IMMEDIATE")
605 .map_err(|e| e.to_string())?;
606 match save_vault_txn(conn, data, ctx) {
607 Ok(hash) => {
608 conn.execute_batch("COMMIT").map_err(|e| e.to_string())?;
609 Ok(hash)
610 }
611 Err(e) => {
612 let _ = conn.execute_batch("ROLLBACK");
613 Err(e)
614 }
615 }
616}
617
618fn save_vault_txn(
624 conn: &Connection,
625 data: serde_json::Value,
626 ctx: SaveCtx<'_>,
627) -> Result<String, String> {
628 let actor = ctx.actor;
629 let now_str = iso_now();
630
631 check_schema_version(conn)?;
634 storage::migrate_in_txn(conn, &now_str)?;
636
637 let snap = storage::snapshot(conn)?;
641 let (mut ents, merged) = storage::merge(conn, &snap, storage::split(data), ctx.expect_version)?;
642
643 let stored: std::collections::HashMap<&str, &str> = snap
644 .iter()
645 .filter(|((kind, _), _)| kind == "entry")
646 .map(|((_, key), (rev, _))| (key.as_str(), rev.as_str()))
647 .collect();
648 let kept: std::collections::HashSet<&str> = ents
649 .iter()
650 .filter(|e| e.kind == "entry")
651 .map(|e| e.key.as_str())
652 .collect();
653 for key in stored.keys() {
654 if !kept.contains(*key) {
655 if let Some(old) = storage::load_ent(conn, "entry", key)? {
656 append_audit(conn, "delete", &old.provider(), &now_str, None, actor)?;
657 }
658 }
659 }
660
661 for ent in ents.iter_mut().filter(|e| e.kind == "entry") {
662 match stored.get(ent.key.as_str()) {
663 Some(rev) if *rev == ent.rev => continue,
665 Some(_) => {
666 let Some(old) = storage::load_ent(conn, "entry", &ent.key)? else {
667 continue;
668 };
669 let old_e = old.full();
670 let mut entry = ent.full();
671 let provider = ent.provider();
672 let entry = &mut entry;
673 for (field, label) in HISTORIED_SECRET_FIELDS {
682 let new_val = entry.get(field).and_then(|v| v.as_str()).unwrap_or("");
683 let old_val = old_e.get(field).and_then(|v| v.as_str()).unwrap_or("");
684 if new_val == old_val || old_val.is_empty() {
685 continue;
686 }
687 let mut history: Vec<serde_json::Value> = entry
688 .get("version_history")
689 .and_then(|v| v.as_array())
690 .cloned()
691 .unwrap_or_else(|| {
692 old_e
693 .get("version_history")
694 .and_then(|v| v.as_array())
695 .cloned()
696 .unwrap_or_default()
697 });
698 let mut record = serde_json::json!({ "value": old_val, "saved_at": now_str });
699 if field != "api_key" {
700 record["field"] = serde_json::json!(field);
701 }
702 history.insert(0, record);
703 history.truncate(50);
707 if let Some(obj) = entry.as_object_mut() {
708 obj.insert(
709 "version_history".to_string(),
710 serde_json::Value::Array(history),
711 );
712 }
713 append_audit(
714 conn,
715 "update",
716 &provider,
717 &now_str,
718 Some(&format!("{label} rotated")),
719 actor,
720 )?;
721 }
722
723 let old_vars = historied_extra_vars(&old_e);
734 if !old_vars.is_empty() {
735 let new_vars: std::collections::HashMap<String, String> =
736 historied_extra_vars(entry).into_iter().collect();
737 for (key, old_val) in old_vars {
738 if old_val.is_empty() {
739 continue;
740 }
741 if new_vars.get(&key).map(String::as_str) == Some(old_val.as_str()) {
742 continue;
743 }
744 let mut history: Vec<serde_json::Value> = entry
745 .get("version_history")
746 .and_then(|v| v.as_array())
747 .cloned()
748 .unwrap_or_default();
749 history.insert(
750 0,
751 serde_json::json!({
752 "value": old_val,
753 "saved_at": now_str,
754 "field": format!("extra_vars/{key}"),
757 }),
758 );
759 history.truncate(50);
760 if let Some(obj) = entry.as_object_mut() {
761 obj.insert(
762 "version_history".to_string(),
763 serde_json::Value::Array(history),
764 );
765 }
766 append_audit(
767 conn,
768 "update",
769 &provider,
770 &now_str,
771 Some(&format!("{key} rotated")),
772 actor,
773 )?;
774 }
775 }
776 if let Some(h) = entry
778 .as_object_mut()
779 .and_then(|o| o.remove("version_history"))
780 {
781 ent.history = Some(h);
782 }
783 }
784 None => {
785 append_audit(conn, "add", &ent.provider(), &now_str, None, actor)?;
786 }
787 }
788 }
789
790 let token = storage::write(conn, &snap, &ents, &now_str)?;
792 conn.execute(
796 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', ?1)",
797 rusqlite::params![VAULT_SCHEMA_VERSION.to_string()],
798 )
799 .map_err(|e| e.to_string())?;
800
801 if merged {
808 Ok(format!("{token}{MERGED_SUFFIX}"))
809 } else {
810 Ok(token)
811 }
812}
813
814pub fn verify_vault_integrity(conn: &Connection) -> Result<bool, String> {
817 storage::verify(conn)
818}
819
820pub fn get_expiring_entries(
825 conn: &Connection,
826 within_days: u32,
827) -> Result<Vec<serde_json::Value>, String> {
828 let data = load_vault(conn)?.unwrap_or_else(|| serde_json::json!({ "api_keys": [] }));
829 Ok(expiring_from_value(&data, within_days))
830}
831
832pub fn get_expiring_entries_for_user(
836 conn: &Connection,
837 within_days: u32,
838 read: Option<&permex::Expr>,
839) -> Result<Vec<serde_json::Value>, String> {
840 let data = load_vault(conn)?.unwrap_or_else(|| serde_json::json!({ "api_keys": [] }));
841 let filtered = filter_vault_for_user(data, read);
842 Ok(expiring_from_value(&filtered, within_days))
843}
844
845fn expiring_from_value(data: &serde_json::Value, within_days: u32) -> Vec<serde_json::Value> {
847 let now = time::OffsetDateTime::now_utc();
848 let cutoff = now + time::Duration::days(within_days as i64);
849 let today_str = fmt_date(&now);
850 let cutoff_str = fmt_date(&cutoff);
851
852 data.get("api_keys")
853 .and_then(|k| k.as_array())
854 .cloned()
855 .unwrap_or_default()
856 .into_iter()
857 .filter(|entry| {
858 entry
859 .get("expires_at")
860 .and_then(|v| v.as_str())
861 .is_some_and(|s| {
862 let d = &s[..s.len().min(10)];
863 d >= today_str.as_str() && d <= cutoff_str.as_str()
864 })
865 })
866 .collect()
867}
868
869fn fmt_date(dt: &time::OffsetDateTime) -> String {
870 format!("{:04}-{:02}-{:02}", dt.year(), dt.month() as u8, dt.day())
871}
872
873#[derive(Debug, serde::Serialize, serde::Deserialize)]
877pub struct AuditRow {
878 pub id: i64,
879 pub action: String,
880 pub entry_provider: Option<String>,
881 pub timestamp: String,
882 pub details: Option<String>,
883 pub entry_hash: Option<String>,
884 pub prev_hash: Option<String>,
885 pub actor: Option<String>,
888}
889
890fn append_audit(
892 conn: &Connection,
893 action: &str,
894 provider: &str,
895 timestamp: &str,
896 details: Option<&str>,
897 actor: Option<&str>,
898) -> Result<(), String> {
899 let prev_hash: Option<String> = conn
900 .query_row(
901 "SELECT entry_hash FROM vault_audit ORDER BY id DESC LIMIT 1",
902 [],
903 |row| row.get(0),
904 )
905 .optional()
906 .map_err(|e| e.to_string())?
907 .flatten();
908
909 let entry_hash = compute_audit_hash(
910 action,
911 provider,
912 timestamp,
913 actor,
914 prev_hash.as_deref().unwrap_or("genesis"),
915 );
916
917 conn.execute(
918 "INSERT INTO vault_audit \
919 (action, entry_provider, timestamp, details, entry_hash, prev_hash, actor) \
920 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
921 rusqlite::params![action, provider, timestamp, details, entry_hash, prev_hash, actor],
922 )
923 .map_err(|e| e.to_string())?;
924 Ok(())
925}
926
927pub fn record_event(
929 conn: &Connection,
930 action: &str,
931 provider: &str,
932 details: Option<&str>,
933 actor: Option<&str>,
934) -> Result<(), String> {
935 append_audit(conn, action, provider, &iso_now(), details, actor)
936}
937
938fn compute_audit_hash(
948 action: &str,
949 provider: &str,
950 timestamp: &str,
951 actor: Option<&str>,
952 prev_hash: &str,
953) -> String {
954 use sha2::{Digest, Sha256};
955 let mut h = Sha256::new();
956 match actor {
957 Some(a) => {
958 for part in [
959 action, "|", provider, "|", timestamp, "|", a, "|", prev_hash,
960 ] {
961 h.update(part.as_bytes());
962 }
963 }
964 None => {
965 for part in [action, "|", provider, "|", timestamp, "|", prev_hash] {
966 h.update(part.as_bytes());
967 }
968 }
969 }
970 hex::encode(h.finalize())
971}
972
973pub fn load_audit(conn: &Connection) -> Result<Vec<AuditRow>, String> {
975 let mut stmt = conn
976 .prepare(
977 "SELECT id, action, entry_provider, timestamp, details, entry_hash, prev_hash, actor \
978 FROM vault_audit ORDER BY id DESC",
979 )
980 .map_err(|e| e.to_string())?;
981
982 let rows: Vec<Result<AuditRow, _>> = stmt
983 .query_map([], |row| {
984 Ok(AuditRow {
985 id: row.get(0)?,
986 action: row.get(1)?,
987 entry_provider: row.get(2)?,
988 timestamp: row.get(3)?,
989 details: row.get(4)?,
990 entry_hash: row.get(5)?,
991 prev_hash: row.get(6)?,
992 actor: row.get(7)?,
993 })
994 })
995 .map_err(|e| e.to_string())?
996 .collect();
997 rows.into_iter()
998 .map(|r| r.map_err(|e| e.to_string()))
999 .collect()
1000}
1001
1002pub fn migrate_legacy_json(conn: &Connection, raw_json: &str) -> Result<(), String> {
1007 let doc: serde_json::Value = serde_json::from_str(raw_json).map_err(|e| e.to_string())?;
1008 save_vault(conn, doc, SaveCtx::default()).map(|_| ())
1009}
1010
1011pub fn new_uuid() -> String {
1022 use rand::RngCore;
1023 let mut b = [0u8; 16];
1024 rand::thread_rng().fill_bytes(&mut b);
1025 b[6] = (b[6] & 0x0f) | 0x40;
1026 b[8] = (b[8] & 0x3f) | 0x80;
1027 format!(
1028 "{}-{}-{}-{}-{}",
1029 hex::encode(&b[0..4]),
1030 hex::encode(&b[4..6]),
1031 hex::encode(&b[6..8]),
1032 hex::encode(&b[8..10]),
1033 hex::encode(&b[10..16]),
1034 )
1035}
1036
1037pub fn iso_now() -> String {
1038 let t = time::OffsetDateTime::now_utc();
1039 format!(
1040 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1041 t.year(),
1042 t.month() as u8,
1043 t.day(),
1044 t.hour(),
1045 t.minute(),
1046 t.second()
1047 )
1048}
1049
1050#[cfg(test)]
1053mod tests {
1054 #[test]
1055 fn a_row_patch_replaces_by_id_appends_deletes_and_refuses_bad_input() {
1056 use serde_json::json;
1057 let doc = json!({
1058 "api_keys": [{"id":"a","provider":"A"},{"id":"b","provider":"B"}],
1059 "user_categories": ["x"],
1060 "projects": [{"id":"p","name":"P"}]
1061 });
1062 let out = apply_row_patch(
1063 doc.clone(),
1064 &json!({
1065 "put": [{"id":"b","provider":"B2"},{"id":"c","provider":"C"}],
1066 "delete": ["a"],
1067 "projects_put": [{"id":"q","name":"Q"}],
1068 "projects_delete": ["p"],
1069 "categories": ["y","z"]
1070 }),
1071 )
1072 .unwrap();
1073 assert_eq!(
1074 out,
1075 json!({
1076 "api_keys": [{"id":"b","provider":"B2"},{"id":"c","provider":"C"}],
1077 "user_categories": ["y","z"],
1078 "projects": [{"id":"q","name":"Q"}]
1079 })
1080 );
1081 assert_eq!(apply_row_patch(doc.clone(), &json!({})).unwrap(), doc);
1083 for bad in [
1084 json!({"put": [{"provider":"no id"}]}),
1085 json!({"delete": [1]}),
1086 json!({"projects_put": [{"name":"no id"}]}),
1087 json!({"categories": [1]}),
1088 ] {
1089 assert!(apply_row_patch(doc.clone(), &bad).is_err(), "{bad}");
1090 }
1091 }
1092
1093 use super::*;
1094 use serde_json::json;
1095
1096 fn scratch(tag: &str) -> std::path::PathBuf {
1098 let nanos = std::time::SystemTime::now()
1099 .duration_since(std::time::UNIX_EPOCH)
1100 .unwrap()
1101 .as_nanos();
1102 let dir = std::env::temp_dir().join(format!("unenverse-test-{tag}-{nanos}"));
1103 fs::create_dir_all(&dir).unwrap();
1104 dir
1105 }
1106
1107 fn open_scratch(tag: &str) -> (Connection, std::path::PathBuf) {
1108 let dir = scratch(tag);
1109 let key = derive_key("correct horse battery staple", b"0123456789abcdef").unwrap();
1110 let conn = open_db(&dir.join("vault.db"), &key).unwrap();
1111 init_schema(&conn).unwrap();
1112 (conn, dir)
1113 }
1114
1115 fn history_of(conn: &Connection) -> Vec<serde_json::Value> {
1118 let raw = load_vault(conn).unwrap().unwrap_or(json!({}));
1119 raw["api_keys"][0]["version_history"]
1120 .as_array()
1121 .cloned()
1122 .unwrap_or_default()
1123 }
1124
1125 #[test]
1133 fn every_secret_carrying_value_is_versioned() {
1134 let (conn, _d) = open_scratch("historyfields");
1135 let base = json!({ "api_keys": [{
1136 "id": "e1", "provider": "Aws",
1137 "api_key": "key-v1", "api_secret": "secret-v1",
1138 "extra_vars": [
1139 { "key": "SESSION_TOKEN", "value": "tok-v1" },
1140 { "key": "REGION", "value": "eu-west-1", "public": true },
1141 ],
1142 }]});
1143 save_vault(&conn, base.clone(), SaveCtx::default()).unwrap();
1144 assert!(history_of(&conn).is_empty(), "nothing changed yet");
1145
1146 let mut next = base.clone();
1147 next["api_keys"][0]["api_key"] = json!("key-v2");
1148 next["api_keys"][0]["api_secret"] = json!("secret-v2");
1149 next["api_keys"][0]["extra_vars"][0]["value"] = json!("tok-v2");
1150 next["api_keys"][0]["extra_vars"][1]["value"] = json!("us-east-1");
1151 save_vault(&conn, next.clone(), SaveCtx::default()).unwrap();
1152
1153 let hist = history_of(&conn);
1154 let found: Vec<(String, String)> = hist
1155 .iter()
1156 .map(|h| {
1157 (
1158 h.get("field")
1159 .and_then(|v| v.as_str())
1160 .unwrap_or("api_key")
1161 .to_string(),
1162 h["value"].as_str().unwrap_or("").to_string(),
1163 )
1164 })
1165 .collect();
1166
1167 assert!(
1168 found.contains(&("api_key".into(), "key-v1".into())),
1169 "api_key still writes no discriminator — every pre-Phase-22 vault relies on that: {found:?}"
1170 );
1171 assert!(
1172 found.contains(&("api_secret".into(), "secret-v1".into())),
1173 "a replaced client secret is as unrecoverable as a replaced key: {found:?}"
1174 );
1175 assert!(
1176 found.contains(&("extra_vars/SESSION_TOKEN".into(), "tok-v1".into())),
1177 "a named variable is where an env_var entry's whole payload lives: {found:?}"
1178 );
1179 assert!(
1180 !found.iter().any(|(f, _)| f == "extra_vars/REGION"),
1181 "a var marked public is a region by declaration; filling a 50-record \
1182 history with them evicts the values that cannot be recovered: {found:?}"
1183 );
1184 }
1185
1186 #[test]
1191 fn deleting_a_variable_still_versions_it() {
1192 let (conn, _d) = open_scratch("historydelete");
1193 let base = json!({ "api_keys": [{
1194 "id": "e1", "provider": "Aws", "api_key": "k",
1195 "extra_vars": [{ "key": "SESSION_TOKEN", "value": "tok-v1" }],
1196 }]});
1197 save_vault(&conn, base.clone(), SaveCtx::default()).unwrap();
1198
1199 let mut next = base.clone();
1200 next["api_keys"][0]["extra_vars"] = json!([]);
1201 save_vault(&conn, next, SaveCtx::default()).unwrap();
1202
1203 let hist = history_of(&conn);
1204 assert_eq!(hist.len(), 1, "{hist:?}");
1205 assert_eq!(hist[0]["field"], json!("extra_vars/SESSION_TOKEN"));
1206 assert_eq!(hist[0]["value"], json!("tok-v1"));
1207 }
1208
1209 #[test]
1216 fn variables_are_matched_by_name_not_position() {
1217 let (conn, _d) = open_scratch("historyreorder");
1218 let base = json!({ "api_keys": [{
1219 "id": "e1", "provider": "Aws", "api_key": "k",
1220 "extra_vars": [
1221 { "key": "A", "value": "a1" },
1222 { "key": "B", "value": "b1" },
1223 ],
1224 }]});
1225 save_vault(&conn, base, SaveCtx::default()).unwrap();
1226
1227 let next = json!({ "api_keys": [{
1229 "id": "e1", "provider": "Aws", "api_key": "k",
1230 "extra_vars": [
1231 { "key": "B", "value": "b2" },
1232 { "key": "A", "value": "a1" },
1233 ],
1234 }]});
1235 save_vault(&conn, next, SaveCtx::default()).unwrap();
1236
1237 let hist = history_of(&conn);
1238 assert_eq!(hist.len(), 1, "only B changed: {hist:?}");
1239 assert_eq!(hist[0]["field"], json!("extra_vars/B"));
1240 assert_eq!(hist[0]["value"], json!("b1"));
1241 }
1242
1243 #[test]
1246 fn saving_stamps_the_schema_version() {
1247 let (conn, _d) = open_scratch("schemastamp");
1248 assert_eq!(
1249 vault_schema_version(&conn).unwrap(),
1250 None,
1251 "a fresh database carries no stamp until something is written"
1252 );
1253 save_vault(
1254 &conn,
1255 serde_json::json!({ "api_keys": [] }),
1256 SaveCtx::default(),
1257 )
1258 .unwrap();
1259 assert_eq!(
1260 vault_schema_version(&conn).unwrap(),
1261 Some(VAULT_SCHEMA_VERSION)
1262 );
1263 }
1264
1265 #[test]
1266 fn an_unstamped_vault_still_opens() {
1267 let (conn, _d) = open_scratch("schemalegacy");
1270 save_vault(
1271 &conn,
1272 serde_json::json!({ "api_keys": [] }),
1273 SaveCtx::default(),
1274 )
1275 .unwrap();
1276 conn.execute("DELETE FROM vault_meta WHERE key = 'schema_version'", [])
1277 .unwrap();
1278 assert!(load_vault(&conn).unwrap().is_some());
1279 }
1280
1281 #[test]
1282 fn a_future_schema_is_refused_for_both_read_and_write() {
1283 let (conn, _d) = open_scratch("schemafuture");
1288 save_vault(
1289 &conn,
1290 serde_json::json!({ "api_keys": [], "projects": [] }),
1291 SaveCtx::default(),
1292 )
1293 .unwrap();
1294 conn.execute(
1295 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', ?1)",
1296 rusqlite::params![(VAULT_SCHEMA_VERSION + 1).to_string()],
1297 )
1298 .unwrap();
1299
1300 let read = load_vault(&conn).unwrap_err();
1301 assert!(read.starts_with(SCHEMA_ERR), "load said: {read}");
1302 let write = save_vault(
1303 &conn,
1304 serde_json::json!({ "api_keys": [] }),
1305 SaveCtx::default(),
1306 )
1307 .unwrap_err();
1308 assert!(write.starts_with(SCHEMA_ERR), "save said: {write}");
1309
1310 conn.execute(
1312 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', '1')",
1313 [],
1314 )
1315 .unwrap();
1316 let v = load_vault(&conn).unwrap().unwrap();
1317 assert!(
1318 v.get("projects").is_some(),
1319 "the refused save wrote nothing"
1320 );
1321 }
1322
1323 #[test]
1324 fn an_unparseable_stamp_is_treated_as_unknown_not_as_absent() {
1325 let (conn, _d) = open_scratch("schemajunk");
1326 conn.execute(
1327 "INSERT OR REPLACE INTO vault_meta (key, value) VALUES ('schema_version', 'tomorrow')",
1328 [],
1329 )
1330 .unwrap();
1331 let e = load_vault(&conn).unwrap_err();
1332 assert!(e.starts_with(SCHEMA_ERR), "said: {e}");
1333 }
1334
1335 #[test]
1338 fn derive_key_is_deterministic_and_salt_sensitive() {
1339 let a = derive_key("hunter2", b"0123456789abcdef").unwrap();
1340 let b = derive_key("hunter2", b"0123456789abcdef").unwrap();
1341 let c = derive_key("hunter2", b"fedcba9876543210").unwrap();
1342 let d = derive_key("hunter3", b"0123456789abcdef").unwrap();
1343 assert_eq!(a, b, "same password + salt must derive the same key");
1344 assert_ne!(a, c, "different salt must derive a different key");
1345 assert_ne!(a, d, "different password must derive a different key");
1346 }
1347
1348 #[test]
1349 fn salt_is_persisted_and_reused() {
1350 let dir = scratch("salt");
1351 let path = dir.join("vault.salt");
1352 let first = read_or_create_salt(&path).unwrap();
1353 let second = read_or_create_salt(&path).unwrap();
1354 assert_eq!(first, second, "salt must be stable across reads");
1355 assert_eq!(first.len(), SALT_LEN);
1356 }
1357
1358 #[test]
1361 fn entry_ck_prefers_stable_id() {
1362 let a = json!({ "id": "abc", "provider": "GitHub", "account_name": "x" });
1363 let b = json!({ "id": "abc", "provider": "Renamed", "account_name": "y" });
1364 assert_eq!(entry_ck(&a), entry_ck(&b), "id must dominate other fields");
1365 }
1366
1367 #[test]
1368 fn entry_ck_legacy_distinguishes_key_id() {
1369 let a = json!({ "provider": "AWS", "account_name": "prod", "key_id": "one" });
1372 let b = json!({ "provider": "AWS", "account_name": "prod", "key_id": "two" });
1373 assert_ne!(entry_ck(&a), entry_ck(&b));
1374 }
1375
1376 #[test]
1377 fn entry_ck_ignores_empty_id() {
1378 let with_empty = json!({ "id": "", "provider": "P" });
1379 let without = json!({ "provider": "P" });
1380 assert_eq!(entry_ck(&with_empty), entry_ck(&without));
1381 }
1382
1383 #[test]
1386 fn save_then_load_roundtrips() {
1387 let (conn, _dir) = open_scratch("roundtrip");
1388 let data = json!({
1389 "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "ghp_aaa" }],
1390 "user_categories": ["dev"],
1391 "projects": [{ "id": "Universal", "name": "Universal" }],
1392 });
1393 save_vault(&conn, data.clone(), SaveCtx::default()).unwrap();
1394 let loaded = load_vault(&conn).unwrap().expect("vault should exist");
1395 assert_eq!(loaded["api_keys"][0]["provider"], "GitHub");
1396 assert_eq!(loaded["user_categories"][0], "dev");
1397 }
1398
1399 #[test]
1400 fn load_returns_none_for_fresh_vault() {
1401 let (conn, _dir) = open_scratch("fresh");
1402 assert!(load_vault(&conn).unwrap().is_none());
1403 }
1404
1405 #[test]
1406 fn changing_a_key_records_previous_value_in_history() {
1407 let (conn, _dir) = open_scratch("history");
1408 save_vault(
1409 &conn,
1410 json!({
1411 "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "old_value" }]
1412 }),
1413 SaveCtx::default(),
1414 )
1415 .unwrap();
1416 save_vault(
1417 &conn,
1418 json!({
1419 "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "new_value" }]
1420 }),
1421 SaveCtx::default(),
1422 )
1423 .unwrap();
1424
1425 let loaded = load_vault(&conn).unwrap().unwrap();
1426 let history = loaded["api_keys"][0]["version_history"].as_array().unwrap();
1427 assert_eq!(
1428 history.len(),
1429 1,
1430 "one rotation should append one history entry"
1431 );
1432 assert_eq!(history[0]["value"], "old_value");
1433 }
1434
1435 #[test]
1436 fn a_replaced_totp_seed_is_versioned_and_labelled() {
1437 let (conn, _dir) = open_scratch("totp-history");
1441 save_vault(
1442 &conn,
1443 json!({
1444 "api_keys": [{
1445 "id": "1", "provider": "GitHub",
1446 "api_key": "k1", "totp_secret": "JBSWY3DPEHPK3PXP",
1447 }]
1448 }),
1449 SaveCtx::default(),
1450 )
1451 .unwrap();
1452 save_vault(
1453 &conn,
1454 json!({
1455 "api_keys": [{
1456 "id": "1", "provider": "GitHub",
1457 "api_key": "k1", "totp_secret": "MZXW6YTBOI======",
1458 }]
1459 }),
1460 SaveCtx::default(),
1461 )
1462 .unwrap();
1463
1464 let loaded = load_vault(&conn).unwrap().unwrap();
1465 let history = loaded["api_keys"][0]["version_history"].as_array().unwrap();
1466 assert_eq!(history.len(), 1, "the seed change is one revision");
1467 assert_eq!(history[0]["value"], "JBSWY3DPEHPK3PXP");
1468 assert_eq!(history[0]["field"], "totp_secret");
1470 }
1471
1472 #[test]
1473 fn an_api_key_revision_still_carries_no_field_discriminator() {
1474 let (conn, _dir) = open_scratch("legacy-history-shape");
1478 save_vault(
1479 &conn,
1480 json!({ "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "v1" }] }),
1481 SaveCtx::default(),
1482 )
1483 .unwrap();
1484 save_vault(
1485 &conn,
1486 json!({ "api_keys": [{ "id": "1", "provider": "GitHub", "api_key": "v2" }] }),
1487 SaveCtx::default(),
1488 )
1489 .unwrap();
1490
1491 let loaded = load_vault(&conn).unwrap().unwrap();
1492 let history = loaded["api_keys"][0]["version_history"].as_array().unwrap();
1493 assert!(history[0].get("field").is_none(), "{:?}", history[0]);
1494 }
1495
1496 #[test]
1497 fn history_follows_the_id_not_the_provider_name() {
1498 let (conn, _dir) = open_scratch("rename");
1501 save_vault(
1502 &conn,
1503 json!({
1504 "api_keys": [{ "id": "1", "provider": "OldName", "api_key": "v1" }]
1505 }),
1506 SaveCtx::default(),
1507 )
1508 .unwrap();
1509 save_vault(
1510 &conn,
1511 json!({
1512 "api_keys": [{ "id": "1", "provider": "NewName", "api_key": "v2" }]
1513 }),
1514 SaveCtx::default(),
1515 )
1516 .unwrap();
1517
1518 let loaded = load_vault(&conn).unwrap().unwrap();
1519 let history = loaded["api_keys"][0]["version_history"].as_array().unwrap();
1520 assert_eq!(history[0]["value"], "v1", "history must survive a rename");
1521 }
1522
1523 #[test]
1524 fn integrity_hash_matches_after_save() {
1525 let (conn, _dir) = open_scratch("integrity");
1526 save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1527 assert!(verify_vault_integrity(&conn).unwrap());
1528 }
1529
1530 #[test]
1531 fn integrity_check_detects_tampering() {
1532 let (conn, _dir) = open_scratch("tamper");
1533 save_vault(
1534 &conn,
1535 json!({
1536 "api_keys": [{ "id": "1", "provider": "P", "api_key": "k" }]
1537 }),
1538 SaveCtx::default(),
1539 )
1540 .unwrap();
1541 conn.execute(
1543 "UPDATE vault_rows SET data = ?1 WHERE kind = 'entry'",
1544 rusqlite::params![r#"{"id":"1","provider":"EVIL","api_key":"k"}"#],
1545 )
1546 .unwrap();
1547 assert!(
1548 !verify_vault_integrity(&conn).unwrap(),
1549 "tampered data must fail the hash check"
1550 );
1551 }
1552
1553 #[test]
1554 fn empty_vault_is_trivially_intact() {
1555 let (conn, _dir) = open_scratch("empty-integrity");
1556 assert!(verify_vault_integrity(&conn).unwrap());
1557 }
1558
1559 #[test]
1562 fn version_changes_with_every_write() {
1563 let (conn, _dir) = open_scratch("version");
1564 assert!(
1565 vault_version(&conn).unwrap().is_none(),
1566 "empty vault has no version"
1567 );
1568 let v1 = save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1569 let v2 = save_vault(
1570 &conn,
1571 json!({
1572 "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1573 }),
1574 SaveCtx::default(),
1575 )
1576 .unwrap();
1577 assert_ne!(v1, v2);
1578 assert_eq!(vault_version(&conn).unwrap().as_deref(), Some(v2.as_str()));
1579 }
1580
1581 #[test]
1582 fn returned_version_is_the_stored_version() {
1583 let (conn, _dir) = open_scratch("version-match");
1586 let v = save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1587 assert_eq!(vault_version(&conn).unwrap().unwrap(), v);
1588 }
1589
1590 #[test]
1591 fn writing_at_the_expected_version_succeeds() {
1592 let (conn, _dir) = open_scratch("cas-ok");
1593 let v1 = save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1594 let res = save_vault(
1595 &conn,
1596 json!({
1597 "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1598 }),
1599 SaveCtx {
1600 actor: None,
1601 expect_version: Some(&v1),
1602 },
1603 );
1604 assert!(res.is_ok());
1605 }
1606
1607 #[test]
1608 fn writing_at_a_stale_version_is_refused() {
1609 let (conn, _dir) = open_scratch("cas-stale");
1612 let v1 = save_vault(
1613 &conn,
1614 json!({
1615 "api_keys": [{ "id": "1", "provider": "original", "api_key": "k" }]
1616 }),
1617 SaveCtx::default(),
1618 )
1619 .unwrap();
1620
1621 save_vault(
1623 &conn,
1624 json!({
1625 "api_keys": [{ "id": "1", "provider": "written-by-A", "api_key": "k" }]
1626 }),
1627 SaveCtx {
1628 actor: None,
1629 expect_version: Some(&v1),
1630 },
1631 )
1632 .unwrap();
1633
1634 let err = save_vault(
1636 &conn,
1637 json!({
1638 "api_keys": [{ "id": "1", "provider": "written-by-B", "api_key": "k" }]
1639 }),
1640 SaveCtx {
1641 actor: None,
1642 expect_version: Some(&v1),
1643 },
1644 )
1645 .expect_err("a stale write must be refused");
1646 assert!(
1647 err.starts_with(CONFLICT_ERR),
1648 "callers match on this prefix, got: {err}"
1649 );
1650
1651 let stored = load_vault(&conn).unwrap().unwrap();
1653 assert_eq!(stored["api_keys"][0]["provider"], "written-by-A");
1654 }
1655
1656 #[test]
1657 fn a_refused_write_leaves_no_trace() {
1658 let (conn, _dir) = open_scratch("cas-clean");
1661 let v1 = save_vault(
1662 &conn,
1663 json!({
1664 "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1665 }),
1666 SaveCtx::default(),
1667 )
1668 .unwrap();
1669 save_vault(
1671 &conn,
1672 json!({
1673 "api_keys": [{ "id": "1", "provider": "A-theirs", "api_key": "k" }]
1674 }),
1675 SaveCtx::default(),
1676 )
1677 .unwrap();
1678
1679 let audit_before = load_audit(&conn).unwrap().len();
1680 let version_before = vault_version(&conn).unwrap();
1681
1682 let err = save_vault(
1683 &conn,
1684 json!({
1685 "api_keys": [
1686 { "id": "1", "provider": "A-mine", "api_key": "k" },
1687 { "id": "9", "provider": "GHOST", "api_key": "k" }
1688 ]
1689 }),
1690 SaveCtx {
1691 actor: None,
1692 expect_version: Some(&v1),
1693 },
1694 )
1695 .expect_err("both sides edited entry 1");
1696 assert!(err.starts_with(CONFLICT_ERR), "{err}");
1697 assert!(
1698 err.contains("A-mine"),
1699 "the conflict names the entry: {err}"
1700 );
1701
1702 assert_eq!(
1703 load_audit(&conn).unwrap().len(),
1704 audit_before,
1705 "a refused write must not append audit rows"
1706 );
1707 assert_eq!(vault_version(&conn).unwrap(), version_before);
1708 assert!(!load_audit(&conn)
1709 .unwrap()
1710 .iter()
1711 .any(|r| r.entry_provider.as_deref() == Some("GHOST")));
1712 let stored = load_vault(&conn).unwrap().unwrap();
1713 assert_eq!(stored["api_keys"].as_array().unwrap().len(), 1);
1714 assert_eq!(stored["api_keys"][0]["provider"], "A-theirs");
1715 }
1716
1717 #[test]
1718 fn expecting_a_version_against_an_empty_vault_is_refused() {
1719 let (conn, _dir) = open_scratch("cas-empty");
1720 let err = save_vault(
1721 &conn,
1722 json!({ "api_keys": [] }),
1723 SaveCtx {
1724 actor: None,
1725 expect_version: Some("deadbeef"),
1726 },
1727 )
1728 .expect_err("nothing is stored, so no version can match");
1729 assert!(err.starts_with(CONFLICT_ERR));
1730 }
1731
1732 #[test]
1733 fn omitting_the_version_writes_unconditionally() {
1734 let (conn, _dir) = open_scratch("cas-force");
1736 save_vault(
1737 &conn,
1738 json!({
1739 "api_keys": [{ "id": "1", "provider": "first", "api_key": "k" }]
1740 }),
1741 SaveCtx::default(),
1742 )
1743 .unwrap();
1744 save_vault(
1745 &conn,
1746 json!({
1747 "api_keys": [{ "id": "1", "provider": "forced", "api_key": "k" }]
1748 }),
1749 SaveCtx::default(),
1750 )
1751 .unwrap();
1752 let stored = load_vault(&conn).unwrap().unwrap();
1753 assert_eq!(stored["api_keys"][0]["provider"], "forced");
1754 }
1755
1756 #[test]
1757 fn integrity_still_holds_after_a_refused_write() {
1758 let (conn, _dir) = open_scratch("cas-integrity");
1759 let v1 = save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1760 save_vault(
1761 &conn,
1762 json!({
1763 "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1764 }),
1765 SaveCtx::default(),
1766 )
1767 .unwrap();
1768 let _ = save_vault(
1769 &conn,
1770 json!({ "api_keys": [] }),
1771 SaveCtx {
1772 actor: None,
1773 expect_version: Some(&v1),
1774 },
1775 );
1776 assert!(
1777 verify_vault_integrity(&conn).unwrap(),
1778 "a rolled-back write must not desync data from its hash"
1779 );
1780 }
1781
1782 #[test]
1785 fn audit_rows_form_a_hash_chain() {
1786 let (conn, _dir) = open_scratch("audit");
1787 save_vault(
1788 &conn,
1789 json!({
1790 "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1791 }),
1792 SaveCtx::default(),
1793 )
1794 .unwrap();
1795 save_vault(
1796 &conn,
1797 json!({
1798 "api_keys": [
1799 { "id": "1", "provider": "A", "api_key": "k" },
1800 { "id": "2", "provider": "B", "api_key": "k2" }
1801 ]
1802 }),
1803 SaveCtx::default(),
1804 )
1805 .unwrap();
1806
1807 let mut rows = load_audit(&conn).unwrap();
1808 assert!(rows.len() >= 2, "expected an audit row per added entry");
1809 rows.sort_by_key(|r| r.id); assert!(rows[0].entry_hash.is_some());
1811 for pair in rows.windows(2) {
1813 assert_eq!(
1814 pair[1].prev_hash, pair[0].entry_hash,
1815 "row {} must chain to row {}",
1816 pair[1].id, pair[0].id
1817 );
1818 }
1819 }
1820
1821 #[test]
1822 fn deleting_an_entry_is_audited() {
1823 let (conn, _dir) = open_scratch("audit-delete");
1824 save_vault(
1825 &conn,
1826 json!({
1827 "api_keys": [{ "id": "1", "provider": "Doomed", "api_key": "k" }]
1828 }),
1829 SaveCtx::default(),
1830 )
1831 .unwrap();
1832 save_vault(&conn, json!({ "api_keys": [] }), SaveCtx::default()).unwrap();
1833 let rows = load_audit(&conn).unwrap();
1834 assert!(rows
1835 .iter()
1836 .any(|r| r.action == "delete" && r.entry_provider.as_deref() == Some("Doomed")));
1837 }
1838
1839 #[test]
1840 fn audit_rows_record_the_acting_user() {
1841 let (conn, _dir) = open_scratch("audit-actor");
1842 save_vault(
1843 &conn,
1844 json!({
1845 "api_keys": [{ "id": "1", "provider": "A", "api_key": "k" }]
1846 }),
1847 SaveCtx {
1848 actor: Some("user-123"),
1849 ..Default::default()
1850 },
1851 )
1852 .unwrap();
1853 let rows = load_audit(&conn).unwrap();
1854 let add = rows.iter().find(|r| r.action == "add").unwrap();
1855 assert_eq!(add.actor.as_deref(), Some("user-123"));
1856 }
1857
1858 #[test]
1859 fn actor_is_bound_into_the_hash_chain() {
1860 let with = compute_audit_hash("add", "P", "T", Some("alice"), "prev");
1863 let other = compute_audit_hash("add", "P", "T", Some("bob"), "prev");
1864 let without = compute_audit_hash("add", "P", "T", None, "prev");
1865 assert_ne!(with, other, "different actor must give a different hash");
1866 assert_ne!(with, without);
1867 }
1868
1869 #[test]
1870 fn actorless_rows_keep_the_v1_hash_format() {
1871 use sha2::{Digest, Sha256};
1874 let mut h = Sha256::new();
1875 for part in ["add", "|", "P", "|", "T", "|", "prev"] {
1876 h.update(part.as_bytes());
1877 }
1878 assert_eq!(
1879 compute_audit_hash("add", "P", "T", None, "prev"),
1880 hex::encode(h.finalize())
1881 );
1882 }
1883
1884 #[test]
1887 fn expiring_selects_only_the_window() {
1888 let now = time::OffsetDateTime::now_utc();
1889 let fmt = |d: i64| {
1890 let t = now + time::Duration::days(d);
1891 format!("{:04}-{:02}-{:02}", t.year(), t.month() as u8, t.day())
1892 };
1893 let data = json!({ "api_keys": [
1894 { "provider": "expired", "expires_at": fmt(-5) },
1895 { "provider": "soon", "expires_at": fmt(3) },
1896 { "provider": "far", "expires_at": fmt(365) },
1897 { "provider": "no-expiry" },
1898 ]});
1899 let found = expiring_from_value(&data, 30);
1900 let names: Vec<&str> = found
1901 .iter()
1902 .map(|e| e["provider"].as_str().unwrap())
1903 .collect();
1904 assert_eq!(
1905 names,
1906 vec!["soon"],
1907 "already-expired, far-future and never-expiring entries are all excluded"
1908 );
1909 }
1910}
1911
1912#[cfg(test)]
1913mod salt_pairing_tests {
1914 use super::*;
1915
1916 fn tmp(name: &str) -> std::path::PathBuf {
1917 let d = std::env::temp_dir().join(format!("unv-salt-{name}-{}", std::process::id()));
1918 let _ = fs::remove_dir_all(&d);
1919 fs::create_dir_all(&d).unwrap();
1920 d
1921 }
1922
1923 #[test]
1927 fn a_database_without_its_salt_is_refused_not_re_salted() {
1928 let d = tmp("orphan");
1929 let db = d.join("vault.db");
1930 let salt = d.join("vault.salt");
1931 fs::write(&db, b"pretend this is a SQLCipher file").unwrap();
1932
1933 let err = check_salt_pairing(&db, &salt).unwrap_err();
1934 assert!(err.contains("is missing"), "{err}");
1935 assert!(
1936 err.contains("nothing can recompute it"),
1937 "the message must not imply recovery is possible: {err}"
1938 );
1939 assert!(!salt.exists(), "the check must not create a salt");
1940 let _ = fs::remove_dir_all(&d);
1941 }
1942
1943 #[test]
1945 fn a_fresh_directory_is_fine() {
1946 let d = tmp("fresh");
1947 assert!(check_salt_pairing(&d.join("vault.db"), &d.join("vault.salt")).is_ok());
1948 let _ = fs::remove_dir_all(&d);
1949 }
1950
1951 #[test]
1953 fn an_empty_database_file_is_not_treated_as_a_vault() {
1954 let d = tmp("empty");
1955 let db = d.join("vault.db");
1956 fs::write(&db, b"").unwrap();
1957 assert!(check_salt_pairing(&db, &d.join("vault.salt")).is_ok());
1958 let _ = fs::remove_dir_all(&d);
1959 }
1960
1961 #[test]
1963 #[cfg(unix)]
1964 fn a_generated_salt_is_owner_only() {
1965 use std::os::unix::fs::PermissionsExt;
1966 let d = tmp("mode");
1967 let salt = d.join("vault.salt");
1968 read_or_create_salt(&salt).unwrap();
1969 let mode = fs::metadata(&salt).unwrap().permissions().mode() & 0o777;
1970 assert_eq!(mode, 0o600, "salt was created world-readable");
1971 let _ = fs::remove_dir_all(&d);
1972 }
1973
1974 #[test]
1976 #[cfg(unix)]
1977 fn a_created_database_is_owner_only() {
1978 use std::os::unix::fs::PermissionsExt;
1979 let d = tmp("dbmode");
1980 let db = d.join("vault.db");
1981 let key = derive_key(
1982 "correct-horse-battery",
1983 &read_or_create_salt(&d.join("vault.salt")).unwrap(),
1984 )
1985 .unwrap();
1986 let conn = open_db(&db, &key).unwrap();
1987 drop(conn);
1988 let mode = fs::metadata(&db).unwrap().permissions().mode() & 0o777;
1989 assert_eq!(mode, 0o600, "database was created world-readable");
1990 let _ = fs::remove_dir_all(&d);
1991 }
1992}
1993
1994fn env_bare_ok(v: &str) -> bool {
1996 !v.is_empty()
1997 && v.chars()
1998 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | ':' | '@' | '-'))
1999}
2000
2001pub fn env_quote(value: &str) -> String {
2009 if env_bare_ok(value) {
2010 return value.to_string();
2011 }
2012 let mut out = String::with_capacity(value.len() + 2);
2013 out.push('"');
2014 for ch in value.chars() {
2015 match ch {
2016 '\\' | '"' | '$' | '`' => {
2017 out.push('\\');
2018 out.push(ch);
2019 }
2020 '\n' => out.push_str("\\n"),
2021 '\r' => out.push_str("\\r"),
2022 '\t' => out.push_str("\\t"),
2023 _ => out.push(ch),
2024 }
2025 }
2026 out.push('"');
2027 out
2028}