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

- 写路径事务化: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:
voson
2026-03-19 10:30:45 +08:00
parent 31b0ea9bf1
commit a765dcc428
16 changed files with 1247 additions and 196 deletions

View File

@@ -4,13 +4,30 @@ use sqlx::PgPool;
use std::fs;
use crate::crypto;
use crate::db;
use crate::output::OutputMode;
/// Parse "key=value" entries. Value starting with '@' reads from file.
pub(crate) fn parse_kv(entry: &str) -> Result<(String, String)> {
/// Parse "key=value" or "key:=<json>" entries.
/// - `key=value` → stores the literal string `value`
/// - `key:=<json>` → parses `<json>` as a typed JSON value (number, bool, null, array, object)
/// - `value=@file` → reads the file content as a string (only for `=` form)
pub(crate) fn parse_kv(entry: &str) -> Result<(String, Value)> {
// Typed JSON form: key:=<json>
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((key.to_string(), val));
}
// Plain string form: key=value or key=@file
let (key, raw_val) = entry.split_once('=').ok_or_else(|| {
anyhow::anyhow!(
"Invalid format '{}'. Expected: key=value or key=@file",
"Invalid format '{}'. Expected: key=value, key=@file, or key:=<json>",
entry
)
})?;
@@ -22,14 +39,14 @@ pub(crate) fn parse_kv(entry: &str) -> Result<(String, String)> {
raw_val.to_string()
};
Ok((key.to_string(), value))
Ok((key.to_string(), Value::String(value)))
}
pub(crate) fn build_json(entries: &[String]) -> Result<Value> {
let mut map = Map::new();
for entry in entries {
let (key, value) = parse_kv(entry)?;
map.insert(key, Value::String(value));
map.insert(key, value);
}
Ok(Value::Object(map))
}
@@ -47,21 +64,72 @@ pub struct AddArgs<'a> {
pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let metadata = build_json(args.meta_entries)?;
let secret_json = build_json(args.secret_entries)?;
// Encrypt the secret JSON before storing
let encrypted_bytes = crypto::encrypt_json(master_key, &secret_json)?;
tracing::debug!(args.namespace, args.kind, args.name, "upserting record");
let meta_keys: Vec<&str> = args
.meta_entries
.iter()
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
.collect();
let secret_keys: Vec<&str> = args
.secret_entries
.iter()
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
.collect();
let mut tx = pool.begin().await?;
// Snapshot existing row into history before overwriting (if it exists).
#[derive(sqlx::FromRow)]
struct ExistingRow {
id: uuid::Uuid,
version: i64,
tags: Vec<String>,
metadata: serde_json::Value,
encrypted: Vec<u8>,
}
let existing: Option<ExistingRow> = sqlx::query_as(
"SELECT id, version, tags, metadata, encrypted FROM secrets \
WHERE namespace = $1 AND kind = $2 AND name = $3",
)
.bind(args.namespace)
.bind(args.kind)
.bind(args.name)
.fetch_optional(&mut *tx)
.await?;
if let Some(ex) = existing
&& let Err(e) = db::snapshot_history(
&mut tx,
db::SnapshotParams {
secret_id: ex.id,
namespace: args.namespace,
kind: args.kind,
name: args.name,
version: ex.version,
action: "add",
tags: &ex.tags,
metadata: &ex.metadata,
encrypted: &ex.encrypted,
},
)
.await
{
tracing::warn!(error = %e, "failed to snapshot history before upsert");
}
sqlx::query(
r#"
INSERT INTO secrets (namespace, kind, name, tags, metadata, encrypted, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
INSERT INTO secrets (namespace, kind, name, tags, metadata, encrypted, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
ON CONFLICT (namespace, kind, name)
DO UPDATE SET
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
encrypted = EXCLUDED.encrypted,
tags = EXCLUDED.tags,
metadata = EXCLUDED.metadata,
encrypted = EXCLUDED.encrypted,
version = secrets.version + 1,
updated_at = NOW()
"#,
)
@@ -71,22 +139,11 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
.bind(args.tags)
.bind(&metadata)
.bind(&encrypted_bytes)
.execute(pool)
.execute(&mut *tx)
.await?;
let meta_keys: Vec<&str> = args
.meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
let secret_keys: Vec<&str> = args
.secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
crate::audit::log(
pool,
crate::audit::log_tx(
&mut tx,
"add",
args.namespace,
args.kind,
@@ -99,6 +156,8 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
)
.await;
tx.commit().await?;
let result_json = json!({
"action": "added",
"namespace": args.namespace,