Compare commits
5 Commits
secrets-0.
...
secrets-0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efa76cae55 | ||
|
|
5a5867adc1 | ||
|
|
4ddafbe4b6 | ||
|
|
6ea9f0861b | ||
|
|
3973295d6a |
@@ -7,7 +7,6 @@ on:
|
||||
- 'src/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- '.gitea/workflows/secrets.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -56,6 +55,13 @@ jobs:
|
||||
echo "将创建新版本 ${tag}"
|
||||
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
|
||||
if: steps.ver.outputs.tag_exists == 'false'
|
||||
run: |
|
||||
@@ -327,11 +333,14 @@ jobs:
|
||||
- name: 安装依赖
|
||||
shell: pwsh
|
||||
run: |
|
||||
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
|
||||
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
|
||||
Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile rustup-init.exe
|
||||
.\rustup-init.exe -y --default-toolchain stable
|
||||
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
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
8
.vscode/tasks.json
vendored
8
.vscode/tasks.json
vendored
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
66
AGENTS.md
66
AGENTS.md
@@ -1,5 +1,12 @@
|
||||
# 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)。
|
||||
|
||||
## 项目结构
|
||||
@@ -16,7 +23,7 @@ secrets/
|
||||
audit.rs # 审计写入:log_tx(事务内)/ log(池,保留备用)
|
||||
commands/
|
||||
init.rs # init 命令:主密钥初始化(每台设备一次)
|
||||
add.rs # add 命令:upsert,事务化,含历史快照,支持 key:=json 类型化值
|
||||
add.rs # add 命令:upsert,事务化,含历史快照,支持 key:=json 类型化值与嵌套路径写入
|
||||
config.rs # config 命令:set-db / show / path(持久化 database_url)
|
||||
search.rs # search 命令:多条件查询,公开 fetch_rows / build_env_map
|
||||
delete.rs # delete 命令:事务化,含历史快照
|
||||
@@ -25,6 +32,7 @@ secrets/
|
||||
run.rs # inject / run 命令:临时环境变量注入
|
||||
upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制
|
||||
scripts/
|
||||
release-check.sh # 发版前检查版本号/tag 是否重复,并执行 fmt/clippy/test
|
||||
setup-gitea-actions.sh # 配置 Gitea Actions 变量与 Secrets
|
||||
.gitea/workflows/
|
||||
secrets.yml # CI:fmt + clippy + musl 构建 + Release 上传 + 飞书通知
|
||||
@@ -168,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(分页偏移)
|
||||
@@ -185,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
|
||||
@@ -211,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
|
||||
```
|
||||
|
||||
@@ -228,8 +238,8 @@ secrets search -n refining --kind service --name gitea -o env --show-secrets \
|
||||
# --kind server | service
|
||||
# --name gitea | i-uf63f2uookgs5uxmrdyc
|
||||
# --tag aliyun | hongkong(可重复)
|
||||
# -m / --meta ip=47.117.131.22 | desc="Aliyun ECS" | url=https://...(可重复)
|
||||
# -s / --secret token=<value> | ssh_key=@./key.pem | password=secret123(可重复)
|
||||
# -m / --meta ip=47.117.131.22 | desc="Aliyun ECS" | url=https://... | tls:cert@./cert.pem(可重复)
|
||||
# -s / --secret token=<value> | ssh_key=@./key.pem | password=secret123 | credentials:content@./key.pem(可重复)
|
||||
|
||||
# 添加服务器
|
||||
secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||
@@ -248,6 +258,10 @@ secrets add -n ricnsmart --kind service --name mqtt \
|
||||
-m host=mqtt.ricnsmart.com -m port=1883 \
|
||||
-s password=@./mqtt_password.txt
|
||||
|
||||
# 多行文件直接写入嵌套 secret 字段
|
||||
secrets add -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||
-s credentials:content@./keys/voson_shanghai_e.pem
|
||||
|
||||
# 使用类型化值(key:=<json>)存储非字符串类型
|
||||
secrets add -n refining --kind service --name prometheus \
|
||||
-m scrape_interval:=15 \
|
||||
@@ -269,10 +283,10 @@ secrets add -n refining --kind service --name prometheus \
|
||||
# --name gitea | i-uf63f2uookgs5uxmrdyc
|
||||
# --add-tag production | backup(不影响已有 tag,可重复)
|
||||
# --remove-tag staging | deprecated(可重复)
|
||||
# -m / --meta ip=10.0.0.1 | desc="新描述"(新增或覆盖,可重复)
|
||||
# --remove-meta old_port | legacy_key(删除 metadata 字段,可重复)
|
||||
# -s / --secret token=<new> | ssh_key=@./new.pem(新增或覆盖,可重复)
|
||||
# --remove-secret old_password | deprecated_key(删除 secret 字段,可重复)
|
||||
# -m / --meta ip=10.0.0.1 | desc="新描述" | credentials:username=root(新增或覆盖,可重复)
|
||||
# --remove-meta old_port | legacy_key | credentials:content(删除 metadata 字段,可重复)
|
||||
# -s / --secret token=<new> | ssh_key=@./new.pem | credentials:content@./new.pem(新增或覆盖,可重复)
|
||||
# --remove-secret old_password | deprecated_key | credentials:content(删除 secret 字段,可重复)
|
||||
|
||||
# 更新单个 metadata 字段
|
||||
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||
@@ -291,6 +305,14 @@ secrets update -n refining --kind service --name gitea \
|
||||
secrets update -n refining --kind service --name mqtt \
|
||||
--remove-meta old_port --remove-secret old_password
|
||||
|
||||
# 从文件更新嵌套 secret 字段
|
||||
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||
-s credentials:content@./keys/voson_shanghai_e.pem
|
||||
|
||||
# 删除嵌套字段
|
||||
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
|
||||
--remove-secret credentials:content
|
||||
|
||||
# 移除 tag
|
||||
secrets update -n refining --kind service --name gitea --remove-tag staging
|
||||
```
|
||||
@@ -458,7 +480,7 @@ secrets --db-url "postgres://..." search -n refining
|
||||
- 新增 `kind` 类型时:只需在 `add` 调用时传入,无需改代码
|
||||
- 字段命名:CLI 短标志 `-n`=namespace,`-m`=meta,`-s`=secret,`-q`=query,`-v`=verbose,`-f`=field,`-o`=output
|
||||
- 日志:用户可见输出用 `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 钥匙串加载)
|
||||
- 输出:读命令通过 `OutputMode` 支持 text/json/json-compact/env;写命令 `add` 同样支持 `-o json`
|
||||
|
||||
@@ -466,6 +488,14 @@ secrets --db-url "postgres://..." search -n refining
|
||||
|
||||
每次提交代码前,请在本地依次执行以下检查,**全部通过后再 push**:
|
||||
|
||||
优先使用:
|
||||
|
||||
```bash
|
||||
./scripts/release-check.sh
|
||||
```
|
||||
|
||||
它等价于先检查版本号 / tag,再执行下面的格式、Lint、测试。
|
||||
|
||||
### 1. 版本号(按需)
|
||||
|
||||
若本次改动需要发版,请先确认 `Cargo.toml` 中的 `version` 已提升,避免 CI 打出的 Tag 与已有版本重复。**升级版本后需同时更新 `Cargo.lock`**(运行 `cargo build` 即可自动同步),否则 CI 中 `cargo clippy --locked` 会因 lock 与 manifest 不一致而失败。可通过 git tag 判断:
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1836,7 +1836,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "secrets"
|
||||
version = "0.7.0"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secrets"
|
||||
version = "0.7.0"
|
||||
version = "0.7.4"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
138
README.md
138
README.md
@@ -19,11 +19,11 @@ cargo build --release
|
||||
# 1. 配置数据库连接(会先验证连接可用再写入)
|
||||
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets"
|
||||
|
||||
# 2. 初始化主密钥(提示输入主密码,派生后存入 OS 钥匙串)
|
||||
# 2. 初始化主密钥(提示输入至少 8 位的主密码,派生后存入 OS 钥匙串)
|
||||
secrets init
|
||||
```
|
||||
|
||||
主密码不会存储,仅用于派生主密钥。同一主密码在所有设备上会得到相同主密钥(salt 存于数据库,首台设备生成后共享)。
|
||||
主密码不会存储,仅用于派生主密钥,且至少需 8 位。同一主密码在所有设备上会得到相同主密钥(salt 存于数据库,首台设备生成后共享)。
|
||||
|
||||
**主密钥存储**:macOS → Keychain;Windows → Credential Manager;Linux → keyutils(会话级,重启后需再次 `secrets init`)。
|
||||
|
||||
@@ -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 # 翻页
|
||||
|
||||
@@ -117,6 +126,17 @@ secrets add -n refining --kind server --name my-server \
|
||||
-m ip=47.117.131.22 -m desc="Aliyun Shanghai ECS" \
|
||||
-s username=root -s ssh_key=@./keys/server.pem
|
||||
|
||||
# 多行文件直接写入嵌套 secret 字段
|
||||
secrets add -n refining --kind server --name my-server \
|
||||
-s credentials:content@./keys/server.pem
|
||||
|
||||
# 使用 typed JSON 写入 secret(布尔、数字、数组、对象)
|
||||
secrets add -n refining --kind service --name deploy-bot \
|
||||
-s enabled:=true \
|
||||
-s retry_count:=3 \
|
||||
-s scopes:='["repo","workflow"]' \
|
||||
-s extra:='{"region":"ap-east-1","verify_tls":true}'
|
||||
|
||||
secrets add -n refining --kind service --name gitea \
|
||||
--tag gitea \
|
||||
-m url=https://gitea.refining.dev -m default_org=refining \
|
||||
@@ -126,12 +146,13 @@ secrets add -n refining --kind service --name gitea \
|
||||
secrets update -n refining --kind server --name my-server -m ip=10.0.0.1
|
||||
secrets update -n refining --kind service --name gitea --add-tag production -s token=<new>
|
||||
secrets update -n refining --kind service --name mqtt --remove-meta old_port --remove-secret old_key
|
||||
secrets update -n refining --kind server --name my-server --remove-secret credentials:content
|
||||
|
||||
# ── delete ───────────────────────────────────────────────────────────────────
|
||||
secrets delete -n refining --kind service --name legacy-mqtt
|
||||
|
||||
# ── init ─────────────────────────────────────────────────────────────────────
|
||||
secrets init # 主密钥初始化(每台设备一次,主密码派生后存钥匙串)
|
||||
secrets init # 主密钥初始化(每台设备一次,主密码至少 8 位,派生后存钥匙串)
|
||||
|
||||
# ── config ───────────────────────────────────────────────────────────────────
|
||||
secrets config set-db "postgres://postgres:<password>@<host>:<port>/secrets" # 先验证再写入
|
||||
@@ -160,7 +181,90 @@ RUST_LOG=secrets=trace secrets search
|
||||
| `metadata` | 明文描述信息(ip、desc、domains 等) |
|
||||
| `encrypted` | 敏感凭据(ssh_key、password、token 等),AES-256-GCM 加密存储 |
|
||||
|
||||
`-m` / `--meta` 写入 `metadata`,`-s` / `--secret` 写入 `encrypted`,`value=@file` 从文件读取内容。加解密使用主密钥(由 `secrets init` 设置)。
|
||||
`-m` / `--meta` 写入 `metadata`,`-s` / `--secret` 写入 `encrypted`。支持 `key=value`、`key=@file`、`key:=<json>`,也支持 `credentials:content@./key.pem` 这种嵌套字段文件写入语法,避免手动转义多行文本;删除时也支持 `--remove-secret credentials:content` 和 `--remove-meta credentials:content`。加解密使用主密钥(由 `secrets init` 设置)。
|
||||
|
||||
### `-m` / `--meta` JSON 语法速查
|
||||
|
||||
`-m` 和 `-s` 走的是同一套解析规则,只是写入位置不同:`-m` 写到明文 `metadata`,适合端口、开关、标签、描述性配置等非敏感信息。
|
||||
|
||||
| 目标值 | 写法示例 | 实际存入 |
|
||||
|------|------|------|
|
||||
| 普通字符串 | `-m url=https://gitea.refining.dev` | `"https://gitea.refining.dev"` |
|
||||
| 文件内容字符串 | `-m notes=@./service-notes.txt` | `"..."` |
|
||||
| 布尔值 | `-m enabled:=true` | `true` |
|
||||
| 数字 | `-m port:=3000` | `3000` |
|
||||
| `null` | `-m deprecated_at:=null` | `null` |
|
||||
| 数组 | `-m domains:='["gitea.refining.dev","git.refining.dev"]'` | `["gitea.refining.dev","git.refining.dev"]` |
|
||||
| 对象 | `-m tls:='{"enabled":true,"redirect_http":true}'` | `{"enabled":true,"redirect_http":true}` |
|
||||
| 嵌套路径 + JSON | `-m deploy:strategy:='{"type":"rolling","batch":2}'` | `{"deploy":{"strategy":{"type":"rolling","batch":2}}}` |
|
||||
|
||||
常见规则:
|
||||
|
||||
- `=` 表示按字符串存储。
|
||||
- `:=` 表示按 JSON 解析。
|
||||
- shell 中数组和对象建议整体用单引号包住。
|
||||
- 嵌套字段继续用冒号分隔:`-m runtime:max_open_conns:=20`。
|
||||
|
||||
示例:新增一条带 typed metadata 的记录
|
||||
|
||||
```bash
|
||||
secrets add -n refining --kind service --name gitea \
|
||||
-m url=https://gitea.refining.dev \
|
||||
-m port:=3000 \
|
||||
-m enabled:=true \
|
||||
-m domains:='["gitea.refining.dev","git.refining.dev"]' \
|
||||
-m tls:='{"enabled":true,"redirect_http":true}'
|
||||
```
|
||||
|
||||
示例:更新已有记录中的嵌套 metadata
|
||||
|
||||
```bash
|
||||
secrets update -n refining --kind service --name gitea \
|
||||
-m deploy:strategy:='{"type":"rolling","batch":2}' \
|
||||
-m runtime:max_open_conns:=20
|
||||
```
|
||||
|
||||
### `-s` / `--secret` JSON 语法速查
|
||||
|
||||
当你希望写入的不是普通字符串,而是 `true`、`123`、`null`、数组或对象时,用 `:=`,右侧按 JSON 解析。
|
||||
|
||||
| 目标值 | 写法示例 | 实际存入 |
|
||||
|------|------|------|
|
||||
| 普通字符串 | `-s token=abc123` | `"abc123"` |
|
||||
| 文件内容字符串 | `-s ssh_key=@./id_ed25519` | `"-----BEGIN ..."` |
|
||||
| 布尔值 | `-s enabled:=true` | `true` |
|
||||
| 数字 | `-s retry_count:=3` | `3` |
|
||||
| `null` | `-s deprecated_at:=null` | `null` |
|
||||
| 数组 | `-s scopes:='["repo","workflow"]'` | `["repo","workflow"]` |
|
||||
| 对象 | `-s extra:='{"region":"ap-east-1","verify_tls":true}'` | `{"region":"ap-east-1","verify_tls":true}` |
|
||||
| 嵌套路径 + JSON | `-s auth:policy:='{"mfa":true,"ttl":3600}'` | `{"auth":{"policy":{"mfa":true,"ttl":3600}}}` |
|
||||
|
||||
常见规则:
|
||||
|
||||
- `=` 表示按字符串存储,不做 JSON 解析。
|
||||
- `:=` 表示按 JSON 解析,适合布尔、数字、数组、对象、`null`。
|
||||
- shell 里对象和数组通常要整体加引号,推荐单引号:`-s flags:='["a","b"]'`。
|
||||
- 嵌套字段继续用冒号分隔:`-s credentials:enabled:=true`。
|
||||
- 如果你就是想存一个“JSON 字符串字面量”,可以写成 `-s note:='"hello"'`,但大多数字符串场景直接用 `=` 更直观。
|
||||
|
||||
示例:新增一条同时包含字符串、文件、布尔、数组、对象的记录
|
||||
|
||||
```bash
|
||||
secrets add -n refining --kind service --name deploy-bot \
|
||||
-s token=abc123 \
|
||||
-s ssh_key=@./keys/deploy-bot.pem \
|
||||
-s enabled:=true \
|
||||
-s scopes:='["repo","workflow"]' \
|
||||
-s policy:='{"ttl":3600,"mfa":true}'
|
||||
```
|
||||
|
||||
示例:更新已有记录中的嵌套 JSON 字段
|
||||
|
||||
```bash
|
||||
secrets update -n refining --kind service --name deploy-bot \
|
||||
-s auth:config:='{"issuer":"gitea","rotate":true}' \
|
||||
-s auth:retry:=5
|
||||
```
|
||||
|
||||
## 审计日志
|
||||
|
||||
|
||||
23
scripts/release-check.sh
Executable file
23
scripts/release-check.sh
Executable 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
|
||||
34
src/audit.rs
34
src/audit.rs
@@ -1,5 +1,5 @@
|
||||
use serde_json::Value;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
use sqlx::{Postgres, Transaction};
|
||||
|
||||
/// Write an audit entry within an existing transaction.
|
||||
pub async fn log_tx(
|
||||
@@ -30,35 +30,3 @@ pub async fn log_tx(
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ use crate::crypto;
|
||||
use crate::db;
|
||||
use crate::output::OutputMode;
|
||||
|
||||
/// Parse "key=value" or "key:=<json>" entries.
|
||||
/// - `key=value` → stores the literal string `value`
|
||||
/// - `key:=<json>` → parses `<json>` as a typed JSON value (number, bool, null, array, object)
|
||||
/// - `value=@file` → reads the file content as a string (only for `=` form)
|
||||
pub(crate) fn parse_kv(entry: &str) -> Result<(String, Value)> {
|
||||
/// Parse secret / metadata entries into a nested key path and JSON value.
|
||||
/// - `key=value` → stores the literal string `value`
|
||||
/// - `key:=<json>` → parses `<json>` as a typed JSON value
|
||||
/// - `key=@file` → reads the file content as a string
|
||||
/// - `a:b=value` → writes nested fields: `{ "a": { "b": "value" } }`
|
||||
/// - `a:b@./file.txt` → shorthand for nested file reads without manual JSON escaping
|
||||
pub(crate) fn parse_kv(entry: &str) -> Result<(Vec<String>, Value)> {
|
||||
// Typed JSON form: key:=<json>
|
||||
if let Some((key, json_str)) = entry.split_once(":=") {
|
||||
let val: Value = serde_json::from_str(json_str).map_err(|e| {
|
||||
@@ -21,36 +23,141 @@ pub(crate) fn parse_kv(entry: &str) -> Result<(String, Value)> {
|
||||
e
|
||||
)
|
||||
})?;
|
||||
return Ok((key.to_string(), val));
|
||||
return Ok((parse_key_path(key)?, val));
|
||||
}
|
||||
|
||||
// Plain string form: key=value or key=@file
|
||||
let (key, raw_val) = entry.split_once('=').ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Invalid format '{}'. Expected: key=value, key=@file, or key:=<json>",
|
||||
entry
|
||||
)
|
||||
})?;
|
||||
if let Some((key, raw_val)) = entry.split_once('=') {
|
||||
let value = if let Some(path) = raw_val.strip_prefix('@') {
|
||||
fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
|
||||
} else {
|
||||
raw_val.to_string()
|
||||
};
|
||||
|
||||
let value = if let Some(path) = raw_val.strip_prefix('@') {
|
||||
fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
|
||||
} else {
|
||||
raw_val.to_string()
|
||||
};
|
||||
return Ok((parse_key_path(key)?, Value::String(value)));
|
||||
}
|
||||
|
||||
Ok((key.to_string(), Value::String(value)))
|
||||
// Shorthand file form: nested:key@file
|
||||
if let Some((key, path)) = entry.split_once('@') {
|
||||
let value = fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?;
|
||||
return Ok((parse_key_path(key)?, Value::String(value)));
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"Invalid format '{}'. Expected: key=value, key=@file, nested:key@file, or key:=<json>",
|
||||
entry
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_json(entries: &[String]) -> Result<Value> {
|
||||
let mut map = Map::new();
|
||||
for entry in entries {
|
||||
let (key, value) = parse_kv(entry)?;
|
||||
map.insert(key, value);
|
||||
let (path, value) = parse_kv(entry)?;
|
||||
insert_path(&mut map, &path, value)?;
|
||||
}
|
||||
Ok(Value::Object(map))
|
||||
}
|
||||
|
||||
pub(crate) fn key_path_to_string(path: &[String]) -> String {
|
||||
path.join(":")
|
||||
}
|
||||
|
||||
pub(crate) fn collect_key_paths(entries: &[String]) -> Result<Vec<String>> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| parse_kv(entry).map(|(path, _)| key_path_to_string(&path)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn collect_field_paths(entries: &[String]) -> Result<Vec<String>> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| parse_key_path(entry).map(|path| key_path_to_string(&path)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_key_path(key: &str) -> Result<Vec<String>> {
|
||||
let path: Vec<String> = key
|
||||
.split(':')
|
||||
.map(str::trim)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
|
||||
if path.is_empty() || path.iter().any(|part| part.is_empty()) {
|
||||
anyhow::bail!(
|
||||
"Invalid key path '{}'. Use non-empty segments like 'credentials:content'.",
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub(crate) fn insert_path(
|
||||
map: &mut Map<String, Value>,
|
||||
path: &[String],
|
||||
value: Value,
|
||||
) -> Result<()> {
|
||||
if path.is_empty() {
|
||||
anyhow::bail!("Key path cannot be empty");
|
||||
}
|
||||
|
||||
if path.len() == 1 {
|
||||
map.insert(path[0].clone(), value);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let head = path[0].clone();
|
||||
let tail = &path[1..];
|
||||
|
||||
match map.entry(head.clone()) {
|
||||
serde_json::map::Entry::Vacant(entry) => {
|
||||
let mut child = Map::new();
|
||||
insert_path(&mut child, tail, value)?;
|
||||
entry.insert(Value::Object(child));
|
||||
}
|
||||
serde_json::map::Entry::Occupied(mut entry) => match entry.get_mut() {
|
||||
Value::Object(child) => insert_path(child, tail, value)?,
|
||||
_ => {
|
||||
anyhow::bail!(
|
||||
"Cannot set nested key '{}' because '{}' is already a non-object value",
|
||||
key_path_to_string(path),
|
||||
head
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn remove_path(map: &mut Map<String, Value>, path: &[String]) -> Result<bool> {
|
||||
if path.is_empty() {
|
||||
anyhow::bail!("Key path cannot be empty");
|
||||
}
|
||||
|
||||
if path.len() == 1 {
|
||||
return Ok(map.remove(&path[0]).is_some());
|
||||
}
|
||||
|
||||
let Some(value) = map.get_mut(&path[0]) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let Value::Object(child) = value else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let removed = remove_path(child, &path[1..])?;
|
||||
if child.is_empty() {
|
||||
map.remove(&path[0]);
|
||||
}
|
||||
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub struct AddArgs<'a> {
|
||||
pub namespace: &'a str,
|
||||
pub kind: &'a str,
|
||||
@@ -68,16 +175,8 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
|
||||
|
||||
tracing::debug!(args.namespace, args.kind, args.name, "upserting record");
|
||||
|
||||
let meta_keys: Vec<&str> = args
|
||||
.meta_entries
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
|
||||
.collect();
|
||||
let secret_keys: Vec<&str> = args
|
||||
.secret_entries
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
|
||||
.collect();
|
||||
let meta_keys = collect_key_paths(args.meta_entries)?;
|
||||
let secret_keys = collect_key_paths(args.secret_entries)?;
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
@@ -191,3 +290,77 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_json, key_path_to_string, parse_kv, remove_path};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_file_path(name: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after unix epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("secrets-{name}-{nanos}.txt"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_nested_file_shorthand() {
|
||||
let path = temp_file_path("ssh-key");
|
||||
fs::write(&path, "line1\nline2\n").expect("should write temp file");
|
||||
|
||||
let entry = format!("credentials:content@{}", path.display());
|
||||
let (path_parts, value) = parse_kv(&entry).expect("should parse nested file shorthand");
|
||||
|
||||
assert_eq!(key_path_to_string(&path_parts), "credentials:content");
|
||||
assert_eq!(value, serde_json::Value::String("line1\nline2\n".into()));
|
||||
|
||||
fs::remove_file(path).expect("should remove temp file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_nested_json_from_mixed_entries() {
|
||||
let payload = vec![
|
||||
"credentials:type=ssh".to_string(),
|
||||
"credentials:enabled:=true".to_string(),
|
||||
"username=root".to_string(),
|
||||
];
|
||||
|
||||
let value = build_json(&payload).expect("should build nested json");
|
||||
|
||||
assert_eq!(
|
||||
value,
|
||||
serde_json::json!({
|
||||
"credentials": {
|
||||
"type": "ssh",
|
||||
"enabled": true
|
||||
},
|
||||
"username": "root"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_nested_path_prunes_empty_parents() {
|
||||
let mut value = serde_json::json!({
|
||||
"credentials": {
|
||||
"content": "pem-data"
|
||||
},
|
||||
"username": "root"
|
||||
});
|
||||
|
||||
let map = match &mut value {
|
||||
Value::Object(map) => map,
|
||||
_ => panic!("expected object"),
|
||||
};
|
||||
|
||||
let removed = remove_path(map, &["credentials".to_string(), "content".to_string()])
|
||||
.expect("should remove nested field");
|
||||
|
||||
assert!(removed);
|
||||
assert_eq!(value, serde_json::json!({ "username": "root" }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,23 @@ use sqlx::PgPool;
|
||||
|
||||
use crate::{crypto, db};
|
||||
|
||||
const MIN_MASTER_PASSWORD_LEN: usize = 8;
|
||||
|
||||
pub async fn run(pool: &PgPool) -> Result<()> {
|
||||
println!("Initializing secrets master key...");
|
||||
println!();
|
||||
|
||||
// Read password (no echo)
|
||||
let password =
|
||||
rpassword::prompt_password("Enter master password: ").context("failed to read password")?;
|
||||
if password.is_empty() {
|
||||
anyhow::bail!("Master password must not be empty.");
|
||||
let password = rpassword::prompt_password(format!(
|
||||
"Enter master password (at least {} characters): ",
|
||||
MIN_MASTER_PASSWORD_LEN
|
||||
))
|
||||
.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: ")
|
||||
.context("failed to read password confirmation")?;
|
||||
|
||||
@@ -3,17 +3,11 @@ 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 {
|
||||
secret_id: Uuid,
|
||||
#[allow(dead_code)]
|
||||
namespace: String,
|
||||
#[allow(dead_code)]
|
||||
kind: String,
|
||||
#[allow(dead_code)]
|
||||
name: String,
|
||||
version: i64,
|
||||
action: 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<()> {
|
||||
let snap: Option<HistoryRow> = if let Some(ver) = args.to_version {
|
||||
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 \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 AND version = $4 \
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
@@ -46,7 +40,7 @@ pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -
|
||||
.await?
|
||||
} else {
|
||||
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 \
|
||||
WHERE namespace = $1 AND kind = $2 AND name = $3 \
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
@@ -234,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 <N>` to restore)");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ use serde_json::{Map, Value, json};
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::add::parse_kv;
|
||||
use super::add::{
|
||||
collect_field_paths, collect_key_paths, insert_path, parse_key_path, parse_kv, remove_path,
|
||||
};
|
||||
use crate::crypto;
|
||||
use crate::db;
|
||||
use crate::output::OutputMode;
|
||||
@@ -89,11 +91,12 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
_ => Map::new(),
|
||||
};
|
||||
for entry in args.meta_entries {
|
||||
let (key, value) = parse_kv(entry)?;
|
||||
meta_map.insert(key, value);
|
||||
let (path, value) = parse_kv(entry)?;
|
||||
insert_path(&mut meta_map, &path, value)?;
|
||||
}
|
||||
for key in args.remove_meta {
|
||||
meta_map.remove(key);
|
||||
let path = parse_key_path(key)?;
|
||||
remove_path(&mut meta_map, &path)?;
|
||||
}
|
||||
let metadata = Value::Object(meta_map);
|
||||
|
||||
@@ -108,11 +111,12 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
_ => Map::new(),
|
||||
};
|
||||
for entry in args.secret_entries {
|
||||
let (key, value) = parse_kv(entry)?;
|
||||
enc_map.insert(key, value);
|
||||
let (path, value) = parse_kv(entry)?;
|
||||
insert_path(&mut enc_map, &path, value)?;
|
||||
}
|
||||
for key in args.remove_secrets {
|
||||
enc_map.remove(key);
|
||||
let path = parse_key_path(key)?;
|
||||
remove_path(&mut enc_map, &path)?;
|
||||
}
|
||||
let secret_json = Value::Object(enc_map);
|
||||
let encrypted_bytes = crypto::encrypt_json(master_key, &secret_json)?;
|
||||
@@ -148,16 +152,10 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
);
|
||||
}
|
||||
|
||||
let meta_keys: Vec<&str> = args
|
||||
.meta_entries
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
|
||||
.collect();
|
||||
let secret_keys: Vec<&str> = args
|
||||
.secret_entries
|
||||
.iter()
|
||||
.filter_map(|s| s.split_once(['=', ':']).map(|(k, _)| k))
|
||||
.collect();
|
||||
let meta_keys = collect_key_paths(args.meta_entries)?;
|
||||
let remove_meta_keys = collect_field_paths(args.remove_meta)?;
|
||||
let secret_keys = collect_key_paths(args.secret_entries)?;
|
||||
let remove_secret_keys = collect_field_paths(args.remove_secrets)?;
|
||||
|
||||
crate::audit::log_tx(
|
||||
&mut tx,
|
||||
@@ -169,9 +167,9 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
"add_tags": args.add_tags,
|
||||
"remove_tags": args.remove_tags,
|
||||
"meta_keys": meta_keys,
|
||||
"remove_meta": args.remove_meta,
|
||||
"remove_meta": remove_meta_keys,
|
||||
"secret_keys": secret_keys,
|
||||
"remove_secrets": args.remove_secrets,
|
||||
"remove_secrets": remove_secret_keys,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -186,9 +184,9 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
"add_tags": args.add_tags,
|
||||
"remove_tags": args.remove_tags,
|
||||
"meta_keys": meta_keys,
|
||||
"remove_meta": args.remove_meta,
|
||||
"remove_meta": remove_meta_keys,
|
||||
"secret_keys": secret_keys,
|
||||
"remove_secrets": args.remove_secrets,
|
||||
"remove_secrets": remove_secret_keys,
|
||||
});
|
||||
|
||||
match args.output {
|
||||
@@ -210,13 +208,13 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
|
||||
println!(" +metadata: {}", meta_keys.join(", "));
|
||||
}
|
||||
if !args.remove_meta.is_empty() {
|
||||
println!(" -metadata: {}", args.remove_meta.join(", "));
|
||||
println!(" -metadata: {}", remove_meta_keys.join(", "));
|
||||
}
|
||||
if !args.secret_entries.is_empty() {
|
||||
println!(" +secrets: {}", secret_keys.join(", "));
|
||||
}
|
||||
if !args.remove_secrets.is_empty() {
|
||||
println!(" -secrets: {}", args.remove_secrets.join(", "));
|
||||
println!(" -secrets: {}", remove_secret_keys.join(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use flate2::read::GzDecoder;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
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";
|
||||
|
||||
@@ -28,16 +29,17 @@ fn available_assets(assets: &[Asset]) -> String {
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn find_asset_by_suffix<'a>(assets: &'a [Asset], suffix: &str) -> Result<&'a Asset> {
|
||||
assets
|
||||
.iter()
|
||||
.find(|a| a.name.ends_with(suffix))
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"no asset found for this platform (looking for suffix: {suffix})\navailable: {}",
|
||||
available_assets(assets)
|
||||
)
|
||||
})
|
||||
fn release_asset_name(tag_name: &str, suffix: &str) -> String {
|
||||
format!("secrets-{tag_name}-{suffix}")
|
||||
}
|
||||
|
||||
fn find_asset_by_name<'a>(assets: &'a [Asset], name: &str) -> Result<&'a Asset> {
|
||||
assets.iter().find(|a| a.name == name).with_context(|| {
|
||||
format!(
|
||||
"no matching release asset found: {name}\navailable: {}",
|
||||
available_assets(assets)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect the asset suffix for the current platform/arch at compile time.
|
||||
@@ -89,6 +91,22 @@ fn sha256_hex(bytes: &[u8]) -> String {
|
||||
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> {
|
||||
let checksum = contents
|
||||
.split_whitespace()
|
||||
@@ -163,6 +181,8 @@ pub async fn run(check_only: bool) -> Result<()> {
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(format!("secrets-cli/{CURRENT_VERSION}"))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
.context("failed to build HTTP client")?;
|
||||
|
||||
@@ -192,18 +212,10 @@ pub async fn run(check_only: bool) -> Result<()> {
|
||||
}
|
||||
|
||||
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_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == checksum_name)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"missing checksum asset for download: {checksum_name}\navailable: {}",
|
||||
available_assets(&release.assets)
|
||||
)
|
||||
})?;
|
||||
let checksum_asset = find_asset_by_name(&release.assets, &checksum_name)?;
|
||||
|
||||
println!("Downloading {}...", asset.name);
|
||||
|
||||
@@ -214,19 +226,11 @@ pub async fn run(check_only: bool) -> Result<()> {
|
||||
"checksum download",
|
||||
)
|
||||
.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")?,
|
||||
)?;
|
||||
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}");
|
||||
|
||||
@@ -298,6 +302,33 @@ mod tests {
|
||||
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]
|
||||
fn sha256_hex_matches_known_value() {
|
||||
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]
|
||||
fn extract_from_targz_reads_binary() {
|
||||
let payload = b"fake-secrets-binary";
|
||||
|
||||
@@ -105,15 +105,6 @@ pub fn store_master_key(key: &[u8; 32]) -> Result<()> {
|
||||
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) ────────────────────────────────────
|
||||
|
||||
mod hex {
|
||||
|
||||
89
src/main.rs
89
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)
|
||||
@@ -82,10 +85,28 @@ EXAMPLES:
|
||||
-m url=https://gitea.refining.dev -m default_org=refining \\
|
||||
-s token=<token>
|
||||
|
||||
# Add typed JSON metadata
|
||||
secrets add -n refining --kind service --name gitea \\
|
||||
-m port:=3000 \\
|
||||
-m enabled:=true \\
|
||||
-m domains:='[\"gitea.refining.dev\",\"git.refining.dev\"]' \\
|
||||
-m tls:='{\"enabled\":true,\"redirect_http\":true}'
|
||||
|
||||
# Add with token read from a file
|
||||
secrets add -n ricnsmart --kind service --name mqtt \\
|
||||
-m host=mqtt.ricnsmart.com -m port=1883 \\
|
||||
-s password=@./mqtt_password.txt")]
|
||||
-s password=@./mqtt_password.txt
|
||||
|
||||
# Add typed JSON secrets
|
||||
secrets add -n refining --kind service --name deploy-bot \\
|
||||
-s enabled:=true \\
|
||||
-s retry_count:=3 \\
|
||||
-s scopes:='[\"repo\",\"workflow\"]' \\
|
||||
-s extra:='{\"region\":\"ap-east-1\",\"verify_tls\":true}'
|
||||
|
||||
# Write a multiline file into a nested secret field
|
||||
secrets add -n refining --kind server --name my-server \\
|
||||
-s credentials:content@./keys/server.pem")]
|
||||
Add {
|
||||
/// Namespace, e.g. refining, ricnsmart
|
||||
#[arg(short, long)]
|
||||
@@ -99,10 +120,10 @@ EXAMPLES:
|
||||
/// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong
|
||||
#[arg(long = "tag")]
|
||||
tags: Vec<String>,
|
||||
/// Plaintext metadata: key=value (repeatable; value=@file reads from file)
|
||||
/// Plaintext metadata: key=value, key:=<json>, key=@file, or nested:path@file
|
||||
#[arg(long = "meta", short = 'm')]
|
||||
meta: Vec<String>,
|
||||
/// Secret entry: key=value (repeatable; value=@file reads from file)
|
||||
/// Secret entry: key=value, key:=<json>, key=@file, or nested:path@file
|
||||
#[arg(long = "secret", short = 's')]
|
||||
secrets: Vec<String>,
|
||||
/// Output format: text (default on TTY), json, json-compact, env
|
||||
@@ -129,19 +150,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 +172,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 +189,10 @@ EXAMPLES:
|
||||
/// Fuzzy keyword (matches name, namespace, kind, tags, metadata text)
|
||||
#[arg(short, long)]
|
||||
query: Option<String>,
|
||||
/// 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.<key> or secret.<key> (repeatable)
|
||||
/// Extract metadata field value(s) directly: metadata.<key> (repeatable)
|
||||
#[arg(short = 'f', long = "field")]
|
||||
fields: Vec<String>,
|
||||
/// Return lightweight summary only (namespace, kind, name, tags, desc, updated_at)
|
||||
@@ -225,6 +245,11 @@ EXAMPLES:
|
||||
# Rotate a secret token
|
||||
secrets update -n refining --kind service --name gitea -s token=<new-token>
|
||||
|
||||
# Update typed JSON metadata
|
||||
secrets update -n refining --kind service --name gitea \\
|
||||
-m deploy:strategy:='{\"type\":\"rolling\",\"batch\":2}' \\
|
||||
-m runtime:max_open_conns:=20
|
||||
|
||||
# Add a tag and rotate password at the same time
|
||||
secrets update -n refining --kind service --name gitea \\
|
||||
--add-tag production -s token=<new-token>
|
||||
@@ -233,8 +258,21 @@ EXAMPLES:
|
||||
secrets update -n refining --kind service --name mqtt \\
|
||||
--remove-meta old_port --remove-secret old_password
|
||||
|
||||
# Remove a nested field
|
||||
secrets update -n refining --kind server --name my-server \\
|
||||
--remove-secret credentials:content
|
||||
|
||||
# Remove a tag
|
||||
secrets update -n refining --kind service --name gitea --remove-tag staging")]
|
||||
secrets update -n refining --kind service --name gitea --remove-tag staging
|
||||
|
||||
# Update a nested secret field from a file
|
||||
secrets update -n refining --kind server --name my-server \\
|
||||
-s credentials:content@./keys/server.pem
|
||||
|
||||
# Update nested typed JSON fields
|
||||
secrets update -n refining --kind service --name deploy-bot \\
|
||||
-s auth:config:='{\"issuer\":\"gitea\",\"rotate\":true}' \\
|
||||
-s auth:retry:=5")]
|
||||
Update {
|
||||
/// Namespace, e.g. refining, ricnsmart
|
||||
#[arg(short, long)]
|
||||
@@ -251,16 +289,16 @@ EXAMPLES:
|
||||
/// Remove a tag (repeatable)
|
||||
#[arg(long = "remove-tag")]
|
||||
remove_tags: Vec<String>,
|
||||
/// Set or overwrite a metadata field: key=value (repeatable, @file supported)
|
||||
/// Set or overwrite a metadata field: key=value, key:=<json>, key=@file, or nested:path@file
|
||||
#[arg(long = "meta", short = 'm')]
|
||||
meta: Vec<String>,
|
||||
/// Delete a metadata field by key (repeatable)
|
||||
/// Delete a metadata field by key or nested path, e.g. old_port or credentials:content
|
||||
#[arg(long = "remove-meta")]
|
||||
remove_meta: Vec<String>,
|
||||
/// Set or overwrite a secret field: key=value (repeatable, @file supported)
|
||||
/// Set or overwrite a secret field: key=value, key:=<json>, key=@file, or nested:path@file
|
||||
#[arg(long = "secret", short = 's')]
|
||||
secrets: Vec<String>,
|
||||
/// Delete a secret field by key (repeatable)
|
||||
/// Delete a secret field by key or nested path, e.g. old_password or credentials:content
|
||||
#[arg(long = "remove-secret")]
|
||||
remove_secrets: Vec<String>,
|
||||
/// Output format: text (default on TTY), json, json-compact
|
||||
@@ -501,9 +539,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 +549,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 +557,6 @@ async fn main() -> Result<()> {
|
||||
sort: &sort,
|
||||
output: out,
|
||||
},
|
||||
Some(&master_key),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -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<OutputMode>
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user