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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user