Compare commits

...

4 Commits

Author SHA1 Message Date
voson
5a5867adc1 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
Made-with: Cursor
2026-03-19 12:24:20 +08:00
voson
4ddafbe4b6 chore: remove dead code, bump to 0.7.2
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m49s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 43s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m2s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- Remove unused delete_master_key from crypto.rs
- Remove unused audit::log from audit.rs
- Simplify HistoryRow in rollback.rs (drop unused namespace/kind/name)
- Update AGENTS.md: audit::log → audit::log_tx

Made-with: Cursor
2026-03-19 11:43:01 +08:00
voson
6ea9f0861b chore: bump to 0.7.1, workflow/readme/init/upgrade updates, fix clippy needless_borrows
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 1m47s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 48s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m2s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Made-with: Cursor
2026-03-19 11:34:10 +08:00
voson
3973295d6a chore(release): enforce version bump checks
Fail fast when a release tag already exists, and add a local release-check script so version mistakes are caught before commit and publish.

Made-with: Cursor
2026-03-19 11:17:23 +08:00
16 changed files with 350 additions and 235 deletions

View File

@@ -7,7 +7,6 @@ on:
- 'src/**' - 'src/**'
- 'Cargo.toml' - 'Cargo.toml'
- 'Cargo.lock' - 'Cargo.lock'
- '.gitea/workflows/secrets.yml'
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -56,6 +55,13 @@ jobs:
echo "将创建新版本 ${tag}" echo "将创建新版本 ${tag}"
fi fi
- name: 严格拦截重复版本
if: steps.ver.outputs.tag_exists == 'true'
run: |
echo "错误: 版本 ${{ steps.ver.outputs.tag }} 已存在,禁止重复发版。"
echo "请先 bump Cargo.toml 中的 version并执行 cargo build 同步 Cargo.lock。"
exit 1
- name: 创建 Tag - name: 创建 Tag
if: steps.ver.outputs.tag_exists == 'false' if: steps.ver.outputs.tag_exists == 'false'
run: | run: |
@@ -327,11 +333,14 @@ jobs:
- name: 安装依赖 - name: 安装依赖
shell: pwsh shell: pwsh
run: | run: |
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile rustup-init.exe Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable .\rustup-init.exe -y --default-toolchain stable
Remove-Item rustup-init.exe Remove-Item rustup-init.exe
} }
$env:Path = "$cargoBin;$env:Path"
Add-Content -Path $env:GITHUB_PATH -Value $cargoBin
rustup target add x86_64-pc-windows-msvc rustup target add x86_64-pc-windows-msvc
- uses: actions/checkout@v4 - uses: actions/checkout@v4

8
.vscode/tasks.json vendored
View File

