refactor: 代码审阅优化
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
This commit is contained in:
voson
2026-03-19 09:31:53 +08:00
parent dc0534cbc9
commit 31b0ea9bf1
11 changed files with 189 additions and 126 deletions

View File

@@ -1,43 +1,37 @@
use crate::config::{self, Config, config_path};
use anyhow::Result;
pub enum ConfigAction {
SetDb { url: String },
Show,
Path,
}
pub async fn run(action: ConfigAction) -> Result<()> {
pub async fn run(action: crate::ConfigAction) -> Result<()> {
match action {
ConfigAction::SetDb { url } => {
crate::ConfigAction::SetDb { url } => {
let cfg = Config {
database_url: Some(url.clone()),
};
config::save_config(&cfg)?;
println!("✓ 数据库连接串已保存到: {}", config_path().display());
println!("Database URL saved to: {}", config_path().display());
println!(" {}", mask_password(&url));
}
ConfigAction::Show => {
crate::ConfigAction::Show => {
let cfg = config::load_config()?;
match cfg.database_url {
Some(url) => {
println!("database_url = {}", mask_password(&url));
println!("配置文件: {}", config_path().display());
println!("config file: {}", config_path().display());
}
None => {
println!("未配置数据库连接串。");
println!("请运行:secrets config set-db <DATABASE_URL>");
println!("Database URL not configured.");
println!("Run: secrets config set-db <DATABASE_URL>");
}
}
}
ConfigAction::Path => {
crate::ConfigAction::Path => {
println!("{}", config_path().display());
}
}
Ok(())
}
/// postgres://user:password@host/db 中的密码替换为 ***
/// Mask the password in a postgres://user:password@host/db URL.
fn mask_password(url: &str) -> String {
if let Some(at_pos) = url.rfind('@')
&& let Some(scheme_end) = url.find("://")

View File

@@ -2,7 +2,15 @@ use anyhow::Result;
use serde_json::json;
use sqlx::PgPool;
pub async fn run(pool: &PgPool, namespace: &str, kind: &str, name: &str) -> Result<()> {
use crate::output::OutputMode;
pub async fn run(
pool: &PgPool,
namespace: &str,
kind: &str,
name: &str,
output: OutputMode,
) -> Result<()> {
tracing::debug!(namespace, kind, name, "deleting record");
let result =
@@ -15,10 +23,38 @@ pub async fn run(pool: &PgPool, namespace: &str, kind: &str, name: &str) -> Resu
if result.rows_affected() == 0 {
tracing::warn!(namespace, kind, name, "record not found for deletion");
println!("Not found: [{}/{}] {}", namespace, kind, name);
match output {
OutputMode::Json => println!(
"{}",
serde_json::to_string_pretty(
&json!({"action":"not_found","namespace":namespace,"kind":kind,"name":name})
)?
),
OutputMode::JsonCompact => println!(
"{}",
serde_json::to_string(
&json!({"action":"not_found","namespace":namespace,"kind":kind,"name":name})
)?
),
_ => println!("Not found: [{}/{}] {}", namespace, kind, name),
}
} else {
crate::audit::log(pool, "delete", namespace, kind, name, json!({})).await;
println!("Deleted: [{}/{}] {}", namespace, kind, name);
match output {
OutputMode::Json => println!(
"{}",
serde_json::to_string_pretty(
&json!({"action":"deleted","namespace":namespace,"kind":kind,"name":name})
)?
),
OutputMode::JsonCompact => println!(
"{}",
serde_json::to_string(
&json!({"action":"deleted","namespace":namespace,"kind":kind,"name":name})
)?
),
_ => println!("Deleted: [{}/{}] {}", namespace, kind, name),
}
}
Ok(())
}

View File

@@ -10,7 +10,7 @@ pub struct SearchArgs<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tag: Option<&'a str>,
pub tags: &'a [String],
pub query: Option<&'a str>,
pub show_secrets: bool,
pub fields: &'a [String],
@@ -37,13 +37,22 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
conditions.push(format!("name = ${}", idx));
idx += 1;
}
if args.tag.is_some() {
conditions.push(format!("tags @> ARRAY[${}]", 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} OR namespace ILIKE ${i} OR kind ILIKE ${i} OR metadata::text ILIKE ${i} OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i}))",
"(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;
@@ -81,11 +90,16 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
if let Some(v) = args.name {
q = q.bind(v);
}
if let Some(v) = args.tag {
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));
q = q.bind(format!(
"%{}%",
v.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
));
}
q = q.bind(args.limit as i64).bind(args.offset as i64);
@@ -186,7 +200,7 @@ fn to_json(
Err(e) => json!({"_error": e.to_string()}),
}
} else {
json!({"_encrypted": true, "_key_count": encrypted_key_count(row, master_key)})
json!({"_encrypted": true})
};
json!({
@@ -201,19 +215,6 @@ fn to_json(
"updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
}
/// Return the number of keys in the encrypted JSON (decrypts to count; 0 on failure).
fn encrypted_key_count(row: &Secret, master_key: Option<&[u8; 32]>) -> usize {
if row.encrypted.is_empty() {
return 0;
}
let Some(key) = master_key else { return 0 };
match crypto::decrypt_json(key, &row.encrypted) {
Ok(Value::Object(m)) => m.len(),
_ => 0,
}
}
fn print_text(
row: &Secret,
show_secrets: bool,

View File

@@ -5,6 +5,7 @@ use uuid::Uuid;
use super::add::parse_kv;
use crate::crypto;
use crate::output::OutputMode;
#[derive(FromRow)]
struct UpdateRow {
@@ -24,6 +25,7 @@ pub struct UpdateArgs<'a> {
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<()> {
@@ -141,35 +143,47 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
)
.await;
println!("Updated: [{}/{}] {}", args.namespace, args.kind, args.name);
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": args.remove_meta,
"secret_keys": secret_keys,
"remove_secrets": args.remove_secrets,
});
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() {
let keys: Vec<&str> = args
.meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" +metadata: {}", keys.join(", "));
}
if !args.remove_meta.is_empty() {
println!(" -metadata: {}", args.remove_meta.join(", "));
}
if !args.secret_entries.is_empty() {
let keys: Vec<&str> = args
.secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" +secrets: {}", keys.join(", "));
}
if !args.remove_secrets.is_empty() {
println!(" -secrets: {}", args.remove_secrets.join(", "));
match args.output {
OutputMode::Json => {
println!("{}", serde_json::to_string_pretty(&result_json)?);
}
OutputMode::JsonCompact => {
println!("{}", serde_json::to_string(&result_json)?);
}
_ => {
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: {}", args.remove_meta.join(", "));
}
if !args.secret_entries.is_empty() {
println!(" +secrets: {}", secret_keys.join(", "));
}
if !args.remove_secrets.is_empty() {
println!(" -secrets: {}", args.remove_secrets.join(", "));
}
}
}
Ok(())