diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 943069c..6ed125f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -104,9 +104,9 @@ "dependsOn": "build" }, { - "label": "test: search with secrets revealed", + "label": "test: inject service secrets", "type": "shell", - "command": "./target/debug/secrets search -n refining --kind service --show-secrets", + "command": "./target/debug/secrets inject -n refining --kind service --name gitea", "dependsOn": "build" }, { @@ -118,7 +118,7 @@ { "label": "test: add + delete roundtrip", "type": "shell", - "command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name roundtrip-test --tag test -m foo=bar -s password=secret123 && echo '--- search ---' && ./target/debug/secrets search -n test --show-secrets && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name roundtrip-test && echo '--- verify deleted ---' && ./target/debug/secrets search -n test", + "command": "echo '--- add ---' && ./target/debug/secrets add -n test --kind demo --name roundtrip-test --tag test -m foo=bar -s password=secret123 && echo '--- search metadata ---' && ./target/debug/secrets search -n test && echo '--- inject secrets ---' && ./target/debug/secrets inject -n test --kind demo --name roundtrip-test && echo '--- delete ---' && ./target/debug/secrets delete -n test --kind demo --name roundtrip-test && echo '--- verify deleted ---' && ./target/debug/secrets search -n test", "dependsOn": "build" }, { @@ -142,7 +142,7 @@ { "label": "test: add with file secret", "type": "shell", - "command": "echo '--- add key from file ---' && ./target/debug/secrets add -n test --kind key --name test-key --tag test -s content=@./refining/keys/Vultr && echo '--- verify ---' && ./target/debug/secrets search -n test --kind key --show-secrets && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind key --name test-key", + "command": "echo '--- add key from file ---' && ./target/debug/secrets add -n test --kind key --name test-key --tag test -s content=@./refining/keys/Vultr && echo '--- verify metadata ---' && ./target/debug/secrets search -n test --kind key && echo '--- verify inject ---' && ./target/debug/secrets inject -n test --kind key --name test-key && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind key --name test-key", "dependsOn": "build" } ] diff --git a/AGENTS.md b/AGENTS.md index fa93303..6bc95ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,8 +176,8 @@ secrets init # --name gitea | i-uf63f2uookgs5uxmrdyc | mqtt # --tag aliyun | hongkong | production # -q / --query mqtt | grafana | gitea (模糊匹配 name/namespace/kind/tags/metadata) -# --show-secrets 不带值的 flag,显示 encrypted 字段内容 -# -f / --field metadata.ip | metadata.url | secret.token | secret.ssh_key +# --show-secrets 已弃用;search 不再直接展示 secrets +# -f / --field metadata.ip | metadata.url | metadata.default_org # --summary 不带值的 flag,仅返回摘要(name/tags/desc/updated_at) # --limit 20 | 50(默认 50) # --offset 0 | 10 | 20(分页偏移) @@ -193,14 +193,17 @@ secrets search --sort updated --limit 10 --summary secrets search -n refining --kind service --name gitea secrets search -n refining --kind server --name i-uf63f2uookgs5uxmrdyc -# 精确定位并获取完整内容(含 secrets) -secrets search -n refining --kind service --name gitea -o json --show-secrets +# 精确定位并获取完整内容(secrets 保持加密占位) +secrets search -n refining --kind service --name gitea -o json -# 直接提取字段值(最短路径,-f secret.* 自动解锁 secrets) -secrets search -n refining --kind service --name gitea -f secret.token +# 直接提取 metadata 字段值(最短路径) secrets search -n refining --kind service --name gitea -f metadata.url secrets search -n refining --kind service --name gitea \ - -f metadata.url -f metadata.default_org -f secret.token + -f metadata.url -f metadata.default_org + +# 需要 secrets 时,改用 inject / run +secrets inject -n refining --kind service --name gitea +secrets run -n refining --kind service --name gitea -- printenv # 模糊关键词搜索 secrets search -q mqtt @@ -219,10 +222,9 @@ secrets search -n refining --summary --limit 10 --offset 10 # 管道 / AI 调用(非 TTY 自动 json-compact) secrets search -n refining --kind service | jq '.[].name' -secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token' -# 导出为 env 文件(单条记录) -secrets search -n refining --kind service --name gitea -o env --show-secrets \ +# 导出 metadata 为 env 文件(单条记录) +secrets search -n refining --kind service --name gitea -o env \ > ~/.config/gitea/config.env ``` diff --git a/Cargo.lock b/Cargo.lock index 3695563..c7ad304 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secrets" -version = "0.7.2" +version = "0.7.3" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 200efec..c000f2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets" -version = "0.7.2" +version = "0.7.3" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 9f2f030..bd96337 100644 --- a/README.md +++ b/README.md @@ -54,37 +54,46 @@ secrets search --sort updated --limit 10 --summary # 精确定位(namespace + kind + name 三元组) secrets search -n refining --kind service --name gitea -# 获取完整记录含 secrets(JSON 格式,AI 最易解析) -secrets search -n refining --kind service --name gitea -o json --show-secrets +# 获取完整记录(secrets 保持加密占位) +secrets search -n refining --kind service --name gitea -o json -# 直接提取单个字段值(最短路径) -secrets search -n refining --kind service --name gitea -f secret.token +# 直接提取单个 metadata 字段值(最短路径) secrets search -n refining --kind service --name gitea -f metadata.url -# 同时提取多个字段 +# 同时提取多个 metadata 字段 secrets search -n refining --kind service --name gitea \ - -f metadata.url -f metadata.default_org -f secret.token + -f metadata.url -f metadata.default_org + +# 需要 secrets 时,改用 inject / run +secrets inject -n refining --kind service --name gitea +secrets run -n refining --kind service --name gitea -- printenv ``` -`-f secret.*` 会自动解锁 secrets,无需额外加 `--show-secrets`。 +`search` 只负责发现、定位和读取 metadata,不直接展示 secrets。 ### 输出格式 | 场景 | 推荐命令 | |------|----------| | AI 解析 / 管道处理 | `-o json` 或 `-o json-compact` | -| 写入 `.env` 文件 | `-o env --show-secrets` | +| 写入 metadata `.env` 文件 | `-o env` | +| 注入 secrets 到环境变量 | `inject` / `run` | | 人类查看 | 默认 `text`(TTY 下自动启用) | | 非 TTY(管道/重定向) | 自动 `json-compact` | +说明:`text` 输出中的时间会按当前机器本地时区显示;`json/json-compact` 继续使用 UTC(RFC3339 风格)以便脚本和 AI 稳定解析。 + ```bash # 管道直接 jq 解析(非 TTY 自动 json-compact) secrets search -n refining --kind service | jq '.[].name' -secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token' -# 导出为可 source 的 env 文件(单条记录) -secrets search -n refining --kind service --name gitea -o env --show-secrets \ +# 导出 metadata 为可 source 的 env 文件(单条记录) +secrets search -n refining --kind service --name gitea -o env \ > ~/.config/gitea/config.env + +# 需要 secrets 时,使用 inject / run +secrets inject -n refining --kind service --name gitea > ~/.config/gitea/secrets.env +secrets run -n refining --kind service --name gitea -- ./deploy.sh ``` ## 完整命令参考 @@ -106,8 +115,8 @@ secrets search -n refining --kind service # 按 namespace + kin secrets search -n refining --kind service --name gitea # 精确查找 secrets search -q mqtt # 关键词模糊搜索 secrets search --tag hongkong # 按 tag 过滤 -secrets search -n refining --kind service --name gitea -f secret.token # 提取字段 -secrets search -n refining --kind service --name gitea -o json --show-secrets # 完整 JSON +secrets search -n refining --kind service --name gitea -f metadata.url # 提取 metadata 字段 +secrets search -n refining --kind service --name gitea -o json # 完整记录(secrets 保持占位) secrets search --sort updated --limit 10 --summary # 最近改动 secrets search -n refining --summary --limit 10 --offset 10 # 翻页 diff --git a/src/commands/rollback.rs b/src/commands/rollback.rs index 4d0d1d0..67d22d5 100644 --- a/src/commands/rollback.rs +++ b/src/commands/rollback.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; -use crate::output::OutputMode; +use crate::output::{OutputMode, format_local_time}; #[derive(FromRow)] struct HistoryRow { @@ -228,7 +228,7 @@ pub async fn list_history( r.version, r.action, r.actor, - r.created_at.format("%Y-%m-%d %H:%M:%S UTC") + format_local_time(r.created_at) ); } println!(" (use `secrets rollback --to-version ` to restore)"); diff --git a/src/commands/run.rs b/src/commands/run.rs index 711fd9e..46aadd5 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -3,7 +3,7 @@ use serde_json::Value; use sqlx::PgPool; use std::collections::HashMap; -use crate::commands::search::build_env_map; +use crate::commands::search::build_injected_env_map; use crate::output::OutputMode; pub struct InjectArgs<'a> { @@ -48,7 +48,7 @@ pub async fn collect_env_map( } let mut map = HashMap::new(); for row in &rows { - let row_map = build_env_map(row, prefix, Some(master_key))?; + let row_map = build_injected_env_map(row, prefix, master_key)?; for (k, v) in row_map { map.insert(k, v); } diff --git a/src/commands/search.rs b/src/commands/search.rs index 8e93d28..29400df 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -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 = rows - .iter() - .map(|r| to_json(r, args.show_secrets, args.summary, master_key)) - .collect(); + let arr: Vec = 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_` (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> { +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: `_` (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 { + 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> { + 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 { - 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 = 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 { - let (section, key) = field.split_once('.').ok_or_else(|| { - anyhow::anyhow!( - "Invalid field path '{}'. Use metadata. or secret.", - field - ) - })?; +fn extract_field(row: &Secret, field: &str) -> Result { + let (section, key) = field + .split_once('.') + .ok_or_else(|| anyhow::anyhow!("Invalid field path '{}'. Use metadata..", 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") + ); + } +} diff --git a/src/main.rs b/src/main.rs index 5315a91..dd35749 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,13 +28,16 @@ use output::resolve_output_mode; secrets search --summary --limit 20 # Precise lookup (JSON output for easy parsing) - secrets search -n refining --kind service --name gitea -o json --show-secrets + secrets search -n refining --kind service --name gitea -o json - # Extract a single field value directly - secrets search -n refining --kind service --name gitea -f secret.token + # Extract a single metadata field directly + secrets search -n refining --kind service --name gitea -f metadata.url # Pipe-friendly (non-TTY defaults to json-compact automatically) - secrets search -n refining --kind service | jq '.[].name'" + secrets search -n refining --kind service | jq '.[].name' + + # Inject secrets into environment variables when you really need them + secrets inject -n refining --kind service --name gitea" )] struct Cli { /// Database URL, overrides saved config (one-time override) @@ -129,19 +132,19 @@ EXAMPLES: # Fuzzy keyword search (matches name, namespace, kind, tags, metadata) secrets search -q mqtt - # Extract a single field value (implies --show-secrets for secret.*) - secrets search -n refining --kind service --name gitea -f secret.token + # Extract a single metadata field value secrets search -n refining --kind service --name gitea -f metadata.url # Multiple fields at once secrets search -n refining --kind service --name gitea \\ - -f metadata.url -f metadata.default_org -f secret.token + -f metadata.url -f metadata.default_org - # Full JSON output with secrets revealed (ideal for AI parsing) - secrets search -n refining --kind service --name gitea -o json --show-secrets + # Export metadata as env vars (single record only) + secrets search -n refining --kind service --name gitea -o env - # Export as env vars (source-able; single record only) - secrets search -n refining --kind service --name gitea -o env --show-secrets + # Inject decrypted secrets only when needed + secrets inject -n refining --kind service --name gitea + secrets run -n refining --kind service --name gitea -- printenv # Paginate large result sets secrets search -n refining --summary --limit 10 --offset 0 @@ -151,8 +154,7 @@ EXAMPLES: secrets search --sort updated --limit 5 --summary # Non-TTY / pipe: output is json-compact by default - secrets search -n refining --kind service | jq '.[].name' - secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token'")] + secrets search -n refining --kind service | jq '.[].name'")] Search { /// Filter by namespace, e.g. refining, ricnsmart #[arg(short, long)] @@ -169,10 +171,10 @@ EXAMPLES: /// Fuzzy keyword (matches name, namespace, kind, tags, metadata text) #[arg(short, long)] query: Option, - /// Reveal encrypted secret values in output + /// Deprecated: search never reveals secrets; use inject/run instead #[arg(long)] show_secrets: bool, - /// Extract field value(s) directly: metadata. or secret. (repeatable) + /// Extract metadata field value(s) directly: metadata. (repeatable) #[arg(short = 'f', long = "field")] fields: Vec, /// Return lightweight summary only (namespace, kind, name, tags, desc, updated_at) @@ -501,9 +503,7 @@ async fn main() -> Result<()> { sort, output, } => { - let master_key = crypto::load_master_key()?; let _span = tracing::info_span!("cmd", command = "search").entered(); - let show = show_secrets || fields.iter().any(|f| f.starts_with("secret")); let out = resolve_output_mode(output.as_deref())?; commands::search::run( &pool, @@ -513,7 +513,7 @@ async fn main() -> Result<()> { name: name.as_deref(), tags: &tag, query: query.as_deref(), - show_secrets: show, + show_secrets, fields: &fields, summary, limit, @@ -521,7 +521,6 @@ async fn main() -> Result<()> { sort: &sort, output: out, }, - Some(&master_key), ) .await?; } diff --git a/src/output.rs b/src/output.rs index b9d2709..c62a6b3 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Local, Utc}; use std::io::IsTerminal; use std::str::FromStr; @@ -45,3 +46,10 @@ pub fn resolve_output_mode(explicit: Option<&str>) -> anyhow::Result Ok(OutputMode::JsonCompact) } } + +/// Format a UTC timestamp for local human-readable output. +pub fn format_local_time(dt: DateTime) -> String { + dt.with_timezone(&Local) + .format("%Y-%m-%d %H:%M:%S %:z") + .to_string() +}