@@ -104,9 +104,9 @@
"dependsOn": "build" "dependsOn": "build"
}, },
{ {
"label": "test: search with secrets revealed", "label": "test: inject service secrets",
"type": "shell", "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" "dependsOn": "build"
}, },
{ {
@@ -118,7 +118,7 @@
{ {
"label": "test: add + delete roundtrip", "label": "test: add + delete roundtrip",
"type": "shell", "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" "dependsOn": "build"
}, },
{ {
@@ -142,7 +142,7 @@
{ {
"label": "test: add with file secret", "label": "test: add with file secret",
"type": "shell", "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" "dependsOn": "build"
} }
] ]

View File

@@ -1,5 +1,12 @@
# Secrets CLI — AGENTS.md # Secrets CLI — AGENTS.md
## 提交 / 发版硬规则(优先于下文其他说明)
1. 涉及 `src/**``Cargo.toml``Cargo.lock`、CLI 行为变更的提交,默认视为**需要发版**,除非用户明确说明“本次不发版”。
2. 发版前必须先检查 `Cargo.toml` 中的 `version`,再检查是否已存在对应 tag`git tag -l 'secrets-*'`
3. 若当前版本对应 tag 已存在,必须先 bump `Cargo.toml``version`,再执行 `cargo build` 同步 `Cargo.lock`,然后才能提交。
4. 提交前优先运行 `./scripts/release-check.sh`;该脚本会检查重复版本并执行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`
跨设备密钥与配置管理 CLI 工具,将 refining / ricnsmart 两个项目的服务器信息、服务凭据存储到 PostgreSQL 18供 AI 工具读取上下文。敏感数据encrypted 字段)使用 AES-256-GCM 加密,主密钥由 Argon2id 从主密码派生并存入平台安全存储macOS Keychain / Windows Credential Manager / Linux keyutils 跨设备密钥与配置管理 CLI 工具,将 refining / ricnsmart 两个项目的服务器信息、服务凭据存储到 PostgreSQL 18供 AI 工具读取上下文。敏感数据encrypted 字段)使用 AES-256-GCM 加密,主密钥由 Argon2id 从主密码派生并存入平台安全存储macOS Keychain / Windows Credential Manager / Linux keyutils
## 项目结构 ## 项目结构
@@ -25,6 +32,7 @@ secrets/
run.rs # inject / run 命令:临时环境变量注入 run.rs # inject / run 命令:临时环境变量注入
upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制 upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制
scripts/ scripts/
release-check.sh # 发版前检查版本号/tag 是否重复,并执行 fmt/clippy/test
setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets
.gitea/workflows/ .gitea/workflows/
secrets.yml # CIfmt + clippy + musl 构建 + Release 上传 + 飞书通知 secrets.yml # CIfmt + clippy + musl 构建 + Release 上传 + 飞书通知
@@ -168,8 +176,8 @@ secrets init
# --name gitea | i-uf63f2uookgs5uxmrdyc | mqtt # --name gitea | i-uf63f2uookgs5uxmrdyc | mqtt
# --tag aliyun | hongkong | production # --tag aliyun | hongkong | production
# -q / --query mqtt | grafana | gitea (模糊匹配 name/namespace/kind/tags/metadata # -q / --query mqtt | grafana | gitea (模糊匹配 name/namespace/kind/tags/metadata
# --show-secrets 不带值的 flag显示 encrypted 字段内容 # --show-secrets 已弃用search 不再直接展示 secrets
# -f / --field metadata.ip | metadata.url | secret.token | secret.ssh_key # -f / --field metadata.ip | metadata.url | metadata.default_org
# --summary 不带值的 flag仅返回摘要name/tags/desc/updated_at # --summary 不带值的 flag仅返回摘要name/tags/desc/updated_at
# --limit 20 | 50默认 50 # --limit 20 | 50默认 50
# --offset 0 | 10 | 20分页偏移 # --offset 0 | 10 | 20分页偏移
@@ -185,14 +193,17 @@ secrets search --sort updated --limit 10 --summary
secrets search -n refining --kind service --name gitea secrets search -n refining --kind service --name gitea
secrets search -n refining --kind server --name i-uf63f2uookgs5uxmrdyc secrets search -n refining --kind server --name i-uf63f2uookgs5uxmrdyc
# 精确定位并获取完整内容(secrets # 精确定位并获取完整内容secrets 保持加密占位
secrets search -n refining --kind service --name gitea -o json --show-secrets secrets search -n refining --kind service --name gitea -o json
# 直接提取字段值(最短路径-f secret.* 自动解锁 secrets # 直接提取 metadata 字段值(最短路径)
secrets search -n refining --kind service --name gitea -f secret.token
secrets search -n refining --kind service --name gitea -f metadata.url secrets search -n refining --kind service --name gitea -f metadata.url
secrets search -n refining --kind service --name gitea \ 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 secrets search -q mqtt
@@ -211,10 +222,9 @@ secrets search -n refining --summary --limit 10 --offset 10
# 管道 / AI 调用(非 TTY 自动 json-compact # 管道 / AI 调用(非 TTY 自动 json-compact
secrets search -n refining --kind service | jq '.[].name' secrets search -n refining --kind service | jq '.[].name'
secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token'
# 导出为 env 文件(单条记录) # 导出 metadata 为 env 文件(单条记录)
secrets search -n refining --kind service --name gitea -o env --show-secrets \ secrets search -n refining --kind service --name gitea -o env \
> ~/.config/gitea/config.env > ~/.config/gitea/config.env
``` ```
@@ -458,7 +468,7 @@ secrets --db-url "postgres://..." search -n refining
- 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码 - 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码
- 字段命名CLI 短标志 `-n`=namespace`-m`=meta`-s`=secret`-q`=query`-v`=verbose`-f`=field`-o`=output - 字段命名CLI 短标志 `-n`=namespace`-m`=meta`-s`=secret`-q`=query`-v`=verbose`-f`=field`-o`=output
- 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!` - 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!`
- 审计:`add`/`update`/`delete` 成功后调用 `audit::log()`,写入 `audit_log` 表;失败只 warn 不中断 - 审计:`add`/`update`/`delete` 成功后调用 `audit::log_tx`,写入 `audit_log` 表;失败只 warn 不中断
- 加密:`encrypted` 列存储 AES-256-GCM 密文;`add`/`update`/`search`/`delete` 需主密钥(`secrets init` 后从 OS 钥匙串加载) - 加密:`encrypted` 列存储 AES-256-GCM 密文;`add`/`update`/`search`/`delete` 需主密钥(`secrets init` 后从 OS 钥匙串加载)
- 输出:读命令通过 `OutputMode` 支持 text/json/json-compact/env写命令 `add` 同样支持 `-o json` - 输出:读命令通过 `OutputMode` 支持 text/json/json-compact/env写命令 `add` 同样支持 `-o json`
@@ -466,6 +476,14 @@ secrets --db-url "postgres://..." search -n refining
每次提交代码前,请在本地依次执行以下检查,**全部通过后再 push** 每次提交代码前,请在本地依次执行以下检查,**全部通过后再 push**
优先使用:
```bash
./scripts/release-check.sh
```
它等价于先检查版本号 / tag再执行下面的格式、Lint、测试。
### 1. 版本号(按需) ### 1. 版本号(按需)
若本次改动需要发版,请先确认 `Cargo.toml` 中的 `version` 已提升,避免 CI 打出的 Tag 与已有版本重复。**升级版本后需同时更新 `Cargo.lock`**(运行 `cargo build` 即可自动同步),否则 CI 中 `cargo clippy --locked` 会因 lock 与 manifest 不一致而失败。可通过 git tag 判断: 若本次改动需要发版,请先确认 `Cargo.toml` 中的 `version` 已提升,避免 CI 打出的 Tag 与已有版本重复。**升级版本后需同时更新 `Cargo.lock`**(运行 `cargo build` 即可自动同步),否则 CI 中 `cargo clippy --locked` 会因 lock 与 manifest 不一致而失败。可通过 git tag 判断:

2
Cargo.lock generated
View File

@@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "secrets" name = "secrets"
version = "0.7.0" version = "0.7.3"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "secrets" name = "secrets"
version = "0.7.0" version = "0.7.3"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -19,11 +19,11 @@ cargo build --release
# 1. 配置数据库连接(会先验证连接可用再写入) # 1. 配置数据库连接(会先验证连接可用再写入)
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets" secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
# 2. 初始化主密钥(提示输入主密码,派生后存入 OS 钥匙串) # 2. 初始化主密钥(提示输入至少 8 位的主密码,派生后存入 OS 钥匙串)
secrets init secrets init
``` ```
主密码不会存储仅用于派生主密钥。同一主密码在所有设备上会得到相同主密钥salt 存于数据库,首台设备生成后共享)。 主密码不会存储,仅用于派生主密钥,且至少需 8 位。同一主密码在所有设备上会得到相同主密钥salt 存于数据库,首台设备生成后共享)。
**主密钥存储**macOS → KeychainWindows → Credential ManagerLinux → keyutils会话级重启后需再次 `secrets init`)。 **主密钥存储**macOS → KeychainWindows → Credential ManagerLinux → keyutils会话级重启后需再次 `secrets init`)。
@@ -54,37 +54,46 @@ secrets search --sort updated --limit 10 --summary
# 精确定位namespace + kind + name 三元组) # 精确定位namespace + kind + name 三元组)
secrets search -n refining --kind service --name gitea secrets search -n refining --kind service --name gitea
# 获取完整记录secretsJSON 格式AI 最易解析 # 获取完整记录secrets 保持加密占位
secrets search -n refining --kind service --name gitea -o json --show-secrets secrets search -n refining --kind service --name gitea -o json
# 直接提取单个字段值(最短路径) # 直接提取单个 metadata 字段值(最短路径)
secrets search -n refining --kind service --name gitea -f secret.token
secrets search -n refining --kind service --name gitea -f metadata.url secrets search -n refining --kind service --name gitea -f metadata.url
# 同时提取多个字段 # 同时提取多个 metadata 字段
secrets search -n refining --kind service --name gitea \ 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` | | AI 解析 / 管道处理 | `-o json``-o json-compact` |
| 写入 `.env` 文件 | `-o env --show-secrets` | | 写入 metadata `.env` 文件 | `-o env` |
| 注入 secrets 到环境变量 | `inject` / `run` |
| 人类查看 | 默认 `text`TTY 下自动启用) | | 人类查看 | 默认 `text`TTY 下自动启用) |
| 非 TTY管道/重定向) | 自动 `json-compact` | | 非 TTY管道/重定向) | 自动 `json-compact` |
说明:`text` 输出中的时间会按当前机器本地时区显示;`json/json-compact` 继续使用 UTCRFC3339 风格)以便脚本和 AI 稳定解析。
```bash ```bash
# 管道直接 jq 解析(非 TTY 自动 json-compact # 管道直接 jq 解析(非 TTY 自动 json-compact
secrets search -n refining --kind service | jq '.[].name' secrets search -n refining --kind service | jq '.[].name'
secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token'
# 导出为可 source 的 env 文件(单条记录) # 导出 metadata 为可 source 的 env 文件(单条记录)
secrets search -n refining --kind service --name gitea -o env --show-secrets \ secrets search -n refining --kind service --name gitea -o env \
> ~/.config/gitea/config.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 -n refining --kind service --name gitea # 精确查找
secrets search -q mqtt # 关键词模糊搜索 secrets search -q mqtt # 关键词模糊搜索
secrets search --tag hongkong # 按 tag 过滤 secrets search --tag hongkong # 按 tag 过滤
secrets search -n refining --kind service --name gitea -f secret.token # 提取字段 secrets search -n refining --kind service --name gitea -f metadata.url # 提取 metadata 字段
secrets search -n refining --kind service --name gitea -o json --show-secrets # 完整 JSON secrets search -n refining --kind service --name gitea -o json # 完整记录secrets 保持占位)
secrets search --sort updated --limit 10 --summary # 最近改动 secrets search --sort updated --limit 10 --summary # 最近改动
secrets search -n refining --summary --limit 10 --offset 10 # 翻页 secrets search -n refining --summary --limit 10 --offset 10 # 翻页
@@ -131,7 +140,7 @@ secrets update -n refining --kind service --name mqtt --remove-meta old_port --r
secrets delete -n refining --kind service --name legacy-mqtt secrets delete -n refining --kind service --name legacy-mqtt
# ── init ───────────────────────────────────────────────────────────────────── # ── init ─────────────────────────────────────────────────────────────────────
secrets init # 主密钥初始化(每台设备一次,主密码派生后存钥匙串) secrets init # 主密钥初始化(每台设备一次,主密码至少 8 位,派生后存钥匙串)
# ── config ─────────────────────────────────────────────────────────────────── # ── config ───────────────────────────────────────────────────────────────────
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets" # 先验证再写入 secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets" # 先验证再写入

