use anyhow::Result; use serde_json::{Value, json}; use sqlx::PgPool; use std::collections::HashMap; use crate::crypto; use crate::models::{Entry, SecretField}; use crate::output::{OutputMode, format_local_time}; pub struct SearchArgs<'a> { pub namespace: Option<&'a str>, pub kind: Option<&'a str>, pub name: Option<&'a str>, pub tags: &'a [String], pub query: Option<&'a str>, pub fields: &'a [String], pub summary: bool, pub limit: u32, pub offset: u32, pub sort: &'a str, pub output: OutputMode, } pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> { validate_safe_search_args(args.fields)?; let rows = fetch_entries_paged( pool, PagedFetchArgs { namespace: args.namespace, kind: args.kind, name: args.name, tags: args.tags, query: args.query, sort: args.sort, limit: args.limit, offset: args.offset, }, ) .await?; // -f/--field: extract specific metadata field values directly if !args.fields.is_empty() { return print_fields(&rows, args.fields); } // Fetch secret schemas for all returned entries (no master key needed). let entry_ids: Vec = rows.iter().map(|r| r.id).collect(); let schema_map = if !args.summary && !entry_ids.is_empty() { fetch_secret_schemas(pool, &entry_ids).await? } else { HashMap::new() }; match args.output { OutputMode::Json | OutputMode::JsonCompact => { let arr: Vec = rows .iter() .map(|r| to_json(r, args.summary, schema_map.get(&r.id).map(Vec::as_slice))) .collect(); let out = if args.output == OutputMode::Json { serde_json::to_string_pretty(&arr)? } else { serde_json::to_string(&arr)? }; println!("{}", out); } OutputMode::Text => { if rows.is_empty() { println!("No records found."); return Ok(()); } for row in &rows { print_text( row, args.summary, schema_map.get(&row.id).map(Vec::as_slice), )?; } println!("{} record(s) found.", rows.len()); if rows.len() == args.limit as usize { println!( " (showing up to {}; use --offset {} to see more)", args.limit, args.offset + args.limit ); } } } Ok(()) } fn validate_safe_search_args(fields: &[String]) -> Result<()> { if let Some(field) = fields.iter().find(|field| is_secret_field(field)) { anyhow::bail!( "Field '{}' is sensitive. `search -f` only supports metadata.* fields; use `secrets run` for secrets.", field ); } Ok(()) } fn is_secret_field(field: &str) -> bool { matches!( field.split_once('.').map(|(section, _)| section), Some("secret" | "secrets" | "encrypted") ) } // ── Entry fetching ──────────────────────────────────────────────────────────── struct PagedFetchArgs<'a> { namespace: Option<&'a str>, kind: Option<&'a str>, name: Option<&'a str>, tags: &'a [String], query: Option<&'a str>, sort: &'a str, limit: u32, offset: u32, } /// A very large limit used when callers need all matching records (export, run). /// Postgres will stop scanning when this many rows are found; adjust if needed. pub const FETCH_ALL_LIMIT: u32 = 100_000; /// Fetch entries matching the given filters (used by search, run). /// `limit` caps the result set; pass `FETCH_ALL_LIMIT` when you need all matching records. pub async fn fetch_entries( pool: &PgPool, namespace: Option<&str>, kind: Option<&str>, name: Option<&str>, tags: &[String], query: Option<&str>, ) -> Result> { fetch_entries_with_limit(pool, namespace, kind, name, tags, query, FETCH_ALL_LIMIT).await } /// Like `fetch_entries` but with an explicit limit. Used internally by `search`. pub(crate) async fn fetch_entries_with_limit( pool: &PgPool, namespace: Option<&str>, kind: Option<&str>, name: Option<&str>, tags: &[String], query: Option<&str>, limit: u32, ) -> Result> { fetch_entries_paged( pool, PagedFetchArgs { namespace, kind, name, tags, query, sort: "name", limit, offset: 0, }, ) .await } async fn fetch_entries_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result> { let mut conditions: Vec = Vec::new(); let mut idx: i32 = 1; if a.namespace.is_some() { conditions.push(format!("namespace = ${}", idx)); idx += 1; } if a.kind.is_some() { conditions.push(format!("kind = ${}", idx)); idx += 1; } if a.name.is_some() { conditions.push(format!("name = ${}", idx)); idx += 1; } if !a.tags.is_empty() { let placeholders: Vec = a .tags .iter() .map(|_| { let p = format!("${}", idx); idx += 1; p }) .collect(); conditions.push(format!("tags @> ARRAY[{}]", placeholders.join(", "))); } if a.query.is_some() { conditions.push(format!( "(name ILIKE ${i} ESCAPE '\\' OR namespace ILIKE ${i} ESCAPE '\\' OR kind ILIKE ${i} ESCAPE '\\' OR metadata::text ILIKE ${i} ESCAPE '\\' OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i} ESCAPE '\\'))", i = idx )); idx += 1; } let where_clause = if conditions.is_empty() { String::new() } else { format!("WHERE {}", conditions.join(" AND ")) }; let order = match a.sort { "updated" => "updated_at DESC", "created" => "created_at DESC", _ => "namespace, kind, name", }; let sql = format!( "SELECT * FROM entries {} ORDER BY {} LIMIT ${} OFFSET ${}", where_clause, order, idx, idx + 1 ); tracing::debug!(sql, "executing search query"); let mut q = sqlx::query_as::<_, Entry>(&sql); if let Some(v) = a.namespace { q = q.bind(v); } if let Some(v) = a.kind { q = q.bind(v); } if let Some(v) = a.name { q = q.bind(v); } for v in a.tags { q = q.bind(v.as_str()); } if let Some(v) = a.query { q = q.bind(format!( "%{}%", v.replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_") )); } q = q.bind(a.limit as i64).bind(a.offset as i64); Ok(q.fetch_all(pool).await?) } // ── Secret schema fetching (no master key) ─────────────────────────────────── /// Fetch secret field names for a set of entry ids. /// Returns a map from entry_id to list of SecretField. async fn fetch_secret_schemas( pool: &PgPool, entry_ids: &[uuid::Uuid], ) -> Result>> { if entry_ids.is_empty() { return Ok(HashMap::new()); } let fields: Vec = sqlx::query_as( "SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name", ) .bind(entry_ids) .fetch_all(pool) .await?; let mut map: HashMap> = HashMap::new(); for f in fields { map.entry(f.entry_id).or_default().push(f); } Ok(map) } /// Fetch all secret fields (including encrypted bytes) for a set of entry ids. pub async fn fetch_secrets_for_entries( pool: &PgPool, entry_ids: &[uuid::Uuid], ) -> Result>> { if entry_ids.is_empty() { return Ok(HashMap::new()); } let fields: Vec = sqlx::query_as( "SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name", ) .bind(entry_ids) .fetch_all(pool) .await?; let mut map: HashMap> = HashMap::new(); for f in fields { map.entry(f.entry_id).or_default().push(f); } Ok(map) } // ── Display helpers ─────────────────────────────────────────────────────────── fn env_prefix(entry: &Entry, prefix: &str) -> String { let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_"); if prefix.is_empty() { name_part } else { format!( "{}_{}", prefix.to_uppercase().replace(['-', '.', ' '], "_"), name_part ) } } /// Build a flat KEY=VALUE map from decrypted secret fields only. /// Resolves key_ref: if metadata.key_ref is set, merges secret fields from that key entry. pub async fn build_injected_env_map( pool: &PgPool, entry: &Entry, prefix: &str, master_key: &[u8; 32], fields: &[&SecretField], ) -> Result> { let effective_prefix = env_prefix(entry, prefix); let mut map = HashMap::new(); // Decrypt each secret field and add to env map. for f in fields { let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?; let key = format!( "{}_{}", effective_prefix, f.field_name.to_uppercase().replace(['-', '.'], "_") ); map.insert(key, json_value_to_env_string(&decrypted)); } // Resolve key_ref: merge secrets from the referenced key entry. if let Some(key_ref) = entry.metadata.get("key_ref").and_then(|v| v.as_str()) { let key_entries = fetch_entries( pool, Some(&entry.namespace), Some("key"), Some(key_ref), &[], None, ) .await?; if let Some(key_entry) = key_entries.first() { let key_ids = vec![key_entry.id]; let key_fields_map = fetch_secrets_for_entries(pool, &key_ids).await?; let empty = vec![]; let key_fields = key_fields_map.get(&key_entry.id).unwrap_or(&empty); let key_prefix = env_prefix(key_entry, prefix); for f in key_fields { let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?; let key_var = format!( "{}_{}", key_prefix, f.field_name.to_uppercase().replace(['-', '.'], "_") ); map.insert(key_var, json_value_to_env_string(&decrypted)); } } else { tracing::warn!(key_ref, "key_ref target not found"); } } Ok(map) } fn json_value_to_env_string(v: &Value) -> String { match v { Value::String(s) => s.clone(), Value::Null => String::new(), other => other.to_string(), } } fn to_json(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> Value { if summary { let desc = entry .metadata .get("desc") .or_else(|| entry.metadata.get("url")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); return json!({ "namespace": entry.namespace, "kind": entry.kind, "name": entry.name, "tags": entry.tags, "desc": desc, "updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), }); } let secrets_val: Value = match schema { Some(fields) if !fields.is_empty() => { let schema_arr: Vec = fields .iter() .map(|f| { json!({ "field_name": f.field_name, }) }) .collect(); Value::Array(schema_arr) } _ => Value::Array(vec![]), }; json!({ "id": entry.id, "namespace": entry.namespace, "kind": entry.kind, "name": entry.name, "tags": entry.tags, "metadata": entry.metadata, "secrets": secrets_val, "version": entry.version, "created_at": entry.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), }) } fn print_text(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> Result<()> { println!("[{}/{}] {}", entry.namespace, entry.kind, entry.name); if summary { let desc = entry .metadata .get("desc") .or_else(|| entry.metadata.get("url")) .and_then(|v| v.as_str()) .unwrap_or("-"); if !entry.tags.is_empty() { println!(" tags: [{}]", entry.tags.join(", ")); } println!(" desc: {}", desc); println!(" updated: {}", format_local_time(entry.updated_at)); } else { println!(" id: {}", entry.id); if !entry.tags.is_empty() { println!(" tags: [{}]", entry.tags.join(", ")); } if entry.metadata.as_object().is_some_and(|m| !m.is_empty()) { println!( " metadata: {}", serde_json::to_string_pretty(&entry.metadata)? ); } match schema { Some(fields) if !fields.is_empty() => { let schema_str: Vec = fields.iter().map(|f| f.field_name.clone()).collect(); println!(" secrets: {}", schema_str.join(", ")); println!(" (use `secrets run` to get values)"); } _ => {} } println!(" version: {}", entry.version); println!(" created: {}", format_local_time(entry.created_at)); } println!(); Ok(()) } /// Extract one or more metadata field paths like `metadata.url`. fn print_fields(rows: &[Entry], fields: &[String]) -> Result<()> { for row in rows { for field in fields { let val = extract_field(row, field)?; println!("{}", val); } } Ok(()) } fn extract_field(entry: &Entry, field: &str) -> Result { let (section, key) = field .split_once('.') .ok_or_else(|| anyhow::anyhow!("Invalid field path '{}'. Use metadata..", field))?; let obj = match section { "metadata" | "meta" => &entry.metadata, other => anyhow::bail!("Unknown field section '{}'. Use 'metadata'.", other), }; obj.get(key) .and_then(|v| { v.as_str() .map(|s| s.to_string()) .or_else(|| Some(v.to_string())) }) .ok_or_else(|| { anyhow::anyhow!( "Field '{}' not found in record [{}/{}/{}]", field, entry.namespace, entry.kind, entry.name ) }) } #[cfg(test)] mod tests { use super::*; use chrono::Utc; use serde_json::json; use uuid::Uuid; fn sample_entry() -> Entry { Entry { id: Uuid::nil(), namespace: "refining".to_string(), kind: "service".to_string(), name: "gitea.main".to_string(), tags: vec!["prod".to_string()], metadata: json!({"url": "https://code.example.com", "enabled": true}), version: 1, created_at: Utc::now(), updated_at: Utc::now(), } } fn sample_fields() -> Vec { let key = [0x42u8; 32]; let enc = crypto::encrypt_json(&key, &json!("abc123")).unwrap(); vec![SecretField { id: Uuid::nil(), entry_id: Uuid::nil(), field_name: "token".to_string(), encrypted: enc, version: 1, created_at: Utc::now(), updated_at: Utc::now(), }] } #[test] fn rejects_secret_field_extraction() { let fields = vec!["secret.token".to_string()]; let err = validate_safe_search_args(&fields).unwrap_err(); assert!(err.to_string().contains("sensitive")); } #[test] fn to_json_full_includes_secrets_schema() { let entry = sample_entry(); let fields = sample_fields(); let v = to_json(&entry, false, Some(&fields)); let secrets = v.get("secrets").unwrap().as_array().unwrap(); assert_eq!(secrets.len(), 1); assert_eq!(secrets[0]["field_name"], "token"); } #[test] fn to_json_summary_omits_secrets_schema() { let entry = sample_entry(); let fields = sample_fields(); let v = to_json(&entry, true, Some(&fields)); assert!(v.get("secrets").is_none()); } }