chore: local timezone in text output, search metadata-only, bump 0.7.3
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 2m15s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m50s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 44s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 2m15s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m50s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 44s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Made-with: Cursor
This commit is contained in:
@@ -5,7 +5,7 @@ use std::collections::HashMap;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::models::Secret;
|
||||
use crate::output::OutputMode;
|
||||
use crate::output::{OutputMode, format_local_time};
|
||||
|
||||
pub struct SearchArgs<'a> {
|
||||
pub namespace: Option<&'a str>,
|
||||
@@ -22,7 +22,9 @@ pub struct SearchArgs<'a> {
|
||||
pub output: OutputMode,
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 32]>) -> Result<()> {
|
||||
pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
|
||||
validate_safe_search_args(args.show_secrets, args.fields)?;
|
||||
|
||||
let rows = fetch_rows_paged(
|
||||
pool,
|
||||
PagedFetchArgs {
|
||||
@@ -40,15 +42,12 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
|
||||
|
||||
// -f/--field: extract specific field values directly
|
||||
if !args.fields.is_empty() {
|
||||
return print_fields(&rows, args.fields, master_key);
|
||||
return print_fields(&rows, args.fields);
|
||||
}
|
||||
|
||||
match args.output {
|
||||
OutputMode::Json | OutputMode::JsonCompact => {
|
||||
let arr: Vec<Value> = rows
|
||||
.iter()
|
||||
.map(|r| to_json(r, args.show_secrets, args.summary, master_key))
|
||||
.collect();
|
||||
let arr: Vec<Value> = rows.iter().map(|r| to_json(r, args.summary)).collect();
|
||||
let out = if args.output == OutputMode::Json {
|
||||
serde_json::to_string_pretty(&arr)?
|
||||
} else {
|
||||
@@ -64,7 +63,7 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
|
||||
);
|
||||
}
|
||||
if let Some(row) = rows.first() {
|
||||
let map = build_env_map(row, "", master_key)?;
|
||||
let map = build_metadata_env_map(row, "");
|
||||
let mut pairs: Vec<(String, String)> = map.into_iter().collect();
|
||||
pairs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
for (k, v) in pairs {
|
||||
@@ -80,7 +79,7 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
|
||||
return Ok(());
|
||||
}
|
||||
for row in &rows {
|
||||
print_text(row, args.show_secrets, args.summary, master_key)?;
|
||||
print_text(row, args.summary)?;
|
||||
}
|
||||
println!("{} record(s) found.", rows.len());
|
||||
if rows.len() == args.limit as usize {
|
||||
@@ -96,6 +95,30 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
|
||||
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."
|
||||
);
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
fn is_secret_field(field: &str) -> bool {
|
||||
matches!(
|
||||
field.split_once('.').map(|(section, _)| section),
|
||||
Some("secret" | "secrets" | "encrypted")
|
||||
)
|
||||
}
|
||||
|
||||
/// Fetch rows with simple equality/tag filters (no pagination). Used by inject/run.
|
||||
pub async fn fetch_rows(
|
||||
pool: &PgPool,
|
||||
@@ -218,16 +241,9 @@ async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Se
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Build a flat `KEY=VALUE` map from a record's metadata and decrypted secrets.
|
||||
/// 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_env_map(
|
||||
row: &Secret,
|
||||
prefix: &str,
|
||||
master_key: Option<&[u8; 32]>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
fn env_prefix(row: &Secret, prefix: &str) -> String {
|
||||
let name_part = row.name.to_uppercase().replace(['-', '.', ' '], "_");
|
||||
let effective_prefix = if prefix.is_empty() {
|
||||
if prefix.is_empty() {
|
||||
name_part
|
||||
} else {
|
||||
format!(
|
||||
@@ -235,7 +251,14 @@ pub fn build_env_map(
|
||||
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
|
||||
name_part
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
@@ -250,9 +273,19 @@ pub fn build_env_map(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(master_key) = master_key
|
||||
&& !row.encrypted.is_empty()
|
||||
{
|
||||
map
|
||||
}
|
||||
|
||||
/// Build a flat `KEY=VALUE` map from metadata and decrypted secrets.
|
||||
pub fn build_injected_env_map(
|
||||
row: &Secret,
|
||||
prefix: &str,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let effective_prefix = env_prefix(row, prefix);
|
||||
let mut map = build_metadata_env_map(row, 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 {
|
||||
@@ -284,23 +317,7 @@ fn json_value_to_env_string(v: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt the encrypted blob for a row. Returns an empty object on empty blobs.
|
||||
fn try_decrypt(row: &Secret, master_key: Option<&[u8; 32]>) -> Result<Value> {
|
||||
if row.encrypted.is_empty() {
|
||||
return Ok(Value::Object(Default::default()));
|
||||
}
|
||||
let key = master_key.ok_or_else(|| {
|
||||
anyhow::anyhow!("master key required to decrypt secrets (run `secrets init`)")
|
||||
})?;
|
||||
crypto::decrypt_json(key, &row.encrypted)
|
||||
}
|
||||
|
||||
fn to_json(
|
||||
row: &Secret,
|
||||
show_secrets: bool,
|
||||
summary: bool,
|
||||
master_key: Option<&[u8; 32]>,
|
||||
) -> Value {
|
||||
fn to_json(row: &Secret, summary: bool) -> Value {
|
||||
if summary {
|
||||
let desc = row
|
||||
.metadata
|
||||
@@ -319,11 +336,8 @@ fn to_json(
|
||||
});
|
||||
}
|
||||
|
||||
let secrets_val = if show_secrets {
|
||||
match try_decrypt(row, master_key) {
|
||||
Ok(v) => v,
|
||||
Err(e) => json!({"_error": e.to_string()}),
|
||||
}
|
||||
let secrets_val = if row.encrypted.is_empty() {
|
||||
Value::Object(Default::default())
|
||||
} else {
|
||||
json!({"_encrypted": true})
|
||||
};
|
||||
@@ -342,12 +356,7 @@ fn to_json(
|
||||
})
|
||||
}
|
||||
|
||||
fn print_text(
|
||||
row: &Secret,
|
||||
show_secrets: bool,
|
||||
summary: bool,
|
||||
master_key: Option<&[u8; 32]>,
|
||||
) -> Result<()> {
|
||||
fn print_text(row: &Secret, summary: bool) -> Result<()> {
|
||||
println!("[{}/{}] {}", row.namespace, row.kind, row.name);
|
||||
if summary {
|
||||
let desc = row
|
||||
@@ -360,10 +369,7 @@ fn print_text(
|
||||
println!(" tags: [{}]", row.tags.join(", "));
|
||||
}
|
||||
println!(" desc: {}", desc);
|
||||
println!(
|
||||
" updated: {}",
|
||||
row.updated_at.format("%Y-%m-%d %H:%M:%S UTC")
|
||||
);
|
||||
println!(" updated: {}", format_local_time(row.updated_at));
|
||||
} else {
|
||||
println!(" id: {}", row.id);
|
||||
if !row.tags.is_empty() {
|
||||
@@ -376,61 +382,33 @@ fn print_text(
|
||||
);
|
||||
}
|
||||
if !row.encrypted.is_empty() {
|
||||
if show_secrets {
|
||||
match try_decrypt(row, master_key) {
|
||||
Ok(v) => println!(" secrets: {}", serde_json::to_string_pretty(&v)?),
|
||||
Err(e) => println!(" secrets: [decrypt error: {}]", e),
|
||||
}
|
||||
} else {
|
||||
println!(" secrets: [encrypted] (--show-secrets to reveal)");
|
||||
}
|
||||
println!(" secrets: [encrypted] (use `secrets inject` or `secrets run`)");
|
||||
}
|
||||
println!(
|
||||
" created: {}",
|
||||
row.created_at.format("%Y-%m-%d %H:%M:%S UTC")
|
||||
);
|
||||
println!(" created: {}", format_local_time(row.created_at));
|
||||
}
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract one or more field paths like `metadata.url` or `secret.token`.
|
||||
fn print_fields(rows: &[Secret], fields: &[String], master_key: Option<&[u8; 32]>) -> Result<()> {
|
||||
/// Extract one or more field paths like `metadata.url`.
|
||||
fn print_fields(rows: &[Secret], fields: &[String]) -> Result<()> {
|
||||
for row in rows {
|
||||
let decrypted: Option<Value> = if fields
|
||||
.iter()
|
||||
.any(|f| f.starts_with("secret") || f.starts_with("encrypted"))
|
||||
{
|
||||
Some(try_decrypt(row, master_key)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for field in fields {
|
||||
let val = extract_field(row, field, decrypted.as_ref())?;
|
||||
let val = extract_field(row, field)?;
|
||||
println!("{}", val);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_field(row: &Secret, field: &str, decrypted: Option<&Value>) -> Result<String> {
|
||||
let (section, key) = field.split_once('.').ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Invalid field path '{}'. Use metadata.<key> or secret.<key>",
|
||||
field
|
||||
)
|
||||
})?;
|
||||
fn extract_field(row: &Secret, 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,
|
||||
"secret" | "secrets" | "encrypted" => {
|
||||
decrypted.ok_or_else(|| anyhow::anyhow!("secret field requires master key"))?
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"Unknown field section '{}'. Use 'metadata' or 'secret'",
|
||||
other
|
||||
),
|
||||
other => anyhow::bail!("Unknown field section '{}'. Use 'metadata'.", other),
|
||||
};
|
||||
|
||||
obj.get(key)
|
||||
@@ -449,3 +427,70 @@ fn extract_field(row: &Secret, field: &str, decrypted: Option<&Value>) -> Result
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
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 {
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_secret_field_extraction() {
|
||||
let fields = vec!["secret.token".to_string()];
|
||||
let err = validate_safe_search_args(false, &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, "");
|
||||
|
||||
assert_eq!(
|
||||
map.get("GITEA_MAIN_URL").map(String::as_str),
|
||||
Some("https://gitea.refining.dev")
|
||||
);
|
||||
assert_eq!(
|
||||
map.get("GITEA_MAIN_ENABLED").map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert!(!map.contains_key("GITEA_MAIN_TOKEN"));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
assert_eq!(
|
||||
map.get("GITEA_MAIN_TOKEN").map(String::as_str),
|
||||
Some("abc123")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user