Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m34s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m15s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- Drop field_type, value_len from secrets and secrets_history tables - Remove infer_field_type, compute_value_len from add.rs - Simplify search output to field names only - Update AGENTS.md, README.md documentation Bump version to 0.9.4 Made-with: Cursor
294 lines
9.5 KiB
Rust
294 lines
9.5 KiB
Rust
use anyhow::Result;
|
|
use serde_json::{Map, Value, json};
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
use super::add::{
|
|
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
|
|
parse_kv, remove_path,
|
|
};
|
|
use crate::crypto;
|
|
use crate::db;
|
|
use crate::models::EntryRow;
|
|
use crate::output::{OutputMode, print_json};
|
|
|
|
pub struct UpdateArgs<'a> {
|
|
pub namespace: &'a str,
|
|
pub kind: &'a str,
|
|
pub name: &'a str,
|
|
pub add_tags: &'a [String],
|
|
pub remove_tags: &'a [String],
|
|
pub meta_entries: &'a [String],
|
|
pub remove_meta: &'a [String],
|
|
pub secret_entries: &'a [String],
|
|
pub remove_secrets: &'a [String],
|
|
pub output: OutputMode,
|
|
}
|
|
|
|
pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
|
|
let mut tx = pool.begin().await?;
|
|
|
|
let row: Option<EntryRow> = sqlx::query_as(
|
|
"SELECT id, version, tags, metadata \
|
|
FROM entries \
|
|
WHERE namespace = $1 AND kind = $2 AND name = $3 \
|
|
FOR UPDATE",
|
|
)
|
|
.bind(args.namespace)
|
|
.bind(args.kind)
|
|
.bind(args.name)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
let row = row.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"Not found: [{}/{}] {}. Use `add` to create it first.",
|
|
args.namespace,
|
|
args.kind,
|
|
args.name
|
|
)
|
|
})?;
|
|
|
|
// Snapshot current entry state before modifying.
|
|
if let Err(e) = db::snapshot_entry_history(
|
|
&mut tx,
|
|
db::EntrySnapshotParams {
|
|
entry_id: row.id,
|
|
namespace: args.namespace,
|
|
kind: args.kind,
|
|
name: args.name,
|
|
version: row.version,
|
|
action: "update",
|
|
tags: &row.tags,
|
|
metadata: &row.metadata,
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(error = %e, "failed to snapshot entry history before update");
|
|
}
|
|
|
|
// ── Merge tags ────────────────────────────────────────────────────────────
|
|
let mut tags: Vec<String> = row.tags;
|
|
for t in args.add_tags {
|
|
if !tags.contains(t) {
|
|
tags.push(t.clone());
|
|
}
|
|
}
|
|
tags.retain(|t| !args.remove_tags.contains(t));
|
|
|
|
// ── Merge metadata ────────────────────────────────────────────────────────
|
|
let mut meta_map: Map<String, Value> = match row.metadata {
|
|
Value::Object(m) => m,
|
|
_ => Map::new(),
|
|
};
|
|
for entry in args.meta_entries {
|
|
let (path, value) = parse_kv(entry)?;
|
|
insert_path(&mut meta_map, &path, value)?;
|
|
}
|
|
for key in args.remove_meta {
|
|
let path = parse_key_path(key)?;
|
|
remove_path(&mut meta_map, &path)?;
|
|
}
|
|
let metadata = Value::Object(meta_map);
|
|
|
|
// CAS update of the entry row.
|
|
let result = sqlx::query(
|
|
"UPDATE entries \
|
|
SET tags = $1, metadata = $2, version = version + 1, updated_at = NOW() \
|
|
WHERE id = $3 AND version = $4",
|
|
)
|
|
.bind(&tags)
|
|
.bind(&metadata)
|
|
.bind(row.id)
|
|
.bind(row.version)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
if result.rows_affected() == 0 {
|
|
tx.rollback().await?;
|
|
anyhow::bail!(
|
|
"Concurrent modification detected for [{}/{}] {}. Please retry.",
|
|
args.namespace,
|
|
args.kind,
|
|
args.name
|
|
);
|
|
}
|
|
|
|
let new_version = row.version + 1;
|
|
|
|
// ── Update secret fields ──────────────────────────────────────────────────
|
|
for entry in args.secret_entries {
|
|
let (path, field_value) = parse_kv(entry)?;
|
|
|
|
// For nested paths (e.g. credentials:type), flatten into dot-separated names
|
|
// and treat the sub-value as the individual field to store.
|
|
let flat = flatten_json_fields("", &{
|
|
let mut m = Map::new();
|
|
insert_path(&mut m, &path, field_value)?;
|
|
Value::Object(m)
|
|
});
|
|
|
|
for (field_name, fv) in &flat {
|
|
let encrypted = crypto::encrypt_json(master_key, fv)?;
|
|
|
|
// Snapshot existing field before replacing.
|
|
#[derive(sqlx::FromRow)]
|
|
struct ExistingField {
|
|
id: Uuid,
|
|
encrypted: Vec<u8>,
|
|
}
|
|
let existing_field: Option<ExistingField> = sqlx::query_as(
|
|
"SELECT id, encrypted \
|
|
FROM secrets WHERE entry_id = $1 AND field_name = $2",
|
|
)
|
|
.bind(row.id)
|
|
.bind(field_name)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
if let Some(ef) = &existing_field
|
|
&& let Err(e) = db::snapshot_secret_history(
|
|
&mut tx,
|
|
db::SecretSnapshotParams {
|
|
entry_id: row.id,
|
|
secret_id: ef.id,
|
|
entry_version: row.version,
|
|
field_name,
|
|
encrypted: &ef.encrypted,
|
|
action: "update",
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(error = %e, "failed to snapshot secret field history");
|
|
}
|
|
|
|
sqlx::query(
|
|
"INSERT INTO secrets (entry_id, field_name, encrypted) \
|
|
VALUES ($1, $2, $3) \
|
|
ON CONFLICT (entry_id, field_name) DO UPDATE SET \
|
|
encrypted = EXCLUDED.encrypted, \
|
|
version = secrets.version + 1, \
|
|
updated_at = NOW()",
|
|
)
|
|
.bind(row.id)
|
|
.bind(field_name)
|
|
.bind(&encrypted)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
// ── Remove secret fields ──────────────────────────────────────────────────
|
|
for key in args.remove_secrets {
|
|
let path = parse_key_path(key)?;
|
|
// Dot-join the path to match flattened field_name storage.
|
|
let field_name = path.join(".");
|
|
|
|
// Snapshot before delete.
|
|
#[derive(sqlx::FromRow)]
|
|
struct FieldToDelete {
|
|
id: Uuid,
|
|
encrypted: Vec<u8>,
|
|
}
|
|
let field: Option<FieldToDelete> = sqlx::query_as(
|
|
"SELECT id, encrypted \
|
|
FROM secrets WHERE entry_id = $1 AND field_name = $2",
|
|
)
|
|
.bind(row.id)
|
|
.bind(&field_name)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
if let Some(f) = field {
|
|
if let Err(e) = db::snapshot_secret_history(
|
|
&mut tx,
|
|
db::SecretSnapshotParams {
|
|
entry_id: row.id,
|
|
secret_id: f.id,
|
|
entry_version: new_version,
|
|
field_name: &field_name,
|
|
encrypted: &f.encrypted,
|
|
action: "delete",
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(error = %e, "failed to snapshot secret field history before delete");
|
|
}
|
|
|
|
sqlx::query("DELETE FROM secrets WHERE id = $1")
|
|
.bind(f.id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
let meta_keys = collect_key_paths(args.meta_entries)?;
|
|
let remove_meta_keys = collect_field_paths(args.remove_meta)?;
|
|
let secret_keys = collect_key_paths(args.secret_entries)?;
|
|
let remove_secret_keys = collect_field_paths(args.remove_secrets)?;
|
|
|
|
crate::audit::log_tx(
|
|
&mut tx,
|
|
"update",
|
|
args.namespace,
|
|
args.kind,
|
|
args.name,
|
|
json!({
|
|
"add_tags": args.add_tags,
|
|
"remove_tags": args.remove_tags,
|
|
"meta_keys": meta_keys,
|
|
"remove_meta": remove_meta_keys,
|
|
"secret_keys": secret_keys,
|
|
"remove_secrets": remove_secret_keys,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
tx.commit().await?;
|
|
|
|
let result_json = json!({
|
|
"action": "updated",
|
|
"namespace": args.namespace,
|
|
"kind": args.kind,
|
|
"name": args.name,
|
|
"add_tags": args.add_tags,
|
|
"remove_tags": args.remove_tags,
|
|
"meta_keys": meta_keys,
|
|
"remove_meta": remove_meta_keys,
|
|
"secret_keys": secret_keys,
|
|
"remove_secrets": remove_secret_keys,
|
|
});
|
|
|
|
match args.output {
|
|
OutputMode::Json | OutputMode::JsonCompact => {
|
|
print_json(&result_json, &args.output)?;
|
|
}
|
|
_ => {
|
|
println!("Updated: [{}/{}] {}", args.namespace, args.kind, args.name);
|
|
if !args.add_tags.is_empty() {
|
|
println!(" +tags: {}", args.add_tags.join(", "));
|
|
}
|
|
if !args.remove_tags.is_empty() {
|
|
println!(" -tags: {}", args.remove_tags.join(", "));
|
|
}
|
|
if !args.meta_entries.is_empty() {
|
|
println!(" +metadata: {}", meta_keys.join(", "));
|
|
}
|
|
if !args.remove_meta.is_empty() {
|
|
println!(" -metadata: {}", remove_meta_keys.join(", "));
|
|
}
|
|
if !args.secret_entries.is_empty() {
|
|
println!(" +secrets: {}", secret_keys.join(", "));
|
|
}
|
|
if !args.remove_secrets.is_empty() {
|
|
println!(" -secrets: {}", remove_secret_keys.join(", "));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|