23
scripts/release-check.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
version="$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
tag="secrets-${version}"
echo "==> 当前版本: ${version}"
echo "==> 检查是否已存在 tag: ${tag}"
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
echo "错误: 已存在 tag ${tag}"
echo "请先 bump Cargo.toml 中的 version再执行 cargo build 同步 Cargo.lock。"
exit 1
fi
echo "==> 未发现重复 tag开始执行检查"
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked

View File

@@ -1,5 +1,5 @@
use serde_json::Value; use serde_json::Value;
use sqlx::{PgPool, Postgres, Transaction}; use sqlx::{Postgres, Transaction};
/// Write an audit entry within an existing transaction. /// Write an audit entry within an existing transaction.
pub async fn log_tx( pub async fn log_tx(
@@ -30,35 +30,3 @@ pub async fn log_tx(
tracing::debug!(action, namespace, kind, name, actor, "audit logged"); tracing::debug!(action, namespace, kind, name, actor, "audit logged");
} }
} }
/// Write an audit entry using the pool (fire-and-forget, non-fatal).
/// Kept for future use or scenarios without an active transaction.
#[allow(dead_code)]
pub async fn log(
pool: &PgPool,
action: &str,
namespace: &str,
kind: &str,
name: &str,
detail: Value,
) {
let actor = std::env::var("USER").unwrap_or_default();
let result: Result<_, sqlx::Error> = sqlx::query(
"INSERT INTO audit_log (action, namespace, kind, name, detail, actor) \
VALUES ($1, $2, $3, $4, $5, $6)",
)
.bind(action)
.bind(namespace)
.bind(kind)
.bind(name)
.bind(&detail)
.bind(&actor)
.execute(pool)
.await;
if let Err(e) = result {
tracing::warn!(error = %e, "failed to write audit log");
} else {
tracing::debug!(action, namespace, kind, name, actor, "audit logged");
}
}

