feat: 0.6.0 — 事务/版本化/类型化/inject/run
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m37s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 37s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 50s
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 2s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m37s
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Successful in 37s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 50s
Secrets CLI - Build & Release / 发布草稿 Release (push) Successful in 2s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- 写路径事务化:add/update/delete 与 audit 同事务,update CAS 并发保护 - 版本化与回滚:secrets_history 表、version 字段、history/rollback 命令 - 类型化字段:key:=<json> 支持数字、布尔、数组、对象 - 临时 env 模式:inject 输出 KEY=VALUE,run 向子进程注入 - inject/run 至少需一个过滤条件;search -o env 使用 shell_quote;JSON 输出含 version Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::models::Secret;
|
||||
@@ -22,88 +23,20 @@ pub struct SearchArgs<'a> {
|
||||
}
|
||||
|
||||
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?;
|
||||
let rows = fetch_rows_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 field values directly
|
||||
if !args.fields.is_empty() {
|
||||
@@ -131,7 +64,12 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
|
||||
);
|
||||
}
|
||||
if let Some(row) = rows.first() {
|
||||
print_env(row, args.show_secrets, master_key)?;
|
||||
let map = build_env_map(row, "", master_key)?;
|
||||
let mut pairs: Vec<(String, String)> = map.into_iter().collect();
|
||||
pairs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
for (k, v) in pairs {
|
||||
println!("{}={}", k, shell_quote(&v));
|
||||
}
|
||||
} else {
|
||||
eprintln!("No records found.");
|
||||
}
|
||||
@@ -158,8 +96,195 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch rows with simple equality/tag filters (no pagination). Used by inject/run.
|
||||
pub async fn fetch_rows(
|
||||
pool: &PgPool,
|
||||
namespace: Option<&str>,
|
||||
kind: Option<&str>,
|
||||
name: Option<&str>,
|
||||
tags: &[String],
|
||||
query: Option<&str>,
|
||||
) -> Result<Vec<Secret>> {
|
||||
fetch_rows_paged(
|
||||
pool,
|
||||
PagedFetchArgs {
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
tags,
|
||||
query,
|
||||
sort: "name",
|
||||
limit: 200,
|
||||
offset: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Arguments for the internal paged fetch. Grouped to avoid too-many-arguments lint.
|
||||
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,
|
||||
}
|
||||
|
||||
async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Secret>> {
|
||||
let mut conditions: Vec<String> = 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<String> = 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 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) = 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);
|
||||
|
||||
let rows = q.fetch_all(pool).await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Build a flat `KEY=VALUE` map from a record's metadata and decrypted secrets.
|
||||
/// Variable names: `<PREFIX><NAME>_<FIELD>` (all uppercased, hyphens/dots → underscores).
|
||||
/// If `prefix` is empty, the name segment alone is used as the prefix.
|
||||
pub fn build_env_map(
|
||||
row: &Secret,
|
||||
prefix: &str,
|
||||
master_key: Option<&[u8; 32]>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let name_part = row.name.to_uppercase().replace(['-', '.', ' '], "_");
|
||||
let effective_prefix = if prefix.is_empty() {
|
||||
name_part
|
||||
} else {
|
||||
format!(
|
||||
"{}_{}",
|
||||
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
|
||||
name_part
|
||||
)
|
||||
};
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
if let Some(meta) = row.metadata.as_object() {
|
||||
for (k, v) in meta {
|
||||
let key = format!(
|
||||
"{}_{}",
|
||||
effective_prefix,
|
||||
k.to_uppercase().replace(['-', '.'], "_")
|
||||
);
|
||||
map.insert(key, json_value_to_env_string(v));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(master_key) = master_key
|
||||
&& !row.encrypted.is_empty()
|
||||
{
|
||||
let decrypted = crypto::decrypt_json(master_key, &row.encrypted)?;
|
||||
if let Some(enc) = decrypted.as_object() {
|
||||
for (k, v) in enc {
|
||||
let key = format!(
|
||||
"{}_{}",
|
||||
effective_prefix,
|
||||
k.to_uppercase().replace(['-', '.'], "_")
|
||||
);
|
||||
map.insert(key, json_value_to_env_string(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Quote a value for safe shell / env output. Wraps in single quotes,
|
||||
/// escaping any single quotes within the value.
|
||||
fn shell_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
/// Convert a JSON value to its string representation suitable for env vars.
|
||||
fn json_value_to_env_string(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()));
|
||||
@@ -211,10 +336,12 @@ fn to_json(
|
||||
"tags": row.tags,
|
||||
"metadata": row.metadata,
|
||||
"secrets": secrets_val,
|
||||
"version": row.version,
|
||||
"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,
|
||||
@@ -267,30 +394,9 @@ fn print_text(
|
||||
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"))
|
||||
|
||||
Reference in New Issue
Block a user