refactor: entries + secrets 双表,search 展示 field schema,key_ref PEM 共享
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m57s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 51s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m6s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Some checks failed
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m57s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 51s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m6s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- secrets 表拆为 entries(主表)+ secrets(每字段一行) - search 无需 master_key 即可展示 secrets 字段名、类型、长度 - inject/run 支持 metadata.key_ref 引用 kind=key 记录,PEM 轮换 O(1) - entries_history + secrets_history 字段级历史,rollback 按 version 恢复 - 移除迁移用 DROP 语句,migrate 幂等 - v0.8.0 Made-with: Cursor
This commit is contained in:
@@ -4,7 +4,7 @@ use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::models::Secret;
|
||||
use crate::models::{Entry, SecretField};
|
||||
use crate::output::{OutputMode, format_local_time};
|
||||
|
||||
pub struct SearchArgs<'a> {
|
||||
@@ -13,7 +13,6 @@ pub struct SearchArgs<'a> {
|
||||
pub name: Option<&'a str>,
|
||||
pub tags: &'a [String],
|
||||
pub query: Option<&'a str>,
|
||||
pub show_secrets: bool,
|
||||
pub fields: &'a [String],
|
||||
pub summary: bool,
|
||||
pub limit: u32,
|
||||
@@ -23,9 +22,9 @@ pub struct SearchArgs<'a> {
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
|
||||
validate_safe_search_args(args.show_secrets, args.fields)?;
|
||||
validate_safe_search_args(args.fields)?;
|
||||
|
||||
let rows = fetch_rows_paged(
|
||||
let rows = fetch_entries_paged(
|
||||
pool,
|
||||
PagedFetchArgs {
|
||||
namespace: args.namespace,
|
||||
@@ -40,14 +39,25 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// -f/--field: extract specific field values directly
|
||||
// -f/--field: extract specific metadata field values directly
|
||||
if !args.fields.is_empty() {
|
||||
return print_fields(&rows, args.fields);
|
||||
}
|
||||
|
||||
// Fetch secret schemas for all returned entries (no master key needed).
|
||||
let entry_ids: Vec<uuid::Uuid> = rows.iter().map(|r| r.id).collect();
|
||||
let schema_map = if !args.summary && !entry_ids.is_empty() {
|
||||
fetch_secret_schemas(pool, &entry_ids).await?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
match args.output {
|
||||
OutputMode::Json | OutputMode::JsonCompact => {
|
||||
let arr: Vec<Value> = rows.iter().map(|r| to_json(r, args.summary)).collect();
|
||||
let arr: Vec<Value> = rows
|
||||
.iter()
|
||||
.map(|r| to_json(r, args.summary, schema_map.get(&r.id).map(Vec::as_slice)))
|
||||
.collect();
|
||||
let out = if args.output == OutputMode::Json {
|
||||
serde_json::to_string_pretty(&arr)?
|
||||
} else {
|
||||
@@ -61,7 +71,11 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
for row in &rows {
|
||||
print_text(row, args.summary)?;
|
||||
print_text(
|
||||
row,
|
||||
args.summary,
|
||||
schema_map.get(&row.id).map(Vec::as_slice),
|
||||
)?;
|
||||
}
|
||||
println!("{} record(s) found.", rows.len());
|
||||
if rows.len() == args.limit as usize {
|
||||
@@ -77,20 +91,13 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_safe_search_args(show_secrets: bool, fields: &[String]) -> Result<()> {
|
||||
if show_secrets {
|
||||
anyhow::bail!(
|
||||
"`search` no longer reveals secrets. Use `secrets inject` or `secrets run` instead."
|
||||
);
|
||||
}
|
||||
|
||||
fn validate_safe_search_args(fields: &[String]) -> Result<()> {
|
||||
if let Some(field) = fields.iter().find(|field| is_secret_field(field)) {
|
||||
anyhow::bail!(
|
||||
"Field '{}' is sensitive. `search -f` only supports metadata.* fields; use `secrets inject` or `secrets run` for secrets.",
|
||||
field
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -101,16 +108,29 @@ fn is_secret_field(field: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Fetch rows with simple equality/tag filters (no pagination). Used by inject/run.
|
||||
pub async fn fetch_rows(
|
||||
// ── Entry fetching ────────────────────────────────────────────────────────────
|
||||
|
||||
struct PagedFetchArgs<'a> {
|
||||
namespace: Option<&'a str>,
|
||||
kind: Option<&'a str>,
|
||||
name: Option<&'a str>,
|
||||
tags: &'a [String],
|
||||
query: Option<&'a str>,
|
||||
sort: &'a str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
}
|
||||
|
||||
/// Fetch entries matching the given filters (used by search, inject, run).
|
||||
pub async fn fetch_entries(
|
||||
pool: &PgPool,
|
||||
namespace: Option<&str>,
|
||||
kind: Option<&str>,
|
||||
name: Option<&str>,
|
||||
tags: &[String],
|
||||
query: Option<&str>,
|
||||
) -> Result<Vec<Secret>> {
|
||||
fetch_rows_paged(
|
||||
) -> Result<Vec<Entry>> {
|
||||
fetch_entries_paged(
|
||||
pool,
|
||||
PagedFetchArgs {
|
||||
namespace,
|
||||
@@ -126,19 +146,7 @@ pub async fn fetch_rows(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Arguments for the internal paged fetch. Grouped to avoid too-many-arguments lint.
|
||||
struct PagedFetchArgs<'a> {
|
||||
namespace: Option<&'a str>,
|
||||
kind: Option<&'a str>,
|
||||
name: Option<&'a str>,
|
||||
tags: &'a [String],
|
||||
query: Option<&'a str>,
|
||||
sort: &'a str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
}
|
||||
|
||||
async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Secret>> {
|
||||
async fn fetch_entries_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Entry>> {
|
||||
let mut conditions: Vec<String> = Vec::new();
|
||||
let mut idx: i32 = 1;
|
||||
|
||||
@@ -187,7 +195,7 @@ async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Se
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT * FROM secrets {} ORDER BY {} LIMIT ${} OFFSET ${}",
|
||||
"SELECT * FROM entries {} ORDER BY {} LIMIT ${} OFFSET ${}",
|
||||
where_clause,
|
||||
order,
|
||||
idx,
|
||||
@@ -196,7 +204,7 @@ async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Se
|
||||
|
||||
tracing::debug!(sql, "executing search query");
|
||||
|
||||
let mut q = sqlx::query_as::<_, Secret>(&sql);
|
||||
let mut q = sqlx::query_as::<_, Entry>(&sql);
|
||||
if let Some(v) = a.namespace {
|
||||
q = q.bind(v);
|
||||
}
|
||||
@@ -219,12 +227,62 @@ async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Se
|
||||
}
|
||||
q = q.bind(a.limit as i64).bind(a.offset as i64);
|
||||
|
||||
let rows = q.fetch_all(pool).await?;
|
||||
Ok(rows)
|
||||
Ok(q.fetch_all(pool).await?)
|
||||
}
|
||||
|
||||
fn env_prefix(row: &Secret, prefix: &str) -> String {
|
||||
let name_part = row.name.to_uppercase().replace(['-', '.', ' '], "_");
|
||||
// ── Secret schema fetching (no master key) ───────────────────────────────────
|
||||
|
||||
/// Fetch secret field schemas (field_name, field_type, value_len) for a set of entry ids.
|
||||
/// Returns a map from entry_id to list of SecretField (encrypted field not used here).
|
||||
async fn fetch_secret_schemas(
|
||||
pool: &PgPool,
|
||||
entry_ids: &[uuid::Uuid],
|
||||
) -> Result<HashMap<uuid::Uuid, Vec<SecretField>>> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let fields: Vec<SecretField> = sqlx::query_as(
|
||||
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut map: HashMap<uuid::Uuid, Vec<SecretField>> = HashMap::new();
|
||||
for f in fields {
|
||||
map.entry(f.entry_id).or_default().push(f);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Fetch all secret fields (including encrypted bytes) for a set of entry ids.
|
||||
pub async fn fetch_secrets_for_entries(
|
||||
pool: &PgPool,
|
||||
entry_ids: &[uuid::Uuid],
|
||||
) -> Result<HashMap<uuid::Uuid, Vec<SecretField>>> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let fields: Vec<SecretField> = sqlx::query_as(
|
||||
"SELECT * FROM secrets WHERE entry_id = ANY($1) ORDER BY entry_id, field_name",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut map: HashMap<uuid::Uuid, Vec<SecretField>> = HashMap::new();
|
||||
for f in fields {
|
||||
map.entry(f.entry_id).or_default().push(f);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
// ── Display helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
fn env_prefix(entry: &Entry, prefix: &str) -> String {
|
||||
let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_");
|
||||
if prefix.is_empty() {
|
||||
name_part
|
||||
} else {
|
||||
@@ -236,15 +294,12 @@ fn env_prefix(row: &Secret, prefix: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a flat `KEY=VALUE` map from metadata only.
|
||||
/// Variable names: `<PREFIX><NAME>_<FIELD>` (all uppercased, hyphens/dots → underscores).
|
||||
/// If `prefix` is empty, the name segment alone is used as the prefix.
|
||||
pub fn build_metadata_env_map(row: &Secret, prefix: &str) -> HashMap<String, String> {
|
||||
let effective_prefix = env_prefix(row, prefix);
|
||||
|
||||
/// Build a flat KEY=VALUE map from metadata only (no master key required).
|
||||
pub fn build_metadata_env_map(entry: &Entry, prefix: &str) -> HashMap<String, String> {
|
||||
let effective_prefix = env_prefix(entry, prefix);
|
||||
let mut map = HashMap::new();
|
||||
|
||||
if let Some(meta) = row.metadata.as_object() {
|
||||
if let Some(meta) = entry.metadata.as_object() {
|
||||
for (k, v) in meta {
|
||||
let key = format!(
|
||||
"{}_{}",
|
||||
@@ -254,37 +309,68 @@ pub fn build_metadata_env_map(row: &Secret, prefix: &str) -> HashMap<String, Str
|
||||
map.insert(key, json_value_to_env_string(v));
|
||||
}
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
/// Build a flat `KEY=VALUE` map from metadata and decrypted secrets.
|
||||
pub fn build_injected_env_map(
|
||||
row: &Secret,
|
||||
/// Build a flat KEY=VALUE map from metadata + decrypted secret fields.
|
||||
/// Resolves key_ref: if metadata.key_ref is set, merges secret fields from that key entry.
|
||||
pub async fn build_injected_env_map(
|
||||
pool: &PgPool,
|
||||
entry: &Entry,
|
||||
prefix: &str,
|
||||
master_key: &[u8; 32],
|
||||
fields: &[SecretField],
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let effective_prefix = env_prefix(row, prefix);
|
||||
let mut map = build_metadata_env_map(row, prefix);
|
||||
let effective_prefix = env_prefix(entry, prefix);
|
||||
let mut map = build_metadata_env_map(entry, prefix);
|
||||
|
||||
if !row.encrypted.is_empty() {
|
||||
let decrypted = crypto::decrypt_json(master_key, &row.encrypted)?;
|
||||
if let Some(enc) = decrypted.as_object() {
|
||||
for (k, v) in enc {
|
||||
let key = format!(
|
||||
// Decrypt each secret field and add to env map.
|
||||
for f in fields {
|
||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
||||
let key = format!(
|
||||
"{}_{}",
|
||||
effective_prefix,
|
||||
f.field_name.to_uppercase().replace(['-', '.'], "_")
|
||||
);
|
||||
map.insert(key, json_value_to_env_string(&decrypted));
|
||||
}
|
||||
|
||||
// Resolve key_ref: merge secrets from the referenced key entry.
|
||||
if let Some(key_ref) = entry.metadata.get("key_ref").and_then(|v| v.as_str()) {
|
||||
let key_entries = fetch_entries(
|
||||
pool,
|
||||
Some(&entry.namespace),
|
||||
Some("key"),
|
||||
Some(key_ref),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(key_entry) = key_entries.first() {
|
||||
let key_ids = vec![key_entry.id];
|
||||
let key_fields_map = fetch_secrets_for_entries(pool, &key_ids).await?;
|
||||
let empty = vec![];
|
||||
let key_fields = key_fields_map.get(&key_entry.id).unwrap_or(&empty);
|
||||
|
||||
let key_prefix = env_prefix(key_entry, prefix);
|
||||
for f in key_fields {
|
||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
||||
let key_var = format!(
|
||||
"{}_{}",
|
||||
effective_prefix,
|
||||
k.to_uppercase().replace(['-', '.'], "_")
|
||||
key_prefix,
|
||||
f.field_name.to_uppercase().replace(['-', '.'], "_")
|
||||
);
|
||||
map.insert(key, json_value_to_env_string(v));
|
||||
map.insert(key_var, json_value_to_env_string(&decrypted));
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(key_ref, "key_ref target not found");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Convert a JSON value to its string representation suitable for env vars.
|
||||
fn json_value_to_env_string(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
@@ -293,81 +379,101 @@ fn json_value_to_env_string(v: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn to_json(row: &Secret, summary: bool) -> Value {
|
||||
fn to_json(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> Value {
|
||||
if summary {
|
||||
let desc = row
|
||||
let desc = entry
|
||||
.metadata
|
||||
.get("desc")
|
||||
.or_else(|| row.metadata.get("url"))
|
||||
.or_else(|| entry.metadata.get("url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
return json!({
|
||||
"namespace": row.namespace,
|
||||
"kind": row.kind,
|
||||
"name": row.name,
|
||||
"tags": row.tags,
|
||||
"namespace": entry.namespace,
|
||||
"kind": entry.kind,
|
||||
"name": entry.name,
|
||||
"tags": entry.tags,
|
||||
"desc": desc,
|
||||
"updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let secrets_val = if row.encrypted.is_empty() {
|
||||
Value::Object(Default::default())
|
||||
} else {
|
||||
json!({"_encrypted": true})
|
||||
let secrets_val: Value = match schema {
|
||||
Some(fields) if !fields.is_empty() => {
|
||||
let schema_arr: Vec<Value> = fields
|
||||
.iter()
|
||||
.map(|f| {
|
||||
json!({
|
||||
"field_name": f.field_name,
|
||||
"field_type": f.field_type,
|
||||
"value_len": f.value_len,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Value::Array(schema_arr)
|
||||
}
|
||||
_ => Value::Array(vec![]),
|
||||
};
|
||||
|
||||
json!({
|
||||
"id": row.id,
|
||||
"namespace": row.namespace,
|
||||
"kind": row.kind,
|
||||
"name": row.name,
|
||||
"tags": row.tags,
|
||||
"metadata": row.metadata,
|
||||
"id": entry.id,
|
||||
"namespace": entry.namespace,
|
||||
"kind": entry.kind,
|
||||
"name": entry.name,
|
||||
"tags": entry.tags,
|
||||
"metadata": entry.metadata,
|
||||
"secrets": secrets_val,
|
||||
"version": row.version,
|
||||
"created_at": row.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
"updated_at": row.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
"version": entry.version,
|
||||
"created_at": entry.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn print_text(row: &Secret, summary: bool) -> Result<()> {
|
||||
println!("[{}/{}] {}", row.namespace, row.kind, row.name);
|
||||
fn print_text(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> Result<()> {
|
||||
println!("[{}/{}] {}", entry.namespace, entry.kind, entry.name);
|
||||
if summary {
|
||||
let desc = row
|
||||
let desc = entry
|
||||
.metadata
|
||||
.get("desc")
|
||||
.or_else(|| row.metadata.get("url"))
|
||||
.or_else(|| entry.metadata.get("url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-");
|
||||
if !row.tags.is_empty() {
|
||||
println!(" tags: [{}]", row.tags.join(", "));
|
||||
if !entry.tags.is_empty() {
|
||||
println!(" tags: [{}]", entry.tags.join(", "));
|
||||
}
|
||||
println!(" desc: {}", desc);
|
||||
println!(" updated: {}", format_local_time(row.updated_at));
|
||||
println!(" updated: {}", format_local_time(entry.updated_at));
|
||||
} else {
|
||||
println!(" id: {}", row.id);
|
||||
if !row.tags.is_empty() {
|
||||
println!(" tags: [{}]", row.tags.join(", "));
|
||||
println!(" id: {}", entry.id);
|
||||
if !entry.tags.is_empty() {
|
||||
println!(" tags: [{}]", entry.tags.join(", "));
|
||||
}
|
||||
if row.metadata.as_object().is_some_and(|m| !m.is_empty()) {
|
||||
if entry.metadata.as_object().is_some_and(|m| !m.is_empty()) {
|
||||
println!(
|
||||
" metadata: {}",
|
||||
serde_json::to_string_pretty(&row.metadata)?
|
||||
serde_json::to_string_pretty(&entry.metadata)?
|
||||
);
|
||||
}
|
||||
if !row.encrypted.is_empty() {
|
||||
println!(" secrets: [encrypted] (use `secrets inject` or `secrets run`)");
|
||||
match schema {
|
||||
Some(fields) if !fields.is_empty() => {
|
||||
let schema_str: Vec<String> = fields
|
||||
.iter()
|
||||
.map(|f| format!("{}: {}({})", f.field_name, f.field_type, f.value_len))
|
||||
.collect();
|
||||
println!(" secrets: {}", schema_str.join(", "));
|
||||
println!(" (use `secrets inject` or `secrets run` to get values)");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
println!(" created: {}", format_local_time(row.created_at));
|
||||
println!(" version: {}", entry.version);
|
||||
println!(" created: {}", format_local_time(entry.created_at));
|
||||
}
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract one or more field paths like `metadata.url`.
|
||||
fn print_fields(rows: &[Secret], fields: &[String]) -> Result<()> {
|
||||
/// Extract one or more metadata field paths like `metadata.url`.
|
||||
fn print_fields(rows: &[Entry], fields: &[String]) -> Result<()> {
|
||||
for row in rows {
|
||||
for field in fields {
|
||||
let val = extract_field(row, field)?;
|
||||
@@ -377,13 +483,13 @@ fn print_fields(rows: &[Secret], fields: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_field(row: &Secret, field: &str) -> Result<String> {
|
||||
fn extract_field(entry: &Entry, field: &str) -> Result<String> {
|
||||
let (section, key) = field
|
||||
.split_once('.')
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid field path '{}'. Use metadata.<key>.", field))?;
|
||||
|
||||
let obj = match section {
|
||||
"metadata" | "meta" => &row.metadata,
|
||||
"metadata" | "meta" => &entry.metadata,
|
||||
other => anyhow::bail!("Unknown field section '{}'. Use 'metadata'.", other),
|
||||
};
|
||||
|
||||
@@ -397,9 +503,9 @@ fn extract_field(row: &Secret, field: &str) -> Result<String> {
|
||||
anyhow::anyhow!(
|
||||
"Field '{}' not found in record [{}/{}/{}]",
|
||||
field,
|
||||
row.namespace,
|
||||
row.kind,
|
||||
row.name
|
||||
entry.namespace,
|
||||
entry.kind,
|
||||
entry.name
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -411,41 +517,47 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn sample_secret() -> Secret {
|
||||
let key = [0x42u8; 32];
|
||||
let encrypted = crypto::encrypt_json(&key, &json!({"token": "abc123"})).unwrap();
|
||||
|
||||
Secret {
|
||||
fn sample_entry() -> Entry {
|
||||
Entry {
|
||||
id: Uuid::nil(),
|
||||
namespace: "refining".to_string(),
|
||||
kind: "service".to_string(),
|
||||
name: "gitea.main".to_string(),
|
||||
tags: vec!["prod".to_string()],
|
||||
metadata: json!({"url": "https://gitea.refining.dev", "enabled": true}),
|
||||
encrypted,
|
||||
version: 1,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_show_secrets_flag() {
|
||||
let err = validate_safe_search_args(true, &[]).unwrap_err();
|
||||
assert!(err.to_string().contains("no longer reveals secrets"));
|
||||
fn sample_fields() -> Vec<SecretField> {
|
||||
let key = [0x42u8; 32];
|
||||
let enc = crypto::encrypt_json(&key, &json!("abc123")).unwrap();
|
||||
vec![SecretField {
|
||||
id: Uuid::nil(),
|
||||
entry_id: Uuid::nil(),
|
||||
field_name: "token".to_string(),
|
||||
field_type: "string".to_string(),
|
||||
value_len: 6,
|
||||
encrypted: enc,
|
||||
version: 1,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_secret_field_extraction() {
|
||||
let fields = vec!["secret.token".to_string()];
|
||||
let err = validate_safe_search_args(false, &fields).unwrap_err();
|
||||
let err = validate_safe_search_args(&fields).unwrap_err();
|
||||
assert!(err.to_string().contains("sensitive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_env_map_excludes_secret_values() {
|
||||
let row = sample_secret();
|
||||
let map = build_metadata_env_map(&row, "");
|
||||
let entry = sample_entry();
|
||||
let map = build_metadata_env_map(&entry, "");
|
||||
|
||||
assert_eq!(
|
||||
map.get("GITEA_MAIN_URL").map(String::as_str),
|
||||
@@ -459,14 +571,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_env_map_includes_secret_values() {
|
||||
let row = sample_secret();
|
||||
let key = [0x42u8; 32];
|
||||
let map = build_injected_env_map(&row, "", &key).unwrap();
|
||||
fn to_json_full_includes_secrets_schema() {
|
||||
let entry = sample_entry();
|
||||
let fields = sample_fields();
|
||||
let v = to_json(&entry, false, Some(&fields));
|
||||
|
||||
assert_eq!(
|
||||
map.get("GITEA_MAIN_TOKEN").map(String::as_str),
|
||||
Some("abc123")
|
||||
);
|
||||
let secrets = v.get("secrets").unwrap().as_array().unwrap();
|
||||
assert_eq!(secrets.len(), 1);
|
||||
assert_eq!(secrets[0]["field_name"], "token");
|
||||
assert_eq!(secrets[0]["field_type"], "string");
|
||||
assert_eq!(secrets[0]["value_len"], 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_json_summary_omits_secrets_schema() {
|
||||
let entry = sample_entry();
|
||||
let fields = sample_fields();
|
||||
let v = to_json(&entry, true, Some(&fields));
|
||||
assert!(v.get("secrets").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user