use anyhow::Result; use serde_json::{Map, Value}; use sqlx::PgPool; use std::fs; use uuid::Uuid; use crate::crypto; use crate::db; use crate::models::EntryRow; // ── Key/value parsing helpers ───────────────────────────────────────────────── pub fn parse_kv(entry: &str) -> Result<(Vec, Value)> { if let Some((key, json_str)) = entry.split_once(":=") { let val: Value = serde_json::from_str(json_str).map_err(|e| { anyhow::anyhow!( "Invalid JSON value for key '{}': {} (use key=value for plain strings)", key, e ) })?; return Ok((parse_key_path(key)?, val)); } if let Some((key, raw_val)) = entry.split_once('=') { let value = if let Some(path) = raw_val.strip_prefix('@') { fs::read_to_string(path) .map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))? } else { raw_val.to_string() }; return Ok((parse_key_path(key)?, Value::String(value))); } if let Some((key, path)) = entry.split_once('@') { let value = fs::read_to_string(path) .map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?; return Ok((parse_key_path(key)?, Value::String(value))); } anyhow::bail!( "Invalid format '{}'. Expected: key=value, key=@file, nested:key@file, or key:=", entry ) } pub fn build_json(entries: &[String]) -> Result { let mut map = Map::new(); for entry in entries { let (path, value) = parse_kv(entry)?; insert_path(&mut map, &path, value)?; } Ok(Value::Object(map)) } pub fn key_path_to_string(path: &[String]) -> String { path.join(":") } pub fn collect_key_paths(entries: &[String]) -> Result> { entries .iter() .map(|entry| parse_kv(entry).map(|(path, _)| key_path_to_string(&path))) .collect() } pub fn collect_field_paths(entries: &[String]) -> Result> { entries .iter() .map(|entry| parse_key_path(entry).map(|path| key_path_to_string(&path))) .collect() } pub fn parse_key_path(key: &str) -> Result> { let path: Vec = key .split(':') .map(str::trim) .map(ToOwned::to_owned) .collect(); if path.is_empty() || path.iter().any(|part| part.is_empty()) { anyhow::bail!( "Invalid key path '{}'. Use non-empty segments like 'credentials:content'.", key ); } Ok(path) } pub fn insert_path(map: &mut Map, path: &[String], value: Value) -> Result<()> { if path.is_empty() { anyhow::bail!("Key path cannot be empty"); } if path.len() == 1 { map.insert(path[0].clone(), value); return Ok(()); } let head = path[0].clone(); let tail = &path[1..]; match map.entry(head.clone()) { serde_json::map::Entry::Vacant(entry) => { let mut child = Map::new(); insert_path(&mut child, tail, value)?; entry.insert(Value::Object(child)); } serde_json::map::Entry::Occupied(mut entry) => match entry.get_mut() { Value::Object(child) => insert_path(child, tail, value)?, _ => { anyhow::bail!( "Cannot set nested key '{}' because '{}' is already a non-object value", key_path_to_string(path), head ); } }, } Ok(()) } pub fn remove_path(map: &mut Map, path: &[String]) -> Result { if path.is_empty() { anyhow::bail!("Key path cannot be empty"); } if path.len() == 1 { return Ok(map.remove(&path[0]).is_some()); } let Some(value) = map.get_mut(&path[0]) else { return Ok(false); }; let Value::Object(child) = value else { return Ok(false); }; let removed = remove_path(child, &path[1..])?; if child.is_empty() { map.remove(&path[0]); } Ok(removed) } pub fn flatten_json_fields(prefix: &str, value: &Value) -> Vec<(String, Value)> { match value { Value::Object(map) => { let mut out = Vec::new(); for (k, v) in map { let full_key = if prefix.is_empty() { k.clone() } else { format!("{}.{}", prefix, k) }; out.extend(flatten_json_fields(&full_key, v)); } out } other => vec![(prefix.to_string(), other.clone())], } } // ── AddResult ───────────────────────────────────────────────────────────────── #[derive(Debug, serde::Serialize)] pub struct AddResult { pub namespace: String, pub kind: String, pub name: String, pub tags: Vec, pub meta_keys: Vec, pub secret_keys: Vec, } pub struct AddParams<'a> { pub namespace: &'a str, pub kind: &'a str, pub name: &'a str, pub tags: &'a [String], pub meta_entries: &'a [String], pub secret_entries: &'a [String], /// Optional user_id for multi-user isolation (None = single-user CLI mode) pub user_id: Option, } pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Result { let metadata = build_json(params.meta_entries)?; let secret_json = build_json(params.secret_entries)?; let meta_keys = collect_key_paths(params.meta_entries)?; let secret_keys = collect_key_paths(params.secret_entries)?; let mut tx = pool.begin().await?; // Fetch existing entry (user-scoped or global depending on user_id) let existing: Option = if let Some(uid) = params.user_id { sqlx::query_as( "SELECT id, version, tags, metadata FROM entries \ WHERE user_id = $1 AND namespace = $2 AND kind = $3 AND name = $4", ) .bind(uid) .bind(params.namespace) .bind(params.kind) .bind(params.name) .fetch_optional(&mut *tx) .await? } else { sqlx::query_as( "SELECT id, version, tags, metadata FROM entries \ WHERE user_id IS NULL AND namespace = $1 AND kind = $2 AND name = $3", ) .bind(params.namespace) .bind(params.kind) .bind(params.name) .fetch_optional(&mut *tx) .await? }; if let Some(ref ex) = existing && let Err(e) = db::snapshot_entry_history( &mut tx, db::EntrySnapshotParams { entry_id: ex.id, user_id: params.user_id, namespace: params.namespace, kind: params.kind, name: params.name, version: ex.version, action: "add", tags: &ex.tags, metadata: &ex.metadata, }, ) .await { tracing::warn!(error = %e, "failed to snapshot entry history before upsert"); } let entry_id: Uuid = if let Some(uid) = params.user_id { sqlx::query_scalar( r#"INSERT INTO entries (user_id, namespace, kind, name, tags, metadata, version, updated_at) VALUES ($1, $2, $3, $4, $5, $6, 1, NOW()) ON CONFLICT (user_id, namespace, kind, name) WHERE user_id IS NOT NULL DO UPDATE SET tags = EXCLUDED.tags, metadata = EXCLUDED.metadata, version = entries.version + 1, updated_at = NOW() RETURNING id"#, ) .bind(uid) .bind(params.namespace) .bind(params.kind) .bind(params.name) .bind(params.tags) .bind(&metadata) .fetch_one(&mut *tx) .await? } else { sqlx::query_scalar( r#"INSERT INTO entries (namespace, kind, name, tags, metadata, version, updated_at) VALUES ($1, $2, $3, $4, $5, 1, NOW()) ON CONFLICT (namespace, kind, name) WHERE user_id IS NULL DO UPDATE SET tags = EXCLUDED.tags, metadata = EXCLUDED.metadata, version = entries.version + 1, updated_at = NOW() RETURNING id"#, ) .bind(params.namespace) .bind(params.kind) .bind(params.name) .bind(params.tags) .bind(&metadata) .fetch_one(&mut *tx) .await? }; let new_entry_version: i64 = sqlx::query_scalar("SELECT version FROM entries WHERE id = $1") .bind(entry_id) .fetch_one(&mut *tx) .await?; if existing.is_none() && let Err(e) = db::snapshot_entry_history( &mut tx, db::EntrySnapshotParams { entry_id, user_id: params.user_id, namespace: params.namespace, kind: params.kind, name: params.name, version: new_entry_version, action: "create", tags: params.tags, metadata: &metadata, }, ) .await { tracing::warn!(error = %e, "failed to snapshot entry history on create"); } if existing.is_some() { #[derive(sqlx::FromRow)] struct ExistingField { id: Uuid, field_name: String, encrypted: Vec, } let existing_fields: Vec = sqlx::query_as("SELECT id, field_name, encrypted FROM secrets WHERE entry_id = $1") .bind(entry_id) .fetch_all(&mut *tx) .await?; for f in &existing_fields { if let Err(e) = db::snapshot_secret_history( &mut tx, db::SecretSnapshotParams { entry_id, secret_id: f.id, entry_version: new_entry_version - 1, field_name: &f.field_name, encrypted: &f.encrypted, action: "add", }, ) .await { tracing::warn!(error = %e, "failed to snapshot secret field history"); } } sqlx::query("DELETE FROM secrets WHERE entry_id = $1") .bind(entry_id) .execute(&mut *tx) .await?; } let flat_fields = flatten_json_fields("", &secret_json); for (field_name, field_value) in &flat_fields { let encrypted = crypto::encrypt_json(master_key, field_value)?; sqlx::query("INSERT INTO secrets (entry_id, field_name, encrypted) VALUES ($1, $2, $3)") .bind(entry_id) .bind(field_name) .bind(&encrypted) .execute(&mut *tx) .await?; } crate::audit::log_tx( &mut tx, params.user_id, "add", params.namespace, params.kind, params.name, serde_json::json!({ "tags": params.tags, "meta_keys": meta_keys, "secret_keys": secret_keys, }), ) .await; tx.commit().await?; Ok(AddResult { namespace: params.namespace.to_string(), kind: params.kind.to_string(), name: params.name.to_string(), tags: params.tags.to_vec(), meta_keys, secret_keys, }) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_nested_file_shorthand() { use std::io::Write; let mut f = tempfile::NamedTempFile::new().unwrap(); writeln!(f, "line1\nline2").unwrap(); let path = f.path().to_str().unwrap().to_string(); let entry = format!("credentials:content@{}", path); let (path_parts, value) = parse_kv(&entry).unwrap(); assert_eq!(key_path_to_string(&path_parts), "credentials:content"); assert!(matches!(value, Value::String(_))); } #[test] fn flatten_json_fields_nested() { let v = serde_json::json!({ "username": "root", "credentials": { "type": "ssh", "content": "pem" } }); let mut fields = flatten_json_fields("", &v); fields.sort_by(|a, b| a.0.cmp(&b.0)); assert_eq!(fields[0].0, "credentials.content"); assert_eq!(fields[1].0, "credentials.type"); assert_eq!(fields[2].0, "username"); } }