View File

@@ -4,15 +4,23 @@ use sqlx::PgPool;
use crate::{crypto, db}; use crate::{crypto, db};
const MIN_MASTER_PASSWORD_LEN: usize = 8;
pub async fn run(pool: &PgPool) -> Result<()> { pub async fn run(pool: &PgPool) -> Result<()> {
println!("Initializing secrets master key..."); println!("Initializing secrets master key...");
println!(); println!();
// Read password (no echo) // Read password (no echo)
let password = let password = rpassword::prompt_password(format!(
rpassword::prompt_password("Enter master password: ").context("failed to read password")?; "Enter master password (at least {} characters): ",
if password.is_empty() { MIN_MASTER_PASSWORD_LEN
anyhow::bail!("Master password must not be empty."); ))
.context("failed to read password")?;
if password.chars().count() < MIN_MASTER_PASSWORD_LEN {
anyhow::bail!(
"Master password must be at least {} characters.",
MIN_MASTER_PASSWORD_LEN
);
} }
let confirm = rpassword::prompt_password("Confirm master password: ") let confirm = rpassword::prompt_password("Confirm master password: ")
.context("failed to read password confirmation")?; .context("failed to read password confirmation")?;

View File

@@ -3,17 +3,11 @@ use serde_json::{Value, json};
use sqlx::{FromRow, PgPool}; use sqlx::{FromRow, PgPool};
use uuid::Uuid; use uuid::Uuid;
use crate::output::OutputMode; use crate::output::{OutputMode, format_local_time};
#[derive(FromRow)] #[derive(FromRow)]
struct HistoryRow { struct HistoryRow {
secret_id: Uuid, secret_id: Uuid,
#[allow(dead_code)]
namespace: String,
#[allow(dead_code)]
kind: String,
#[allow(dead_code)]
name: String,
version: i64, version: i64,
action: String, action: String,
tags: Vec<String>, tags: Vec<String>,
@@ -33,7 +27,7 @@ pub struct RollbackArgs<'a> {
pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -> Result<()> { pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
let snap: Option<HistoryRow> = if let Some(ver) = args.to_version { let snap: Option<HistoryRow> = if let Some(ver) = args.to_version {
sqlx::query_as( sqlx::query_as(
"SELECT secret_id, namespace, kind, name, version, action, tags, metadata, encrypted \ "SELECT secret_id, version, action, tags, metadata, encrypted \
FROM secrets_history \ FROM secrets_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \ WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
ORDER BY id DESC LIMIT 1", ORDER BY id DESC LIMIT 1",
@@ -46,7 +40,7 @@ pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -
.await? .await?
} else { } else {
sqlx::query_as( sqlx::query_as(
"SELECT secret_id, namespace, kind, name, version, action, tags, metadata, encrypted \ "SELECT secret_id, version, action, tags, metadata, encrypted \
FROM secrets_history \ FROM secrets_history \
WHERE namespace = $1 AND kind = $2 AND name = $3 \ WHERE namespace = $1 AND kind = $2 AND name = $3 \
ORDER BY id DESC LIMIT 1", ORDER BY id DESC LIMIT 1",
@@ -234,7 +228,7 @@ pub async fn list_history(
r.version, r.version,
r.action, r.action,
r.actor, 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 <N>` to restore)"); println!(" (use `secrets rollback --to-version <N>` to restore)");

View File

@@ -3,7 +3,7 @@ use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::HashMap; use std::collections::HashMap;
use crate::commands::search::build_env_map; use crate::commands::search::build_injected_env_map;
use crate::output::OutputMode; use crate::output::OutputMode;
pub struct InjectArgs<'a> { pub struct InjectArgs<'a> {
@@ -48,7 +48,7 @@ pub async fn collect_env_map(
} }
let mut map = HashMap::new(); let mut map = HashMap::new();
for row in &rows { 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 { for (k, v) in row_map {
map.insert(k, v); map.insert(k, v);
} }

View File

@@ -5,7 +5,7 @@ use std::collections::HashMap;
use crate::crypto; use crate::crypto;
use crate::models::Secret; use crate::models::Secret;
use crate::output::OutputMode; use crate::output::{OutputMode, format_local_time};
pub struct SearchArgs<'a> { pub struct SearchArgs<'a> {
pub namespace: Option<&'a str>, pub namespace: Option<&'a str>,
@@ -22,7 +22,9 @@ pub struct SearchArgs<'a> {
pub output: OutputMode, 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( let rows = fetch_rows_paged(
pool, pool,
PagedFetchArgs { 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 // -f/--field: extract specific field values directly
if !args.fields.is_empty() { if !args.fields.is_empty() {
return print_fields(&rows, args.fields, master_key); return print_fields(&rows, args.fields);
} }
match args.output { match args.output {
OutputMode::Json | OutputMode::JsonCompact => { OutputMode::Json | OutputMode::JsonCompact => {
let arr: Vec<Value> = rows let arr: Vec<Value> = rows.iter().map(|r| to_json(r, args.summary)).collect();
.iter()
.map(|r| to_json(r, args.show_secrets, args.summary, master_key))
.collect();
let out = if args.output == OutputMode::Json { let out = if args.output == OutputMode::Json {
serde_json::to_string_pretty(&arr)? serde_json::to_string_pretty(&arr)?
} else { } else {
@@ -64,7 +63,7 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
); );
} }
if let Some(row) = rows.first() { 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(); let mut pairs: Vec<(String, String)> = map.into_iter().collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0)); pairs.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in pairs { for (k, v) in pairs {
@@ -80,7 +79,7 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>, master_key: Option<&[u8; 3
return Ok(()); return Ok(());
} }
for row in &rows { 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()); println!("{} record(s) found.", rows.len());
if rows.len() == args.limit as usize { 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(()) 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. /// Fetch rows with simple equality/tag filters (no pagination). Used by inject/run.
pub async fn fetch_rows( pub async fn fetch_rows(
pool: &PgPool, pool: &PgPool,
@@ -218,16 +241,9 @@ async fn fetch_rows_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec<Se
Ok(rows) Ok(rows)
} }
/// Build a flat `KEY=VALUE` map from a record's metadata and decrypted secrets. fn env_prefix(row: &Secret, prefix: &str) -> String {
/// 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>> {
let name_part = row.name.to_uppercase().replace(['-', '.', ' '], "_"); let name_part = row.name.to_uppercase().replace(['-', '.', ' '], "_");
let effective_prefix = if prefix.is_empty() { if prefix.is_empty() {
name_part name_part
} else { } else {
format!( format!(
@@ -235,7 +251,14 @@ pub fn build_env_map(
prefix.to_uppercase().replace(['-', '.', ' '], "_"), prefix.to_uppercase().replace(['-', '.', ' '], "_"),
name_part 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(); let mut map = HashMap::new();
@@ -250,9 +273,19 @@ pub fn build_env_map(
} }
} }
if let Some(master_key) = master_key map
&& !row.encrypted.is_empty() }
{
/// 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)?; let decrypted = crypto::decrypt_json(master_key, &row.encrypted)?;
if let Some(enc) = decrypted.as_object() { if let Some(enc) = decrypted.as_object() {
for (k, v) in enc { 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 to_json(row: &Secret, summary: bool) -> Value {
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 {
if summary { if summary {
let desc = row let desc = row
.metadata .metadata
@@ -319,11 +336,8 @@ fn to_json(
}); });
} }
let secrets_val = if show_secrets { let secrets_val = if row.encrypted.is_empty() {
match try_decrypt(row, master_key) { Value::Object(Default::default())
Ok(v) => v,
Err(e) => json!({"_error": e.to_string()}),
}
} else { } else {
json!({"_encrypted": true}) json!({"_encrypted": true})
}; };
@@ -342,12 +356,7 @@ fn to_json(
}) })
} }
fn print_text( fn print_text(row: &Secret, summary: bool) -> Result<()> {
row: &Secret,
show_secrets: bool,
summary: bool,
master_key: Option<&[u8; 32]>,
) -> Result<()> {
println!("[{}/{}] {}", row.namespace, row.kind, row.name); println!("[{}/{}] {}", row.namespace, row.kind, row.name);
if summary { if summary {
let desc = row let desc = row
@@ -360,10 +369,7 @@ fn print_text(
println!(" tags: [{}]", row.tags.join(", ")); println!(" tags: [{}]", row.tags.join(", "));
} }
println!(" desc: {}", desc); println!(" desc: {}", desc);
println!( println!(" updated: {}", format_local_time(row.updated_at));
" updated: {}",
row.updated_at.format("%Y-%m-%d %H:%M:%S UTC")
);
} else { } else {
println!(" id: {}", row.id); println!(" id: {}", row.id);
if !row.tags.is_empty() { if !row.tags.is_empty() {
@@ -376,61 +382,33 @@ fn print_text(
); );
} }
if !row.encrypted.is_empty() { if !row.encrypted.is_empty() {
if show_secrets { println!(" secrets: [encrypted] (use `secrets inject` or `secrets run`)");
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!(" created: {}", format_local_time(row.created_at));
println!(" secrets: [encrypted] (--show-secrets to reveal)");
}
}
println!(
" created: {}",
row.created_at.format("%Y-%m-%d %H:%M:%S UTC")
);
} }
println!(); println!();
Ok(()) Ok(())
} }
/// Extract one or more field paths like `metadata.url` or `secret.token`. /// Extract one or more field paths like `metadata.url`.
fn print_fields(rows: &[Secret], fields: &[String], master_key: Option<&[u8; 32]>) -> Result<()> { fn print_fields(rows: &[Secret], fields: &[String]) -> Result<()> {
for row in rows { 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 { for field in fields {
let val = extract_field(row, field, decrypted.as_ref())?; let val = extract_field(row, field)?;
println!("{}", val); println!("{}", val);
} }
} }
Ok(()) Ok(())
} }
fn extract_field(row: &Secret, field: &str, decrypted: Option<&Value>) -> Result<String> { fn extract_field(row: &Secret, field: &str) -> Result<String> {
let (section, key) = field.split_once('.').ok_or_else(|| { let (section, key) = field
anyhow::anyhow!( .split_once('.')
"Invalid field path '{}'. Use metadata.<key> or secret.<key>", .ok_or_else(|| anyhow::anyhow!("Invalid field path '{}'. Use metadata.<key>.", field))?;
field
)
})?;
let obj = match section { let obj = match section {
"metadata" | "meta" => &row.metadata, "metadata" | "meta" => &row.metadata,
"secret" | "secrets" | "encrypted" => { other => anyhow::bail!("Unknown field section '{}'. Use 'metadata'.", other),
decrypted.ok_or_else(|| anyhow::anyhow!("secret field requires master key"))?
}
other => anyhow::bail!(
"Unknown field section '{}'. Use 'metadata' or 'secret'",
other
),
}; };
obj.get(key) 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")
);
}
}

View File

@@ -3,6 +3,7 @@ use flate2::read::GzDecoder;
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::io::{Cursor, Read, Write}; use std::io::{Cursor, Read, Write};
use std::time::Duration;
const GITEA_API: &str = "https://gitea.refining.dev/api/v1/repos/refining/secrets/releases/latest"; const GITEA_API: &str = "https://gitea.refining.dev/api/v1/repos/refining/secrets/releases/latest";
@@ -28,13 +29,14 @@ fn available_assets(assets: &[Asset]) -> String {
.join(", ") .join(", ")
} }
fn find_asset_by_suffix<'a>(assets: &'a [Asset], suffix: &str) -> Result<&'a Asset> { fn release_asset_name(tag_name: &str, suffix: &str) -> String {
assets format!("secrets-{tag_name}-{suffix}")
.iter() }
.find(|a| a.name.ends_with(suffix))
.with_context(|| { fn find_asset_by_name<'a>(assets: &'a [Asset], name: &str) -> Result<&'a Asset> {
assets.iter().find(|a| a.name == name).with_context(|| {
format!( format!(
"no asset found for this platform (looking for suffix: {suffix})\navailable: {}", "no matching release asset found: {name}\navailable: {}",
available_assets(assets) available_assets(assets)
) )
}) })
@@ -89,6 +91,22 @@ fn sha256_hex(bytes: &[u8]) -> String {
format!("{digest:x}") format!("{digest:x}")
} }
fn verify_checksum(asset_name: &str, archive: &[u8], checksum_contents: &str) -> Result<String> {
let expected_checksum = parse_checksum_file(checksum_contents)?;
let actual_checksum = sha256_hex(archive);
if actual_checksum != expected_checksum {
bail!(
"checksum verification failed for {}: expected {}, got {}",
asset_name,
expected_checksum,
actual_checksum
);
}
Ok(actual_checksum)
}
fn parse_checksum_file(contents: &str) -> Result<String> { fn parse_checksum_file(contents: &str) -> Result<String> {
let checksum = contents let checksum = contents
.split_whitespace() .split_whitespace()
@@ -163,6 +181,8 @@ pub async fn run(check_only: bool) -> Result<()> {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.user_agent(format!("secrets-cli/{CURRENT_VERSION}")) .user_agent(format!("secrets-cli/{CURRENT_VERSION}"))
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(120))
.build() .build()
.context("failed to build HTTP client")?; .context("failed to build HTTP client")?;
@@ -192,18 +212,10 @@ pub async fn run(check_only: bool) -> Result<()> {
} }
let suffix = platform_asset_suffix()?; let suffix = platform_asset_suffix()?;
let asset = find_asset_by_suffix(&release.assets, suffix)?; let asset_name = release_asset_name(&release.tag_name, suffix);
let asset = find_asset_by_name(&release.assets, &asset_name)?;
let checksum_name = format!("{}.sha256", asset.name); let checksum_name = format!("{}.sha256", asset.name);
let checksum_asset = release let checksum_asset = find_asset_by_name(&release.assets, &checksum_name)?;
.assets
.iter()
.find(|a| a.name == checksum_name)
.with_context(|| {
format!(
"missing checksum asset for download: {checksum_name}\navailable: {}",
available_assets(&release.assets)
)
})?;
println!("Downloading {}...", asset.name); println!("Downloading {}...", asset.name);
@@ -214,19 +226,11 @@ pub async fn run(check_only: bool) -> Result<()> {
"checksum download", "checksum download",
) )
.await?; .await?;
let expected_checksum = parse_checksum_file( let actual_checksum = verify_checksum(
&asset.name,
&archive,
std::str::from_utf8(&checksum_contents).context("checksum file is not valid UTF-8")?, std::str::from_utf8(&checksum_contents).context("checksum file is not valid UTF-8")?,
)?; )?;
let actual_checksum = sha256_hex(&archive);
if actual_checksum != expected_checksum {
bail!(
"checksum verification failed for {}: expected {}, got {}",
asset.name,
expected_checksum,
actual_checksum
);
}
println!("Verified SHA-256: {actual_checksum}"); println!("Verified SHA-256: {actual_checksum}");
@@ -298,6 +302,33 @@ mod tests {
assert!(err.to_string().contains("invalid SHA-256 checksum format")); assert!(err.to_string().contains("invalid SHA-256 checksum format"));
} }
#[test]
fn release_asset_name_matches_release_tag() {
assert_eq!(
release_asset_name("secrets-0.7.0", "x86_64-linux-musl.tar.gz"),
"secrets-secrets-0.7.0-x86_64-linux-musl.tar.gz"
);
}
#[test]
fn find_asset_by_name_rejects_stale_platform_match() {
let assets = vec![
Asset {
name: "secrets-secrets-0.6.9-x86_64-linux-musl.tar.gz".into(),
browser_download_url: "https://example.invalid/old".into(),
},
Asset {
name: "secrets-secrets-0.7.0-aarch64-macos.tar.gz".into(),
browser_download_url: "https://example.invalid/other".into(),
},
];
let err = find_asset_by_name(&assets, "secrets-secrets-0.7.0-x86_64-linux-musl.tar.gz")
.expect_err("stale asset should not match");
assert!(err.to_string().contains("no matching release asset found"));
}
#[test] #[test]
fn sha256_hex_matches_known_value() { fn sha256_hex_matches_known_value() {
assert_eq!( assert_eq!(
@@ -306,6 +337,18 @@ mod tests {
); );
} }
#[test]
fn verify_checksum_rejects_mismatch() {
let err = verify_checksum(
"secrets.tar.gz",
b"abc",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef secrets.tar.gz",
)
.expect_err("checksum mismatch should fail");
assert!(err.to_string().contains("checksum verification failed"));
}
#[test] #[test]
fn extract_from_targz_reads_binary() { fn extract_from_targz_reads_binary() {
let payload = b"fake-secrets-binary"; let payload = b"fake-secrets-binary";

View File

@@ -105,15 +105,6 @@ pub fn store_master_key(key: &[u8; 32]) -> Result<()> {
Ok(()) Ok(())
} }
/// Delete the Master Key from the OS Keychain (used by tests / reset).
#[cfg(test)]
pub fn delete_master_key() -> Result<()> {
let entry =
keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).context("create keychain entry")?;
let _ = entry.delete_credential();
Ok(())
}
// ─── Minimal hex helpers (avoid extra dep) ──────────────────────────────────── // ─── Minimal hex helpers (avoid extra dep) ────────────────────────────────────
mod hex { mod hex {

View File

@@ -28,13 +28,16 @@ use output::resolve_output_mode;
secrets search --summary --limit 20 secrets search --summary --limit 20
# Precise lookup (JSON output for easy parsing) # 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 # Extract a single metadata field directly
secrets search -n refining --kind service --name gitea -f secret.token secrets search -n refining --kind service --name gitea -f metadata.url
# Pipe-friendly (non-TTY defaults to json-compact automatically) # 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 { struct Cli {
/// Database URL, overrides saved config (one-time override) /// Database URL, overrides saved config (one-time override)
@@ -129,19 +132,19 @@ EXAMPLES:
# Fuzzy keyword search (matches name, namespace, kind, tags, metadata) # Fuzzy keyword search (matches name, namespace, kind, tags, metadata)
secrets search -q mqtt secrets search -q mqtt
# Extract a single field value (implies --show-secrets for secret.*) # Extract a single metadata field value
secrets search -n refining --kind service --name gitea -f secret.token
secrets search -n refining --kind service --name gitea -f metadata.url secrets search -n refining --kind service --name gitea -f metadata.url
# Multiple fields at once # Multiple fields at once
secrets search -n refining --kind service --name gitea \\ 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) # Export metadata as env vars (single record only)
secrets search -n refining --kind service --name gitea -o json --show-secrets secrets search -n refining --kind service --name gitea -o env
# Export as env vars (source-able; single record only) # Inject decrypted secrets only when needed
secrets search -n refining --kind service --name gitea -o env --show-secrets secrets inject -n refining --kind service --name gitea
secrets run -n refining --kind service --name gitea -- printenv
# Paginate large result sets # Paginate large result sets
secrets search -n refining --summary --limit 10 --offset 0 secrets search -n refining --summary --limit 10 --offset 0
@@ -151,8 +154,7 @@ EXAMPLES:
secrets search --sort updated --limit 5 --summary secrets search --sort updated --limit 5 --summary
# Non-TTY / pipe: output is json-compact by default # Non-TTY / pipe: output is json-compact by default
secrets search -n refining --kind service | jq '.[].name' secrets search -n refining --kind service | jq '.[].name'")]
secrets search -n refining --kind service --name gitea --show-secrets | jq '.secrets.token'")]
Search { Search {
/// Filter by namespace, e.g. refining, ricnsmart /// Filter by namespace, e.g. refining, ricnsmart
#[arg(short, long)] #[arg(short, long)]
@@ -169,10 +171,10 @@ EXAMPLES:
/// Fuzzy keyword (matches name, namespace, kind, tags, metadata text) /// Fuzzy keyword (matches name, namespace, kind, tags, metadata text)
#[arg(short, long)] #[arg(short, long)]
query: Option<String>, query: Option<String>,
/// Reveal encrypted secret values in output /// Deprecated: search never reveals secrets; use inject/run instead
#[arg(long)] #[arg(long)]
show_secrets: bool, show_secrets: bool,
/// Extract field value(s) directly: metadata.<key> or secret.<key> (repeatable) /// Extract metadata field value(s) directly: metadata.<key> (repeatable)
#[arg(short = 'f', long = "field")] #[arg(short = 'f', long = "field")]
fields: Vec<String>, fields: Vec<String>,
/// Return lightweight summary only (namespace, kind, name, tags, desc, updated_at) /// Return lightweight summary only (namespace, kind, name, tags, desc, updated_at)
@@ -501,9 +503,7 @@ async fn main() -> Result<()> {
sort, sort,
output, output,
} => { } => {
let master_key = crypto::load_master_key()?;
let _span = tracing::info_span!("cmd", command = "search").entered(); 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())?; let out = resolve_output_mode(output.as_deref())?;
commands::search::run( commands::search::run(
&pool, &pool,
@@ -513,7 +513,7 @@ async fn main() -> Result<()> {
name: name.as_deref(), name: name.as_deref(),
tags: &tag, tags: &tag,
query: query.as_deref(), query: query.as_deref(),
show_secrets: show, show_secrets,
fields: &fields, fields: &fields,
summary, summary,
limit, limit,
@@ -521,7 +521,6 @@ async fn main() -> Result<()> {
sort: &sort, sort: &sort,
output: out, output: out,
}, },
Some(&master_key),
) )
.await?; .await?;
} }

View File

@@ -1,3 +1,4 @@
use chrono::{DateTime, Local, Utc};
use std::io::IsTerminal; use std::io::IsTerminal;
use std::str::FromStr; use std::str::FromStr;
@@ -45,3 +46,10 @@ pub fn resolve_output_mode(explicit: Option<&str>) -> anyhow::Result<OutputMode>
Ok(OutputMode::JsonCompact) Ok(OutputMode::JsonCompact)
} }
} }
/// Format a UTC timestamp for local human-readable output.
pub fn format_local_time(dt: DateTime<Utc>) -> String {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M:%S %:z")
.to_string()
}