use anyhow::Result; use serde_json::{Value, json}; use sqlx::PgPool; use crate::crypto; use crate::models::Secret; use crate::output::OutputMode; 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 show_secrets: bool, 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<'_>, master_key: Option<&[u8; 32]>) -> Result<()> { let mut conditions: Vec = Vec::new(); let mut idx: i32 = 1; if args.namespace.is_some() { conditions.push(format!("namespace = ${}", idx)); idx += 1; } if args.kind.is_some() { conditions.push(format!("kind = ${}", idx)); idx += 1; } if args.name.is_some() { conditions.push(format!("name = ${}", idx)); idx += 1; } if !args.tags.is_empty() { // Use PostgreSQL array containment: tags @> ARRAY[$n, $m, ...] means all specified tags must be present let placeholders: Vec = args .tags .iter() .map(|_| { let p = format!("${}", idx); idx += 1; p }) .collect(); conditions.push(format!("tags @> ARRAY[{}]", placeholders.join(", "))); } if args.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 args.sort { "updated" => "updated_at DESC", "created" => "created_at DESC", _ => "namespace, kind, name", }; let sql = format!( "SELECT * FROM secrets {} ORDER BY {} LIMIT ${} OFFSET ${}", where_clause, order, idx, idx + 1 ); tracing::debug!(sql, "executing search query"); let mut q = sqlx::query_as::<_, Secret>(&sql); if let Some(v) = args.namespace { q = q.bind(v); } if let Some(v) = args.kind { q = q.bind(v); } if let Some(v) = args.name { q = q.bind(v); } for v in args.tags { q = q.bind(v.as_str()); } if let Some(v) = args.query { q = q.bind(format!( "%{}%", v.replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_") )); } q = q.bind(args.limit as i64).bind(args.offset as i64); let rows = q.fetch_all(pool).await?; // -f/--field: extract specific field values directly if !args.fields.is_empty() { return print_fields(&rows, args.fields, master_key); } match args.output { OutputMode::Json | OutputMode::JsonCompact => { let arr: Vec = rows .iter() .map(|r| to_json(r, args.show_secrets, args.summary, master_key)) .collect(); let out = if args.output == OutputMode::Json { serde_json::to_string_pretty(&arr)? } else { serde_json::to_string(&arr)? }; println!("{}", out); } OutputMode::Env => { if rows.len() > 1 { anyhow::bail!( "env output requires exactly one record; got {}. Add more filters.", rows.len() ); } if let Some(row) = rows.first() { print_env(row, args.show_secrets, master_key)?; } else { eprintln!("No records found."); } } OutputMode::Text => { if rows.is_empty() { println!("No records found."); return Ok(()); } for row in &rows { print_text(row, args.show_secrets, args.summary, master_key)?; } 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(()) } /// Decrypt the encrypted blob for a row. Returns an empty object on empty blobs. /// Returns an error value on decrypt failure (so callers can decide how to handle). fn try_decrypt(row: &Secret, master_key: Option<&[u8; 32]>) -> Result { if row.encrypted.is_empty() { return Ok(Value::Object(Default::default())); } let key = master_key.ok_or_else(|| { anyhow::anyhow!("master key required to decrypt secrets (run `secrets init`)") })?; crypto::decrypt_json(key, &row.encrypted) } fn to_json( row: &Secret, show_secrets: bool, summary: bool, master_key: Option<&[u8; 32]>, ) -> Value { if summary { let desc = row .metadata .get("desc") .or_else(|| row.metadata.get("url")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); return json!({ "namespace": row.namespace, "kind": row.kind, "name": row.name, "tags": row.tags, "desc": desc, "updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), }); } let secrets_val = if show_secrets { match try_decrypt(row, master_key) { Ok(v) => v, Err(e) => json!({"_error": e.to_string()}), } } else { json!({"_encrypted": true}) }; json!({ "id": row.id, "namespace": row.namespace, "kind": row.kind, "name": row.name, "tags": row.tags, "metadata": row.metadata, "secrets": secrets_val, "created_at": row.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(), }) } fn print_text( row: &Secret, show_secrets: bool, summary: bool, master_key: Option<&[u8; 32]>, ) -> Result<()> { println!("[{}/{}] {}", row.namespace, row.kind, row.name); if summary { let desc = row .metadata .get("desc") .or_else(|| row.metadata.get("url")) .and_then(|v| v.as_str()) .unwrap_or("-"); if !row.tags.is_empty() { println!(" tags: [{}]", row.tags.join(", ")); } println!(" desc: {}", desc); println!( " updated: {}", row.updated_at.format("%Y-%m-%d %H:%M:%S UTC") ); } else { println!(" id: {}", row.id); if !row.tags.is_empty() { println!(" tags: [{}]", row.tags.join(", ")); } if row.metadata.as_object().is_some_and(|m| !m.is_empty()) { println!( " metadata: {}", serde_json::to_string_pretty(&row.metadata)? ); } if !row.encrypted.is_empty() { if show_secrets { match try_decrypt(row, master_key) { Ok(v) => println!(" secrets: {}", serde_json::to_string_pretty(&v)?), Err(e) => println!(" secrets: [decrypt error: {}]", e), } } else { println!(" secrets: [encrypted] (--show-secrets to reveal)"); } } println!( " created: {}", row.created_at.format("%Y-%m-%d %H:%M:%S UTC") ); } println!(); Ok(()) } fn print_env(row: &Secret, show_secrets: bool, master_key: Option<&[u8; 32]>) -> Result<()> { let prefix = row.name.to_uppercase().replace(['-', '.'], "_"); if let Some(meta) = row.metadata.as_object() { for (k, v) in meta { let key = format!("{}_{}", prefix, k.to_uppercase().replace('-', "_")); println!("{}={}", key, v.as_str().unwrap_or(&v.to_string())); } } if show_secrets { let decrypted = try_decrypt(row, master_key)?; if let Some(enc) = decrypted.as_object() { for (k, v) in enc { let key = format!("{}_{}", prefix, k.to_uppercase().replace('-', "_")); println!("{}={}", key, v.as_str().unwrap_or(&v.to_string())); } } } Ok(()) } /// Extract one or more field paths like `metadata.url` or `secret.token`. fn print_fields(rows: &[Secret], fields: &[String], master_key: Option<&[u8; 32]>) -> Result<()> { for row in rows { // Decrypt once per row if any field requires it let decrypted: Option = if fields .iter() .any(|f| f.starts_with("secret") || f.starts_with("encrypted")) { Some(try_decrypt(row, master_key)?) } else { None }; for field in fields { let val = extract_field(row, field, decrypted.as_ref())?; println!("{}", val); } } Ok(()) } fn extract_field(row: &Secret, field: &str, decrypted: Option<&Value>) -> Result { let (section, key) = field.split_once('.').ok_or_else(|| { anyhow::anyhow!( "Invalid field path '{}'. Use metadata. or secret.", field ) })?; let obj = match section { "metadata" | "meta" => &row.metadata, "secret" | "secrets" | "encrypted" => { decrypted.ok_or_else(|| anyhow::anyhow!("secret field requires master key"))? } other => anyhow::bail!( "Unknown field section '{}'. Use 'metadata' or 'secret'", 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, row.namespace, row.kind, row.name ) }) }