release(secrets-mcp): 0.5.4 — Web 分页修正与 hex 解码;批量删除上限;MCP @ 路径检测
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 6s

This commit is contained in:
voson
2026-04-05 11:48:40 +08:00
parent 1860cce86c
commit 9d6ac5c13a
11 changed files with 92 additions and 32 deletions

View File

@@ -12,6 +12,7 @@ aes-gcm.workspace = true
anyhow.workspace = true
thiserror.workspace = true
chrono.workspace = true
hex = "0.4"
rand.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -61,7 +61,7 @@ pub fn decrypt_json(master_key: &[u8; 32], data: &[u8]) -> Result<Value> {
/// Parse a 64-char hex string (from X-Encryption-Key header) into a 32-byte key.
pub fn extract_key_from_hex(hex_str: &str) -> Result<[u8; 32]> {
let bytes = hex::decode_hex(hex_str.trim())?;
let bytes = ::hex::decode(hex_str.trim())?;
if bytes.len() != 32 {
bail!(
"X-Encryption-Key must be 64 hex chars (32 bytes), got {} bytes",
@@ -76,21 +76,14 @@ pub fn extract_key_from_hex(hex_str: &str) -> Result<[u8; 32]> {
// ─── Public hex helpers ───────────────────────────────────────────────────────
pub mod hex {
use anyhow::{Result, bail};
use anyhow::Result;
pub fn encode_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
pub fn decode_hex(s: &str) -> Result<Vec<u8>> {
let s = s.trim();
if !s.len().is_multiple_of(2) {
bail!("hex string has odd length");
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow::anyhow!("{}", e)))
.collect()
Ok(::hex::decode(s.trim())?)
}
}

View File

@@ -243,6 +243,11 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
tracing::warn!(error = %e, "failed to snapshot entry history before upsert");
}
// Upsert the entry row. On conflict (existing entry with same user_id+folder+name),
// the entry columns are replaced wholesale. The old secret associations are torn down
// below within the same transaction, so the whole operation is atomic: if any step
// after this point fails, the transaction rolls back and the entry reverts to its
// pre-upsert state (including the version bump that happened in the DO UPDATE clause).
let entry_id: Uuid = if let Some(uid) = params.user_id {
sqlx::query_scalar(
r#"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata, version, updated_at)

View File

@@ -11,6 +11,7 @@ pub async fn list_for_user(
offset: i64,
) -> Result<Vec<AuditLogEntry>> {
let limit = limit.clamp(1, 200);
let offset = offset.max(0);
let rows = sqlx::query_as(
"SELECT id, user_id, action, folder, type, name, detail, created_at \

View File

@@ -31,6 +31,10 @@ pub struct DeleteParams<'a> {
pub user_id: Option<Uuid>,
}
/// Maximum number of entries that can be deleted in a single bulk operation.
/// Prevents accidental mass deletion when filters are too broad.
pub const MAX_BULK_DELETE: usize = 1000;
/// Delete a single entry by id (multi-tenant: `user_id` must match).
pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<DeleteResult> {
let mut tx = pool.begin().await?;
@@ -374,6 +378,16 @@ async fn delete_bulk(
}
let rows = q.fetch_all(&mut *tx).await?;
if rows.len() > MAX_BULK_DELETE {
tx.rollback().await?;
anyhow::bail!(
"Bulk delete would affect {} entries (limit: {}). \
Narrow your filters or delete entries individually.",
rows.len(),
MAX_BULK_DELETE,
);
}
let mut deleted = Vec::with_capacity(rows.len());
for row in &rows {
let entry_row: EntryRow = EntryRow {

View File

@@ -402,8 +402,8 @@ pub async fn run(
&mut tx,
params.user_id,
"update",
"",
"",
&row.folder,
&row.entry_type,
params.name,
serde_json::json!({
"add_tags": params.add_tags,