Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m42s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m18s
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Failing after 7m40s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
P0: - fix(config): config_dir 使用 home_dir 回退,避免 ~ 不展开 - fix(search): 模糊查询转义 LIKE 通配符 % 和 _ P1: - chore(db): 连接池添加 acquire_timeout 10s - refactor(update): 消除 meta_keys/secret_keys 重复计算 P2: - refactor(config): 合并 ConfigAction 枚举 - chore(deps): 移除 clap/env、uuid/v4 无用 features - perf(main): delete 命令跳过 master_key 加载 - i18n(config): 统一错误消息为英文 - perf(search): show_secrets=false 时不再解密获取 key_count - feat(delete,update): 支持 -o json/json-compact 输出 P3: - feat(search): --tag 支持多值交叉过滤 docs: 将 seed-data.sh 替换为 setup-gitea-actions.sh Made-with: Cursor
346 lines
10 KiB
Rust
346 lines
10 KiB
Rust
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<String> = 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<String> = 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<Value> = 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<Value> {
|
|
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<Value> = 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<String> {
|
|
let (section, key) = field.split_once('.').ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"Invalid field path '{}'. Use metadata.<key> or secret.<key>",
|
|
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
|
|
)
|
|
})
|
|
}
|