Compare commits

...

3 Commits

Author SHA1 Message Date
voson
955acfe9ec feat(run): 选择性字段注入、dry-run 预览、默认 JSON 输出
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m20s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m4s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m13s
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
- run 新增 -s/--secret 字段过滤,只注入指定字段到子进程(最小权限)
- run 新增 --dry-run 模式,输出变量名与来源映射,不执行命令、不暴露值
- run 新增 -o 参数,dry-run 默认 JSON 输出
- 默认输出格式改为始终 json,移除 TTY 自动切换逻辑,-o text 供人类使用
- build_injected_env_map 签名从 &[SecretField] 改为 &[&SecretField]
- 更新 AGENTS.md、README.md、.vscode/tasks.json
- version: 0.9.5 → 0.9.6

Made-with: Cursor
2026-03-19 17:39:09 +08:00
voson
3a5ec92bf0 fix: inject/run 仅注入 secrets 字段,不含 metadata
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m36s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m15s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- build_injected_env_map 不再合并 metadata
- 删除 build_metadata_env_map 及其测试
- 更新 README、AGENTS.md 文档
- bump 版本至 0.9.5

Made-with: Cursor
2026-03-19 17:03:01 +08:00
voson
854720f10c chore: remove field_type and value_len from secrets schema
Some checks failed
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Successful in 2m34s
Secrets CLI - Build & Release / Build (macOS aarch64 + x86_64) (push) Successful in 1m3s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Successful in 1m15s
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled
- Drop field_type, value_len from secrets and secrets_history tables
- Remove infer_field_type, compute_value_len from add.rs
- Simplify search output to field names only
- Update AGENTS.md, README.md documentation

Bump version to 0.9.4

Made-with: Cursor
2026-03-19 16:48:23 +08:00
15 changed files with 330 additions and 379 deletions

8
.vscode/tasks.json vendored
View File

@@ -104,9 +104,9 @@
"dependsOn": "build" "dependsOn": "build"
}, },
{ {
"label": "test: inject service secrets", "label": "test: run service secrets",
"type": "shell", "type": "shell",
"command": "./target/debug/secrets inject -n refining --kind service --name gitea", "command": "./target/debug/secrets run -n refining --kind service --name gitea -- printenv",
"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 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", "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 '--- run secrets ---' && ./target/debug/secrets run -n test --kind demo --name roundtrip-test -- printenv && 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=@./test-fixtures/example-key.pem && 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", "command": "echo '--- add key from file ---' && ./target/debug/secrets add -n test --kind key --name test-key --tag test -s content=@./test-fixtures/example-key.pem && echo '--- verify metadata ---' && ./target/debug/secrets search -n test --kind key && echo '--- verify run ---' && ./target/debug/secrets run -n test --kind key --name test-key -- printenv && echo '--- cleanup ---' && ./target/debug/secrets delete -n test --kind key --name test-key",
"dependsOn": "build" "dependsOn": "build"
} }
] ]

View File

@@ -15,7 +15,7 @@
secrets/ secrets/
src/ src/
main.rs # CLI 入口clap 命令定义auto-migrate--verbose 全局参数 main.rs # CLI 入口clap 命令定义auto-migrate--verbose 全局参数
output.rs # OutputMode 枚举 + TTY 检测TTY→text非 TTY→json-compact output.rs # OutputMode 枚举(默认 json-o text 供人类使用
config.rs # 配置读写:~/.config/secrets/config.tomldatabase_url config.rs # 配置读写:~/.config/secrets/config.tomldatabase_url
db.rs # PgPool 创建 + 建表/索引DROP+CREATE含所有表 db.rs # PgPool 创建 + 建表/索引DROP+CREATE含所有表
crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串 crypto.rs # AES-256-GCM 加解密、Argon2id 派生、OS 钥匙串
@@ -30,7 +30,7 @@ secrets/
update.rs # update 命令增量更新secrets 行级 UPSERT/DELETECAS 并发保护 update.rs # update 命令增量更新secrets 行级 UPSERT/DELETECAS 并发保护
rollback.rs # rollback 命令:按 entry_version 恢复 entry + secrets rollback.rs # rollback 命令:按 entry_version 恢复 entry + secrets
history.rs # history 命令:查看 entry 变更历史列表 history.rs # history 命令:查看 entry 变更历史列表
run.rs # inject / run 命令:逐字段解密 + key_ref 引用解析 run.rs # run 命令:仅 secrets 逐字段解密 + key_ref 引用解析(不含 metadata
upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制 upgrade.rs # upgrade 命令:检查、校验摘要并下载最新版本,自动替换二进制
export_cmd.rs # export 命令:批量导出记录,支持 JSON/TOML/YAML含解密明文 export_cmd.rs # export 命令:批量导出记录,支持 JSON/TOML/YAML含解密明文
import_cmd.rs # import 命令批量导入记录冲突检测dry-run重新加密写入 import_cmd.rs # import 命令批量导入记录冲突检测dry-run重新加密写入
@@ -71,8 +71,6 @@ secrets (
id UUID PRIMARY KEY DEFAULT uuidv7(), id UUID PRIMARY KEY DEFAULT uuidv7(),
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
field_name VARCHAR(256) NOT NULL, -- 明文字段名: "username", "token", "ssh_key" field_name VARCHAR(256) NOT NULL, -- 明文字段名: "username", "token", "ssh_key"
field_type VARCHAR(32) NOT NULL DEFAULT 'string', -- 明文类型: "string"|"number"|"boolean"|"json"
value_len INT NOT NULL DEFAULT 0, -- 明文原始值字符数PEM≈4096token≈40
encrypted BYTEA NOT NULL DEFAULT '\x', -- 仅加密值本身nonce(12B)||ciphertext+tag encrypted BYTEA NOT NULL DEFAULT '\x', -- 仅加密值本身nonce(12B)||ciphertext+tag
version BIGINT NOT NULL DEFAULT 1, version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -130,8 +128,6 @@ secrets_history (
secret_id UUID NOT NULL, -- 对应 secrets.id secret_id UUID NOT NULL, -- 对应 secrets.id
entry_version BIGINT NOT NULL, -- 关联 entries_history 的版本号 entry_version BIGINT NOT NULL, -- 关联 entries_history 的版本号
field_name VARCHAR(256) NOT NULL, field_name VARCHAR(256) NOT NULL,
field_type VARCHAR(32) NOT NULL DEFAULT 'string',
value_len INT NOT NULL DEFAULT 0,
encrypted BYTEA NOT NULL DEFAULT '\x', encrypted BYTEA NOT NULL DEFAULT '\x',
action VARCHAR(16) NOT NULL, -- 'add' | 'update' | 'delete' | 'rollback' action VARCHAR(16) NOT NULL, -- 'add' | 'update' | 'delete' | 'rollback'
actor VARCHAR(128) NOT NULL DEFAULT '', actor VARCHAR(128) NOT NULL DEFAULT '',
@@ -149,8 +145,6 @@ secrets_history (
| `tags` | 多维分类标签 | `["aliyun","hongkong","ricn"]` | | `tags` | 多维分类标签 | `["aliyun","hongkong","ricn"]` |
| `metadata` | 明文非敏感信息 | `{"ip":"192.0.2.1","desc":"Grafana","key_ref":"my-shared-key"}` | | `metadata` | 明文非敏感信息 | `{"ip":"192.0.2.1","desc":"Grafana","key_ref":"my-shared-key"}` |
| `secrets.field_name` | 加密字段名(明文) | `"username"`, `"token"`, `"ssh_key"` | | `secrets.field_name` | 加密字段名(明文) | `"username"`, `"token"`, `"ssh_key"` |
| `secrets.field_type` | 值类型(明文) | `"string"`, `"number"`, `"boolean"`, `"json"` |
| `secrets.value_len` | 原始值字符数(明文) | `4`root`40`token`4096`PEM |
| `secrets.encrypted` | 仅加密值本身 | AES-256-GCM 密文 | | `secrets.encrypted` | 仅加密值本身 | AES-256-GCM 密文 |
### PEM 共享机制key_ref ### PEM 共享机制key_ref
@@ -163,7 +157,7 @@ secrets add -n refining --kind key --name my-shared-key \
--tag aliyun --tag hongkong \ --tag aliyun --tag hongkong \
-s content=@./keys/my-shared-key.pem -s content=@./keys/my-shared-key.pem
# 2. 服务器通过 metadata.key_ref 引用(inject/run 时自动合并 key 的 secrets # 2. 服务器通过 metadata.key_ref 引用run 时自动合并 key 的 secrets
secrets add -n refining --kind server --name i-example0xyz789 \ secrets add -n refining --kind server --name i-example0xyz789 \
-m ip=192.0.2.1 -m key_ref=my-shared-key \ -m ip=192.0.2.1 -m key_ref=my-shared-key \
-s username=ecs-user -s username=ecs-user
@@ -209,9 +203,9 @@ secrets init # 提示输入主密码Argon2id 派生主密钥后存入 OS
**读取一律用 `search`,写入用 `add` / `update`,避免反复查帮助。** **读取一律用 `search`,写入用 `add` / `update`,避免反复查帮助。**
输出格式规则: 输出格式规则:
- TTY终端直接运行→ 默认 `text` - 默认始终输出 `json`pretty-printed无论 TTY 还是管道
- 非 TTY管道/重定向/AI 调用)→ 自动 `json-compact` - 显式 `-o json-compact` → 单行 JSON管道处理时更紧凑
- 显式 `-o json`美化 JSON - 显式 `-o text`人类可读文本格式
--- ---
@@ -259,8 +253,7 @@ 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 metadata.url -f metadata.default_org
# 需要 secrets 时,改用 inject / run # 需要 secrets 时,改用 run
secrets inject -n refining --kind service --name gitea
secrets run -n refining --kind service --name gitea -- printenv secrets run -n refining --kind service --name gitea -- printenv
# 模糊关键词搜索 # 模糊关键词搜索
@@ -278,7 +271,7 @@ secrets search --tag aliyun --summary
secrets search -n refining --summary --limit 10 --offset 0 secrets search -n refining --summary --limit 10 --offset 0
secrets search -n refining --summary --limit 10 --offset 10 secrets search -n refining --summary --limit 10 --offset 10
# 管道 / AI 调用(非 TTY 自动 json-compact # 管道 / AI 调用(默认 json直接可解析
secrets search -n refining --kind service | jq '.[].name' secrets search -n refining --kind service | jq '.[].name'
``` ```
@@ -444,37 +437,11 @@ secrets rollback -n refining --kind service --name gitea --to-version 3
--- ---
### inject — 输出临时环境变量
敏感值仅打印到 stdout不持久化、不写入当前 shell。
```bash
# 参数说明
# -n / --namespace refining | ricnsmart
# --kind server | service
# --name 记录名
# --tag 按 tag 过滤(可重复)
# --prefix 变量名前缀(留空则以记录 name 作前缀)
# -o / --output text默认 KEY=VALUE| json | json-compact
# 打印单条记录的所有变量KEY=VALUE 格式)
secrets inject -n refining --kind service --name gitea
# 自定义前缀
secrets inject -n refining --kind service --name gitea --prefix GITEA
# JSON 格式(适合管道或脚本解析)
secrets inject -n refining --kind service --name gitea -o json
# eval 注入当前 shell谨慎使用
eval $(secrets inject -n refining --kind service --name gitea)
```
---
### run — 向子进程注入 secrets 并执行命令 ### run — 向子进程注入 secrets 并执行命令
secrets 仅作用于子进程环境,不修改当前 shell进程退出码透传。 仅注入 secrets 表中的加密字段(解密后),不含 metadata。secrets 仅作用于子进程环境,不修改当前 shell进程退出码透传。
使用 `-s/--secret` 指定只注入哪些字段(最小权限原则);使用 `--dry-run` 预览将注入哪些变量名及来源,不执行命令。
```bash ```bash
# 参数说明 # 参数说明
@@ -482,17 +449,30 @@ secrets 仅作用于子进程环境,不修改当前 shell进程退出码透
# --kind server | service # --kind server | service
# --name 记录名 # --name 记录名
# --tag 按 tag 过滤(可重复) # --tag 按 tag 过滤(可重复)
# -s / --secret 只注入指定字段名(可重复;省略则注入全部)
# --prefix 变量名前缀 # --prefix 变量名前缀
# -- <command> 执行命令及参数 # --dry-run 预览变量映射,不执行命令
# -o / --output json默认| json-compact | text
# -- <command> 执行的命令及参数(--dry-run 时可省略)
# 向脚本注入单条记录的 secrets # 注入全部 secrets 到脚本
secrets run -n refining --kind service --name gitea -- ./deploy.sh secrets run -n refining --kind service --name gitea -- ./deploy.sh
# 只注入特定字段(最小化注入范围)
secrets run -n refining --kind service --name aliyun \
-s access_key_id -s access_key_secret -- aliyun ecs DescribeInstances
# 按 tag 批量注入(多条记录合并) # 按 tag 批量注入(多条记录合并)
secrets run --tag production -- env | grep -i token secrets run --tag production -- env | grep -i token
# 验证注入哪些变量 # 预览将注入哪些变量(不执行命令,默认 JSON 输出)
secrets run -n refining --kind service --name gitea -- printenv secrets run -n refining --kind service --name gitea --dry-run
# 配合字段过滤预览
secrets run -n refining --kind service --name gitea -s token --dry-run
# text 模式预览(人类阅读)
secrets run -n refining --kind service --name gitea --dry-run -o text
``` ```
--- ---
@@ -622,7 +602,7 @@ secrets --db-url "postgres://..." search -n refining
- 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!` - 日志:用户可见输出用 `println!`;调试/运维信息用 `tracing::debug!`/`info!`/`warn!`/`error!`
- 审计:`add`/`update`/`delete` 成功后调用 `audit::log_tx`,写入 `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;默认始终 `json`pretty`-o text` 供人类阅读;写命令 `add` 同样支持 `-o json`
## 提交前检查(必须全部通过) ## 提交前检查(必须全部通过)

2
Cargo.lock generated
View File

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

View File

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

View File

@@ -54,7 +54,7 @@ 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
# 获取完整记录(含 secrets 字段 schemafield_name、field_type、value_len,无需 master_key # 获取完整记录(含 secrets 字段,无需 master_key
secrets search -n refining --kind service --name gitea -o json secrets search -n refining --kind service --name gitea -o json
# 直接提取单个 metadata 字段值(最短路径) # 直接提取单个 metadata 字段值(最短路径)
@@ -64,31 +64,35 @@ 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 metadata.url -f metadata.default_org
# 需要 secrets 时,改用 inject / run # 需要 secrets 时,改用 run只注入 token 字段到子进程)
secrets inject -n refining --kind service --name gitea secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh
secrets run -n refining --kind service --name gitea -- printenv
# 预览 run 会注入哪些变量(不执行命令)
secrets run -n refining --kind service --name gitea --dry-run
``` ```
`search` 展示 metadata 与 secrets 的字段 schema字段名、类型、长度不展示 secret 值本身;需要值时用 `inject` / `run` `search` 展示 metadata 与 secrets 的字段名,不展示 secret 值本身;需要 secret 值时用 `run`(仅注入加密字段到子进程,不含 metadata。用 `-s` 指定只注入特定字段,最小化注入范围
### 输出格式 ### 输出格式
| 场景 | 推荐命令 | | 场景 | 推荐命令 |
|------|----------| |------|----------|
| AI 解析 / 管道处理 | `-o json``-o json-compact` | | AI 解析 / 管道处理(默认) | jsonpretty-printed |
| 注入 secrets 到环境变量 | `inject` / `run` | | 管道紧凑格式 | `-o json-compact` |
| 人类查看 | 默认 `text`TTY 下自动启用) | | 注入 secrets 到子进程环境 | `run` |
| 非 TTY管道/重定向) | 自动 `json-compact` | | 人类查看 | `-o text` |
说明:`text` 输出中时间会按当前机器本地时区显示;`json/json-compact` 继续使用 UTCRFC3339 风格)以便脚本和 AI 稳定解析 默认始终输出 JSON无论是 TTY 还是管道。`text` 输出中时间本地时区显示;`json/json-compact` 使用 UTCRFC3339
```bash ```bash
# 管道直接 jq 解析(非 TTY 自动 json-compact # 默认 JSON 输出,直接 jq 解析
secrets search -n refining --kind service | jq '.[].name' secrets search -n refining --kind service | jq '.[].name'
# 需要 secrets 时,使用 inject / run # 需要 secrets 时,使用 run-s 指定只注入特定字段)
secrets inject -n refining --kind service --name gitea > ~/.config/gitea/secrets.env secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh
secrets run -n refining --kind service --name gitea -- ./deploy.sh
# 预览 run 会注入哪些变量(不执行命令)
secrets run -n refining --kind service --name gitea --dry-run
``` ```
## 完整命令参考 ## 完整命令参考
@@ -177,6 +181,16 @@ secrets import backup.json # 导入(冲突时报
secrets import --force refining.toml # 冲突时覆盖已有记录 secrets import --force refining.toml # 冲突时覆盖已有记录
secrets import --dry-run backup.yaml # 预览将要执行的操作(不写入) secrets import --dry-run backup.yaml # 预览将要执行的操作(不写入)
# ── run ───────────────────────────────────────────────────────────────────────
secrets run -n refining --kind service --name gitea -- ./deploy.sh # 注入全部 secrets
secrets run -n refining --kind service --name gitea -s token -- ./deploy.sh # 只注入 token 字段
secrets run -n refining --kind service --name aliyun \
-s access_key_id -s access_key_secret -- aliyun ecs DescribeInstances # 只注入指定字段
secrets run --tag production -- env # 按 tag 批量注入
secrets run -n refining --kind service --name gitea --dry-run # 预览变量映射
secrets run -n refining --kind service --name gitea -s token --dry-run # 过滤后预览
secrets run -n refining --kind service --name gitea --dry-run -o text # 人类可读预览
# ── 调试 ────────────────────────────────────────────────────────────────────── # ── 调试 ──────────────────────────────────────────────────────────────────────
secrets --verbose search -q mqtt secrets --verbose search -q mqtt
RUST_LOG=secrets=trace secrets search RUST_LOG=secrets=trace secrets search
@@ -184,7 +198,7 @@ RUST_LOG=secrets=trace secrets search
## 数据模型 ## 数据模型
主表 `entries`namespace、kind、name、tags、metadata+ 子表 `secrets`(每个加密字段一行,含 field_name、field_type、value_len、encrypted。首次连接自动建表同时创建 `audit_log``entries_history``secrets_history` 等表。 主表 `entries`namespace、kind、name、tags、metadata+ 子表 `secrets`(每个加密字段一行,含 field_name、encrypted。首次连接自动建表同时创建 `audit_log``entries_history``secrets_history` 等表。
| 位置 | 字段 | 说明 | | 位置 | 字段 | 说明 |
|------|------|------| |------|------|------|
@@ -193,7 +207,7 @@ RUST_LOG=secrets=trace secrets search
| entries | name | 人类可读唯一标识 | | entries | name | 人类可读唯一标识 |
| entries | tags | 多维标签,如 `["aliyun","hongkong"]` | | entries | tags | 多维标签,如 `["aliyun","hongkong"]` |
| entries | metadata | 明文描述ip、desc、domains、key_ref 等) | | entries | metadata | 明文描述ip、desc、domains、key_ref 等) |
| secrets | field_name / field_type / value_len | 明文search 可见AI 可推断 inject 会生成什么变量 | | secrets | field_name | 明文search 可见AI 可推断 run 会注入哪些变量 |
| secrets | encrypted | 仅加密值本身AES-256-GCM | | secrets | encrypted | 仅加密值本身AES-256-GCM |
`-m` / `--meta` 写入 `metadata``-s` / `--secret` 写入 `secrets` 表的独立行。支持 `key=value``key=@file``key:=<json>`,也支持 `credentials:content@./key.pem` 这类嵌套字段文件写入;删除时支持 `--remove-secret credentials:content`。加解密使用主密钥(由 `secrets init` 设置)。 `-m` / `--meta` 写入 `metadata``-s` / `--secret` 写入 `secrets` 表的独立行。支持 `key=value``key=@file``key:=<json>`,也支持 `credentials:content@./key.pem` 这类嵌套字段文件写入;删除时支持 `--remove-secret credentials:content`。加解密使用主密钥(由 `secrets init` 设置)。
@@ -314,7 +328,7 @@ src/
delete.rs # 删除CASCADE 删除 secrets delete.rs # 删除CASCADE 删除 secrets
update.rs # 增量更新tags/metadata + secrets 行级 UPSERT/DELETE update.rs # 增量更新tags/metadata + secrets 行级 UPSERT/DELETE
rollback.rs # rollback / history按 entry_version 恢复 rollback.rs # rollback / history按 entry_version 恢复
run.rs # inject / run逐字段解密 + key_ref 引用解析 run.rs # run仅 secrets 逐字段解密 + key_ref 引用解析(不含 metadata
upgrade.rs # 从 Gitea Release 自更新 upgrade.rs # 从 Gitea Release 自更新
export_cmd.rs # export批量导出支持 JSON/TOML/YAML含解密明文 export_cmd.rs # export批量导出支持 JSON/TOML/YAML含解密明文
import_cmd.rs # import批量导入冲突检测dry-run重新加密写入 import_cmd.rs # import批量导入冲突检测dry-run重新加密写入

View File

@@ -161,28 +161,6 @@ pub(crate) fn remove_path(map: &mut Map<String, Value>, path: &[String]) -> Resu
Ok(removed) Ok(removed)
} }
// ── field_type inference and value_len ──────────────────────────────────────
/// Infer the field type string from a JSON value.
pub(crate) fn infer_field_type(v: &Value) -> &'static str {
match v {
Value::String(_) => "string",
Value::Number(_) => "number",
Value::Bool(_) => "boolean",
Value::Null => "string",
Value::Array(_) | Value::Object(_) => "json",
}
}
/// Compute the plaintext length of a JSON value (chars for string, serialized length otherwise).
pub(crate) fn compute_value_len(v: &Value) -> i32 {
match v {
Value::String(s) => s.chars().count() as i32,
Value::Null => 0,
other => other.to_string().chars().count() as i32,
}
}
/// Flatten a (potentially nested) JSON object into dot-separated field entries. /// Flatten a (potentially nested) JSON object into dot-separated field entries.
/// e.g. `{"credentials": {"type": "ssh", "content": "..."}}` → /// e.g. `{"credentials": {"type": "ssh", "content": "..."}}` →
/// `[("credentials.type", "ssh"), ("credentials.content", "...")]` /// `[("credentials.type", "ssh"), ("credentials.content", "...")]`
@@ -291,12 +269,10 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
struct ExistingField { struct ExistingField {
id: uuid::Uuid, id: uuid::Uuid,
field_name: String, field_name: String,
field_type: String,
value_len: i32,
encrypted: Vec<u8>, encrypted: Vec<u8>,
} }
let existing_fields: Vec<ExistingField> = sqlx::query_as( let existing_fields: Vec<ExistingField> = sqlx::query_as(
"SELECT id, field_name, field_type, value_len, encrypted \ "SELECT id, field_name, encrypted \
FROM secrets WHERE entry_id = $1", FROM secrets WHERE entry_id = $1",
) )
.bind(entry_id) .bind(entry_id)
@@ -311,8 +287,6 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
secret_id: f.id, secret_id: f.id,
entry_version: new_entry_version - 1, entry_version: new_entry_version - 1,
field_name: &f.field_name, field_name: &f.field_name,
field_type: &f.field_type,
value_len: f.value_len,
encrypted: &f.encrypted, encrypted: &f.encrypted,
action: "add", action: "add",
}, },
@@ -333,18 +307,14 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
// Insert new secret fields. // Insert new secret fields.
let flat_fields = flatten_json_fields("", &secret_json); let flat_fields = flatten_json_fields("", &secret_json);
for (field_name, field_value) in &flat_fields { for (field_name, field_value) in &flat_fields {
let field_type = infer_field_type(field_value);
let value_len = compute_value_len(field_value);
let encrypted = crypto::encrypt_json(master_key, field_value)?; let encrypted = crypto::encrypt_json(master_key, field_value)?;
sqlx::query( sqlx::query(
"INSERT INTO secrets (entry_id, field_name, field_type, value_len, encrypted) \ "INSERT INTO secrets (entry_id, field_name, encrypted) \
VALUES ($1, $2, $3, $4, $5)", VALUES ($1, $2, $3)",
) )
.bind(entry_id) .bind(entry_id)
.bind(field_name) .bind(field_name)
.bind(field_type)
.bind(value_len)
.bind(&encrypted) .bind(&encrypted)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
@@ -399,10 +369,7 @@ pub async fn run(pool: &PgPool, args: AddArgs<'_>, master_key: &[u8; 32]) -> Res
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{build_json, flatten_json_fields, key_path_to_string, parse_kv, remove_path};
build_json, compute_value_len, flatten_json_fields, infer_field_type, key_path_to_string,
parse_kv, remove_path,
};
use serde_json::Value; use serde_json::Value;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -489,19 +456,4 @@ mod tests {
assert_eq!(fields[1].0, "credentials.type"); assert_eq!(fields[1].0, "credentials.type");
assert_eq!(fields[2].0, "username"); assert_eq!(fields[2].0, "username");
} }
#[test]
fn infer_field_types() {
assert_eq!(infer_field_type(&Value::String("x".into())), "string");
assert_eq!(infer_field_type(&serde_json::json!(42)), "number");
assert_eq!(infer_field_type(&Value::Bool(true)), "boolean");
assert_eq!(infer_field_type(&serde_json::json!(["a"])), "json");
}
#[test]
fn compute_value_len_string() {
assert_eq!(compute_value_len(&Value::String("root".into())), 4);
assert_eq!(compute_value_len(&Value::Null), 0);
assert_eq!(compute_value_len(&serde_json::json!(1234)), 4);
}
} }

View File

@@ -257,7 +257,7 @@ async fn snapshot_and_delete(
} }
let fields: Vec<SecretFieldRow> = sqlx::query_as( let fields: Vec<SecretFieldRow> = sqlx::query_as(
"SELECT id, field_name, field_type, value_len, encrypted \ "SELECT id, field_name, encrypted \
FROM secrets WHERE entry_id = $1", FROM secrets WHERE entry_id = $1",
) )
.bind(row.id) .bind(row.id)
@@ -272,8 +272,6 @@ async fn snapshot_and_delete(
secret_id: f.id, secret_id: f.id,
entry_version: row.version, entry_version: row.version,
field_name: &f.field_name, field_name: &f.field_name,
field_type: &f.field_type,
value_len: f.value_len,
encrypted: &f.encrypted, encrypted: &f.encrypted,
action: "delete", action: "delete",
}, },

View File

@@ -71,14 +71,12 @@ pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -
struct SecretHistoryRow { struct SecretHistoryRow {
secret_id: Uuid, secret_id: Uuid,
field_name: String, field_name: String,
field_type: String,
value_len: i32,
encrypted: Vec<u8>, encrypted: Vec<u8>,
action: String, action: String,
} }
let field_snaps: Vec<SecretHistoryRow> = sqlx::query_as( let field_snaps: Vec<SecretHistoryRow> = sqlx::query_as(
"SELECT secret_id, field_name, field_type, value_len, encrypted, action \ "SELECT secret_id, field_name, encrypted, action \
FROM secrets_history \ FROM secrets_history \
WHERE entry_id = $1 AND entry_version = $2 \ WHERE entry_id = $1 AND entry_version = $2 \
ORDER BY field_name", ORDER BY field_name",
@@ -145,12 +143,10 @@ pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -
struct LiveField { struct LiveField {
id: Uuid, id: Uuid,
field_name: String, field_name: String,
field_type: String,
value_len: i32,
encrypted: Vec<u8>, encrypted: Vec<u8>,
} }
let live_fields: Vec<LiveField> = sqlx::query_as( let live_fields: Vec<LiveField> = sqlx::query_as(
"SELECT id, field_name, field_type, value_len, encrypted \ "SELECT id, field_name, encrypted \
FROM secrets WHERE entry_id = $1", FROM secrets WHERE entry_id = $1",
) )
.bind(lr.id) .bind(lr.id)
@@ -165,8 +161,6 @@ pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -
secret_id: f.id, secret_id: f.id,
entry_version: lr.version, entry_version: lr.version,
field_name: &f.field_name, field_name: &f.field_name,
field_type: &f.field_type,
value_len: f.value_len,
encrypted: &f.encrypted, encrypted: &f.encrypted,
action: "rollback", action: "rollback",
}, },
@@ -212,11 +206,9 @@ pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -
continue; continue;
} }
sqlx::query( sqlx::query(
"INSERT INTO secrets (id, entry_id, field_name, field_type, value_len, encrypted) \ "INSERT INTO secrets (id, entry_id, field_name, encrypted) \
VALUES ($1, $2, $3, $4, $5, $6) \ VALUES ($1, $2, $3, $4) \
ON CONFLICT (entry_id, field_name) DO UPDATE SET \ ON CONFLICT (entry_id, field_name) DO UPDATE SET \
field_type = EXCLUDED.field_type, \
value_len = EXCLUDED.value_len, \
encrypted = EXCLUDED.encrypted, \ encrypted = EXCLUDED.encrypted, \
version = secrets.version + 1, \ version = secrets.version + 1, \
updated_at = NOW()", updated_at = NOW()",
@@ -224,8 +216,6 @@ pub async fn run(pool: &PgPool, args: RollbackArgs<'_>, master_key: &[u8; 32]) -
.bind(f.secret_id) .bind(f.secret_id)
.bind(snap.entry_id) .bind(snap.entry_id)
.bind(&f.field_name) .bind(&f.field_name)
.bind(&f.field_type)
.bind(f.value_len)
.bind(&f.encrypted) .bind(&f.encrypted)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;

View File

@@ -1,45 +1,57 @@
use anyhow::Result; use anyhow::Result;
use serde_json::Value; use serde_json::json;
use sqlx::PgPool; use sqlx::PgPool;
use std::collections::HashMap; use std::collections::HashMap;
use crate::commands::search::{build_injected_env_map, fetch_entries, fetch_secrets_for_entries}; use crate::commands::search::{build_injected_env_map, fetch_entries, fetch_secrets_for_entries};
use crate::output::OutputMode; use crate::output::OutputMode;
pub struct InjectArgs<'a> {
pub namespace: Option<&'a str>,
pub kind: Option<&'a str>,
pub name: Option<&'a str>,
pub tags: &'a [String],
pub prefix: &'a str,
pub output: OutputMode,
}
pub struct RunArgs<'a> { pub struct RunArgs<'a> {
pub namespace: Option<&'a str>, pub namespace: Option<&'a str>,
pub kind: Option<&'a str>, pub kind: Option<&'a str>,
pub name: Option<&'a str>, pub name: Option<&'a str>,
pub tags: &'a [String], pub tags: &'a [String],
pub secret_fields: &'a [String],
pub prefix: &'a str, pub prefix: &'a str,
pub dry_run: bool,
pub output: OutputMode,
pub command: &'a [String], pub command: &'a [String],
} }
/// Fetch entries matching the filter and build a flat env map (metadata + decrypted secrets). /// A single environment variable with its origin for dry-run display.
pub async fn collect_env_map( pub struct EnvMapping {
pub var_name: String,
pub source: String,
pub field: String,
}
struct CollectArgs<'a> {
namespace: Option<&'a str>,
kind: Option<&'a str>,
name: Option<&'a str>,
tags: &'a [String],
secret_fields: &'a [String],
prefix: &'a str,
}
/// Fetch entries matching the filter and build a flat env map (decrypted secrets only, no metadata).
/// If `secret_fields` is non-empty, only those fields are decrypted and included.
async fn collect_env_map(
pool: &PgPool, pool: &PgPool,
namespace: Option<&str>, args: &CollectArgs<'_>,
kind: Option<&str>,
name: Option<&str>,
tags: &[String],
prefix: &str,
master_key: &[u8; 32], master_key: &[u8; 32],
) -> Result<HashMap<String, String>> { ) -> Result<HashMap<String, String>> {
if namespace.is_none() && kind.is_none() && name.is_none() && tags.is_empty() { if args.namespace.is_none()
&& args.kind.is_none()
&& args.name.is_none()
&& args.tags.is_empty()
{
anyhow::bail!( anyhow::bail!(
"At least one filter (--namespace, --kind, --name, or --tag) is required for inject/run" "At least one filter (--namespace, --kind, --name, or --tag) is required for run"
); );
} }
let entries = fetch_entries(pool, namespace, kind, name, tags, None).await?; let entries =
fetch_entries(pool, args.namespace, args.kind, args.name, args.tags, None).await?;
if entries.is_empty() { if entries.is_empty() {
anyhow::bail!("No records matched the given filters."); anyhow::bail!("No records matched the given filters.");
} }
@@ -50,8 +62,17 @@ pub async fn collect_env_map(
let mut map = HashMap::new(); let mut map = HashMap::new();
for entry in &entries { for entry in &entries {
let empty = vec![]; let empty = vec![];
let fields = fields_map.get(&entry.id).unwrap_or(&empty); let all_fields = fields_map.get(&entry.id).unwrap_or(&empty);
let row_map = build_injected_env_map(pool, entry, prefix, master_key, fields).await?; let filtered_fields: Vec<_> = if args.secret_fields.is_empty() {
all_fields.iter().collect()
} else {
all_fields
.iter()
.filter(|f| args.secret_fields.contains(&f.field_name))
.collect()
};
let row_map =
build_injected_env_map(pool, entry, args.prefix, master_key, &filtered_fields).await?;
for (k, v) in row_map { for (k, v) in row_map {
map.insert(k, v); map.insert(k, v);
} }
@@ -59,64 +80,152 @@ pub async fn collect_env_map(
Ok(map) Ok(map)
} }
/// `inject` command: print env vars to stdout. /// Like `collect_env_map` but also returns per-variable source info for dry-run display.
pub async fn run_inject(pool: &PgPool, args: InjectArgs<'_>, master_key: &[u8; 32]) -> Result<()> { async fn collect_env_map_with_source(
let env_map = collect_env_map( pool: &PgPool,
pool, args: &CollectArgs<'_>,
args.namespace, master_key: &[u8; 32],
args.kind, ) -> Result<(HashMap<String, String>, Vec<EnvMapping>)> {
args.name, if args.namespace.is_none()
args.tags, && args.kind.is_none()
args.prefix, && args.name.is_none()
master_key, && args.tags.is_empty()
{
anyhow::bail!(
"At least one filter (--namespace, --kind, --name, or --tag) is required for run"
);
}
let entries =
fetch_entries(pool, args.namespace, args.kind, args.name, args.tags, None).await?;
if entries.is_empty() {
anyhow::bail!("No records matched the given filters.");
}
let entry_ids: Vec<uuid::Uuid> = entries.iter().map(|e| e.id).collect();
let fields_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
let mut map = HashMap::new();
let mut mappings: Vec<EnvMapping> = Vec::new();
for entry in &entries {
let empty = vec![];
let all_fields = fields_map.get(&entry.id).unwrap_or(&empty);
let filtered_fields: Vec<_> = if args.secret_fields.is_empty() {
all_fields.iter().collect()
} else {
all_fields
.iter()
.filter(|f| args.secret_fields.contains(&f.field_name))
.collect()
};
let row_map =
build_injected_env_map(pool, entry, args.prefix, master_key, &filtered_fields).await?;
let source = format!("{}/{}/{}", entry.namespace, entry.kind, entry.name);
for field in &filtered_fields {
let var_name = format!(
"{}_{}",
env_prefix_name(&entry.name, args.prefix),
field.field_name.to_uppercase().replace(['-', '.'], "_")
);
if row_map.contains_key(&var_name) {
mappings.push(EnvMapping {
var_name: var_name.clone(),
source: source.clone(),
field: field.field_name.clone(),
});
}
}
for (k, v) in row_map {
map.insert(k, v);
}
}
Ok((map, mappings))
}
fn env_prefix_name(entry_name: &str, prefix: &str) -> String {
let name_part = entry_name.to_uppercase().replace(['-', '.', ' '], "_");
if prefix.is_empty() {
name_part
} else {
format!(
"{}_{}",
prefix.to_uppercase().replace(['-', '.', ' '], "_"),
name_part
) )
.await?;
match args.output {
OutputMode::Json => {
let obj: serde_json::Map<String, Value> = env_map
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect();
println!("{}", serde_json::to_string_pretty(&Value::Object(obj))?);
} }
OutputMode::JsonCompact => {
let obj: serde_json::Map<String, Value> = env_map
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect();
println!("{}", serde_json::to_string(&Value::Object(obj))?);
}
_ => {
let mut pairs: Vec<(String, String)> = env_map.into_iter().collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in pairs {
println!("{}={}", k, shell_quote(&v));
}
}
}
Ok(())
} }
/// `run` command: inject secrets into a child process environment and execute. /// `run` command: inject secrets into a child process environment and execute.
/// With `--dry-run`, prints the variable mapping (names and sources only) without executing.
pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -> Result<()> { pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -> Result<()> {
if args.command.is_empty() { if !args.dry_run && args.command.is_empty() {
anyhow::bail!( anyhow::bail!(
"No command specified. Usage: secrets run [filter flags] -- <command> [args]" "No command specified. Usage: secrets run [filter flags] -- <command> [args]"
); );
} }
let env_map = collect_env_map( let collect = CollectArgs {
pool, namespace: args.namespace,
args.namespace, kind: args.kind,
args.kind, name: args.name,
args.name, tags: args.tags,
args.tags, secret_fields: args.secret_fields,
args.prefix, prefix: args.prefix,
master_key, };
)
.await?; if args.dry_run {
let (env_map, mappings) = collect_env_map_with_source(pool, &collect, master_key).await?;
let total_vars = env_map.len();
let total_records = {
let mut seen = std::collections::HashSet::new();
for m in &mappings {
seen.insert(&m.source);
}
seen.len()
};
match args.output {
OutputMode::Text => {
for m in &mappings {
println!("{:<40} <- {} :: {}", m.var_name, m.source, m.field);
}
println!("---");
println!(
"{} variable(s) from {} record(s).",
total_vars, total_records
);
}
OutputMode::Json | OutputMode::JsonCompact => {
let vars: Vec<_> = mappings
.iter()
.map(|m| {
json!({
"name": m.var_name,
"source": m.source,
"field": m.field,
})
})
.collect();
let out = json!({
"variables": vars,
"total_vars": total_vars,
"total_records": total_records,
});
if args.output == OutputMode::Json {
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
println!("{}", serde_json::to_string(&out)?);
}
}
}
return Ok(());
}
let env_map = collect_env_map(pool, &collect, master_key).await?;
tracing::debug!( tracing::debug!(
vars = env_map.len(), vars = env_map.len(),
@@ -137,7 +246,3 @@ pub async fn run_exec(pool: &PgPool, args: RunArgs<'_>, master_key: &[u8; 32]) -
Ok(()) Ok(())
} }
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}

View File

@@ -94,7 +94,7 @@ pub async fn run(pool: &PgPool, args: SearchArgs<'_>) -> Result<()> {
fn validate_safe_search_args(fields: &[String]) -> Result<()> { fn validate_safe_search_args(fields: &[String]) -> Result<()> {
if let Some(field) = fields.iter().find(|field| is_secret_field(field)) { if let Some(field) = fields.iter().find(|field| is_secret_field(field)) {
anyhow::bail!( anyhow::bail!(
"Field '{}' is sensitive. `search -f` only supports metadata.* fields; use `secrets inject` or `secrets run` for secrets.", "Field '{}' is sensitive. `search -f` only supports metadata.* fields; use `secrets run` for secrets.",
field field
); );
} }
@@ -121,11 +121,11 @@ struct PagedFetchArgs<'a> {
offset: u32, offset: u32,
} }
/// A very large limit used when callers need all matching records (export, inject, run). /// A very large limit used when callers need all matching records (export, run).
/// Postgres will stop scanning when this many rows are found; adjust if needed. /// Postgres will stop scanning when this many rows are found; adjust if needed.
pub const FETCH_ALL_LIMIT: u32 = 100_000; pub const FETCH_ALL_LIMIT: u32 = 100_000;
/// Fetch entries matching the given filters (used by search, inject, run). /// Fetch entries matching the given filters (used by search, run).
/// `limit` caps the result set; pass `FETCH_ALL_LIMIT` when you need all matching records. /// `limit` caps the result set; pass `FETCH_ALL_LIMIT` when you need all matching records.
pub async fn fetch_entries( pub async fn fetch_entries(
pool: &PgPool, pool: &PgPool,
@@ -250,8 +250,8 @@ async fn fetch_entries_paged(pool: &PgPool, a: PagedFetchArgs<'_>) -> Result<Vec
// ── Secret schema fetching (no master key) ─────────────────────────────────── // ── Secret schema fetching (no master key) ───────────────────────────────────
/// Fetch secret field schemas (field_name, field_type, value_len) for a set of entry ids. /// Fetch secret field names for a set of entry ids.
/// Returns a map from entry_id to list of SecretField (encrypted field not used here). /// Returns a map from entry_id to list of SecretField.
async fn fetch_secret_schemas( async fn fetch_secret_schemas(
pool: &PgPool, pool: &PgPool,
entry_ids: &[uuid::Uuid], entry_ids: &[uuid::Uuid],
@@ -312,35 +312,17 @@ fn env_prefix(entry: &Entry, prefix: &str) -> String {
} }
} }
/// Build a flat KEY=VALUE map from metadata only (no master key required). /// Build a flat KEY=VALUE map from decrypted secret fields only.
pub fn build_metadata_env_map(entry: &Entry, prefix: &str) -> HashMap<String, String> {
let effective_prefix = env_prefix(entry, prefix);
let mut map = HashMap::new();
if let Some(meta) = entry.metadata.as_object() {
for (k, v) in meta {
let key = format!(
"{}_{}",
effective_prefix,
k.to_uppercase().replace(['-', '.'], "_")
);
map.insert(key, json_value_to_env_string(v));
}
}
map
}
/// Build a flat KEY=VALUE map from metadata + decrypted secret fields.
/// Resolves key_ref: if metadata.key_ref is set, merges secret fields from that key entry. /// Resolves key_ref: if metadata.key_ref is set, merges secret fields from that key entry.
pub async fn build_injected_env_map( pub async fn build_injected_env_map(
pool: &PgPool, pool: &PgPool,
entry: &Entry, entry: &Entry,
prefix: &str, prefix: &str,
master_key: &[u8; 32], master_key: &[u8; 32],
fields: &[SecretField], fields: &[&SecretField],
) -> Result<HashMap<String, String>> { ) -> Result<HashMap<String, String>> {
let effective_prefix = env_prefix(entry, prefix); let effective_prefix = env_prefix(entry, prefix);
let mut map = build_metadata_env_map(entry, prefix); let mut map = HashMap::new();
// Decrypt each secret field and add to env map. // Decrypt each secret field and add to env map.
for f in fields { for f in fields {
@@ -423,8 +405,6 @@ fn to_json(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> Valu
.map(|f| { .map(|f| {
json!({ json!({
"field_name": f.field_name, "field_name": f.field_name,
"field_type": f.field_type,
"value_len": f.value_len,
}) })
}) })
.collect(); .collect();
@@ -474,12 +454,9 @@ fn print_text(entry: &Entry, summary: bool, schema: Option<&[SecretField]>) -> R
} }
match schema { match schema {
Some(fields) if !fields.is_empty() => { Some(fields) if !fields.is_empty() => {
let schema_str: Vec<String> = fields let schema_str: Vec<String> = fields.iter().map(|f| f.field_name.clone()).collect();
.iter()
.map(|f| format!("{}: {}({})", f.field_name, f.field_type, f.value_len))
.collect();
println!(" secrets: {}", schema_str.join(", ")); println!(" secrets: {}", schema_str.join(", "));
println!(" (use `secrets inject` or `secrets run` to get values)"); println!(" (use `secrets run` to get values)");
} }
_ => {} _ => {}
} }
@@ -556,8 +533,6 @@ mod tests {
id: Uuid::nil(), id: Uuid::nil(),
entry_id: Uuid::nil(), entry_id: Uuid::nil(),
field_name: "token".to_string(), field_name: "token".to_string(),
field_type: "string".to_string(),
value_len: 6,
encrypted: enc, encrypted: enc,
version: 1, version: 1,
created_at: Utc::now(), created_at: Utc::now(),
@@ -572,22 +547,6 @@ mod tests {
assert!(err.to_string().contains("sensitive")); assert!(err.to_string().contains("sensitive"));
} }
#[test]
fn metadata_env_map_excludes_secret_values() {
let entry = sample_entry();
let map = build_metadata_env_map(&entry, "");
assert_eq!(
map.get("GITEA_MAIN_URL").map(String::as_str),
Some("https://code.example.com")
);
assert_eq!(
map.get("GITEA_MAIN_ENABLED").map(String::as_str),
Some("true")
);
assert!(!map.contains_key("GITEA_MAIN_TOKEN"));
}
#[test] #[test]
fn to_json_full_includes_secrets_schema() { fn to_json_full_includes_secrets_schema() {
let entry = sample_entry(); let entry = sample_entry();
@@ -597,8 +556,6 @@ mod tests {
let secrets = v.get("secrets").unwrap().as_array().unwrap(); let secrets = v.get("secrets").unwrap().as_array().unwrap();
assert_eq!(secrets.len(), 1); assert_eq!(secrets.len(), 1);
assert_eq!(secrets[0]["field_name"], "token"); assert_eq!(secrets[0]["field_name"], "token");
assert_eq!(secrets[0]["field_type"], "string");
assert_eq!(secrets[0]["value_len"], 6);
} }
#[test] #[test]

View File

@@ -4,8 +4,8 @@ use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use super::add::{ use super::add::{
collect_field_paths, collect_key_paths, compute_value_len, flatten_json_fields, collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
infer_field_type, insert_path, parse_key_path, parse_kv, remove_path, parse_kv, remove_path,
}; };
use crate::crypto; use crate::crypto;
use crate::db; use crate::db;
@@ -130,20 +130,16 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
}); });
for (field_name, fv) in &flat { for (field_name, fv) in &flat {
let field_type = infer_field_type(fv);
let value_len = compute_value_len(fv);
let encrypted = crypto::encrypt_json(master_key, fv)?; let encrypted = crypto::encrypt_json(master_key, fv)?;
// Snapshot existing field before replacing. // Snapshot existing field before replacing.
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct ExistingField { struct ExistingField {
id: Uuid, id: Uuid,
field_type: String,
value_len: i32,
encrypted: Vec<u8>, encrypted: Vec<u8>,
} }
let existing_field: Option<ExistingField> = sqlx::query_as( let existing_field: Option<ExistingField> = sqlx::query_as(
"SELECT id, field_type, value_len, encrypted \ "SELECT id, encrypted \
FROM secrets WHERE entry_id = $1 AND field_name = $2", FROM secrets WHERE entry_id = $1 AND field_name = $2",
) )
.bind(row.id) .bind(row.id)
@@ -159,8 +155,6 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
secret_id: ef.id, secret_id: ef.id,
entry_version: row.version, entry_version: row.version,
field_name, field_name,
field_type: &ef.field_type,
value_len: ef.value_len,
encrypted: &ef.encrypted, encrypted: &ef.encrypted,
action: "update", action: "update",
}, },
@@ -171,19 +165,15 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
} }
sqlx::query( sqlx::query(
"INSERT INTO secrets (entry_id, field_name, field_type, value_len, encrypted) \ "INSERT INTO secrets (entry_id, field_name, encrypted) \
VALUES ($1, $2, $3, $4, $5) \ VALUES ($1, $2, $3) \
ON CONFLICT (entry_id, field_name) DO UPDATE SET \ ON CONFLICT (entry_id, field_name) DO UPDATE SET \
field_type = EXCLUDED.field_type, \
value_len = EXCLUDED.value_len, \
encrypted = EXCLUDED.encrypted, \ encrypted = EXCLUDED.encrypted, \
version = secrets.version + 1, \ version = secrets.version + 1, \
updated_at = NOW()", updated_at = NOW()",
) )
.bind(row.id) .bind(row.id)
.bind(field_name) .bind(field_name)
.bind(field_type)
.bind(value_len)
.bind(&encrypted) .bind(&encrypted)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
@@ -200,12 +190,10 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct FieldToDelete { struct FieldToDelete {
id: Uuid, id: Uuid,
field_type: String,
value_len: i32,
encrypted: Vec<u8>, encrypted: Vec<u8>,
} }
let field: Option<FieldToDelete> = sqlx::query_as( let field: Option<FieldToDelete> = sqlx::query_as(
"SELECT id, field_type, value_len, encrypted \ "SELECT id, encrypted \
FROM secrets WHERE entry_id = $1 AND field_name = $2", FROM secrets WHERE entry_id = $1 AND field_name = $2",
) )
.bind(row.id) .bind(row.id)
@@ -221,8 +209,6 @@ pub async fn run(pool: &PgPool, args: UpdateArgs<'_>, master_key: &[u8; 32]) ->
secret_id: f.id, secret_id: f.id,
entry_version: new_version, entry_version: new_version,
field_name: &field_name, field_name: &field_name,
field_type: &f.field_type,
value_len: f.value_len,
encrypted: &f.encrypted, encrypted: &f.encrypted,
action: "delete", action: "delete",
}, },

View File

@@ -44,8 +44,6 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
id UUID PRIMARY KEY DEFAULT uuidv7(), id UUID PRIMARY KEY DEFAULT uuidv7(),
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE, entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
field_name VARCHAR(256) NOT NULL, field_name VARCHAR(256) NOT NULL,
field_type VARCHAR(32) NOT NULL DEFAULT 'string',
value_len INT NOT NULL DEFAULT 0,
encrypted BYTEA NOT NULL DEFAULT '\x', encrypted BYTEA NOT NULL DEFAULT '\x',
version BIGINT NOT NULL DEFAULT 1, version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -103,8 +101,6 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
secret_id UUID NOT NULL, secret_id UUID NOT NULL,
entry_version BIGINT NOT NULL, entry_version BIGINT NOT NULL,
field_name VARCHAR(256) NOT NULL, field_name VARCHAR(256) NOT NULL,
field_type VARCHAR(32) NOT NULL DEFAULT 'string',
value_len INT NOT NULL DEFAULT 0,
encrypted BYTEA NOT NULL DEFAULT '\x', encrypted BYTEA NOT NULL DEFAULT '\x',
action VARCHAR(16) NOT NULL, action VARCHAR(16) NOT NULL,
actor VARCHAR(128) NOT NULL DEFAULT '', actor VARCHAR(128) NOT NULL DEFAULT '',
@@ -168,8 +164,6 @@ pub struct SecretSnapshotParams<'a> {
pub secret_id: uuid::Uuid, pub secret_id: uuid::Uuid,
pub entry_version: i64, pub entry_version: i64,
pub field_name: &'a str, pub field_name: &'a str,
pub field_type: &'a str,
pub value_len: i32,
pub encrypted: &'a [u8], pub encrypted: &'a [u8],
pub action: &'a str, pub action: &'a str,
} }
@@ -182,15 +176,13 @@ pub async fn snapshot_secret_history(
let actor = current_actor(); let actor = current_actor();
sqlx::query( sqlx::query(
"INSERT INTO secrets_history \ "INSERT INTO secrets_history \
(entry_id, secret_id, entry_version, field_name, field_type, value_len, encrypted, action, actor) \ (entry_id, secret_id, entry_version, field_name, encrypted, action, actor) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", VALUES ($1, $2, $3, $4, $5, $6, $7)",
) )
.bind(p.entry_id) .bind(p.entry_id)
.bind(p.secret_id) .bind(p.secret_id)
.bind(p.entry_version) .bind(p.entry_version)
.bind(p.field_name) .bind(p.field_name)
.bind(p.field_type)
.bind(p.value_len)
.bind(p.encrypted) .bind(p.encrypted)
.bind(p.action) .bind(p.action)
.bind(&actor) .bind(&actor)

View File

@@ -41,8 +41,8 @@ use output::resolve_output_mode;
# 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 # Run a command with secrets injected into its child process environment
secrets inject -n refining --kind service --name gitea" secrets run -n refining --kind service --name gitea -- printenv"
)] )]
struct Cli { struct Cli {
/// Database URL, overrides saved config (one-time override) /// Database URL, overrides saved config (one-time override)
@@ -111,7 +111,13 @@ EXAMPLES:
# Write a multiline file into a nested secret field # Write a multiline file into a nested secret field
secrets add -n refining --kind server --name my-server \\ secrets add -n refining --kind server --name my-server \\
-s credentials:content@./keys/server.pem")] -s credentials:content@./keys/server.pem
# Shared PEM (key_ref): store key once, reference from multiple servers
secrets add -n refining --kind key --name my-shared-key \\
--tag aliyun -s content=@./keys/shared.pem
secrets add -n refining --kind server --name i-abc123 \\
-m ip=10.0.0.1 -m key_ref=my-shared-key -s username=ecs-user")]
Add { Add {
/// Namespace, e.g. refining, ricnsmart /// Namespace, e.g. refining, ricnsmart
#[arg(short, long)] #[arg(short, long)]
@@ -125,7 +131,8 @@ EXAMPLES:
/// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong /// Tag for categorization (repeatable), e.g. --tag aliyun --tag hongkong
#[arg(long = "tag")] #[arg(long = "tag")]
tags: Vec<String>, tags: Vec<String>,
/// Plaintext metadata: key=value, key:=<json>, key=@file, or nested:path@file /// Plaintext metadata: key=value, key:=<json>, key=@file, or nested:path@file.
/// Use key_ref=<name> to reference a shared key entry (kind=key); run merges its secrets.
#[arg(long = "meta", short = 'm')] #[arg(long = "meta", short = 'm')]
meta: Vec<String>, meta: Vec<String>,
/// Secret entry: key=value, key:=<json>, key=@file, or nested:path@file /// Secret entry: key=value, key:=<json>, key=@file, or nested:path@file
@@ -162,8 +169,7 @@ EXAMPLES:
secrets search -n refining --kind service --name gitea \\ secrets search -n refining --kind service --name gitea \\
-f metadata.url -f metadata.default_org -f metadata.url -f metadata.default_org
# Inject decrypted secrets only when needed # Run a command with decrypted secrets only when needed
secrets inject -n refining --kind service --name gitea
secrets run -n refining --kind service --name gitea -- printenv secrets run -n refining --kind service --name gitea -- printenv
# Paginate large result sets # Paginate large result sets
@@ -287,7 +293,11 @@ EXAMPLES:
# Update nested typed JSON fields # Update nested typed JSON fields
secrets update -n refining --kind service --name deploy-bot \\ secrets update -n refining --kind service --name deploy-bot \\
-s auth:config:='{\"issuer\":\"gitea\",\"rotate\":true}' \\ -s auth:config:='{\"issuer\":\"gitea\",\"rotate\":true}' \\
-s auth:retry:=5")] -s auth:retry:=5
# Rotate shared PEM (all servers with key_ref=my-shared-key get the new key)
secrets update -n refining --kind key --name my-shared-key \\
-s content=@./keys/new-shared.pem")]
Update { Update {
/// Namespace, e.g. refining, ricnsmart /// Namespace, e.g. refining, ricnsmart
#[arg(short, long)] #[arg(short, long)]
@@ -304,7 +314,8 @@ EXAMPLES:
/// Remove a tag (repeatable) /// Remove a tag (repeatable)
#[arg(long = "remove-tag")] #[arg(long = "remove-tag")]
remove_tags: Vec<String>, remove_tags: Vec<String>,
/// Set or overwrite a metadata field: key=value, key:=<json>, key=@file, or nested:path@file /// Set or overwrite a metadata field: key=value, key:=<json>, key=@file, or nested:path@file.
/// Use key_ref=<name> to reference a shared key entry (kind=key).
#[arg(long = "meta", short = 'm')] #[arg(long = "meta", short = 'm')]
meta: Vec<String>, meta: Vec<String>,
/// Delete a metadata field by key or nested path, e.g. old_port or credentials:content /// Delete a metadata field by key or nested path, e.g. old_port or credentials:content
@@ -380,51 +391,34 @@ EXAMPLES:
output: Option<String>, output: Option<String>,
}, },
/// Print secrets as environment variables (stdout only, nothing persisted).
///
/// Outputs KEY=VALUE pairs for all matched records. Safe to pipe or eval.
#[command(after_help = "EXAMPLES:
# Print env vars for a single service
secrets inject -n refining --kind service --name gitea
# With a custom prefix
secrets inject -n refining --kind service --name gitea --prefix GITEA
# JSON output (all vars as a JSON object)
secrets inject -n refining --kind service --name gitea -o json
# Eval into current shell (use with caution)
eval $(secrets inject -n refining --kind service --name gitea)")]
Inject {
#[arg(short, long)]
namespace: Option<String>,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
tag: Vec<String>,
/// Prefix to prepend to every variable name (uppercased automatically)
#[arg(long, default_value = "")]
prefix: String,
/// Output format: text/KEY=VALUE (default), json, json-compact
#[arg(short, long = "output")]
output: Option<String>,
},
/// Run a command with secrets injected as environment variables. /// Run a command with secrets injected as environment variables.
/// ///
/// Secrets are available only to the child process; the current shell /// Secrets are available only to the child process; the current shell
/// environment is not modified. The process exit code is propagated. /// environment is not modified. The process exit code is propagated.
///
/// Use -s/--secret to inject only specific fields. Use --dry-run to preview
/// which variables would be injected without executing the command.
#[command(after_help = "EXAMPLES: #[command(after_help = "EXAMPLES:
# Run a script with a single service's secrets injected # Run a script with a single service's secrets injected
secrets run -n refining --kind service --name gitea -- ./deploy.sh secrets run -n refining --kind service --name gitea -- ./deploy.sh
# Inject only specific fields (minimal exposure)
secrets run -n refining --kind service --name aliyun \\
-s access_key_id -s access_key_secret -- aliyun ecs DescribeInstances
# Run with a tag filter (all matched records merged) # Run with a tag filter (all matched records merged)
secrets run --tag production -- env | grep GITEA secrets run --tag production -- env | grep GITEA
# With prefix # With prefix
secrets run -n refining --kind service --name gitea --prefix GITEA -- printenv")] secrets run -n refining --kind service --name gitea --prefix GITEA -- printenv
# Preview which variables would be injected (no command executed)
secrets run -n refining --kind service --name gitea --dry-run
# Preview with field filter and JSON output
secrets run -n refining --kind service --name gitea -s token --dry-run -o json
# metadata.key_ref entries get key secrets merged (e.g. server + shared PEM)")]
Run { Run {
#[arg(short, long)] #[arg(short, long)]
namespace: Option<String>, namespace: Option<String>,
@@ -434,11 +428,20 @@ EXAMPLES:
name: Option<String>, name: Option<String>,
#[arg(long)] #[arg(long)]
tag: Vec<String>, tag: Vec<String>,
/// Only inject these secret field names (repeatable). Omit to inject all fields.
#[arg(long = "secret", short = 's')]
secret_fields: Vec<String>,
/// Prefix to prepend to every variable name (uppercased automatically) /// Prefix to prepend to every variable name (uppercased automatically)
#[arg(long, default_value = "")] #[arg(long, default_value = "")]
prefix: String, prefix: String,
/// Preview variables that would be injected without executing the command
#[arg(long)]
dry_run: bool,
/// Output format for --dry-run: json (default), json-compact, text
#[arg(short, long = "output")]
output: Option<String>,
/// Command and arguments to execute with injected environment /// Command and arguments to execute with injected environment
#[arg(last = true, required = true)] #[arg(last = true)]
command: Vec<String>, command: Vec<String>,
}, },
@@ -754,40 +757,24 @@ async fn main() -> Result<()> {
.await?; .await?;
} }
Commands::Inject {
namespace,
kind,
name,
tag,
prefix,
output,
} => {
let master_key = crypto::load_master_key()?;
let out = resolve_output_mode(output.as_deref())?;
commands::run::run_inject(
&pool,
commands::run::InjectArgs {
namespace: namespace.as_deref(),
kind: kind.as_deref(),
name: name.as_deref(),
tags: &tag,
prefix: &prefix,
output: out,
},
&master_key,
)
.await?;
}
Commands::Run { Commands::Run {
namespace, namespace,
kind, kind,
name, name,
tag, tag,
secret_fields,
prefix, prefix,
dry_run,
output,
command, command,
} => { } => {
let master_key = crypto::load_master_key()?; let master_key = crypto::load_master_key()?;
let out = resolve_output_mode(output.as_deref())?;
if !dry_run && command.is_empty() {
anyhow::bail!(
"No command specified. Usage: secrets run [filter flags] -- <command> [args]"
);
}
commands::run::run_exec( commands::run::run_exec(
&pool, &pool,
commands::run::RunArgs { commands::run::RunArgs {
@@ -795,7 +782,10 @@ async fn main() -> Result<()> {
kind: kind.as_deref(), kind: kind.as_deref(),
name: name.as_deref(), name: name.as_deref(),
tags: &tag, tags: &tag,
secret_fields: &secret_fields,
prefix: &prefix, prefix: &prefix,
dry_run,
output: out,
command: &command, command: &command,
}, },
&master_key, &master_key,

View File

@@ -20,17 +20,11 @@ pub struct Entry {
} }
/// A single encrypted field belonging to an Entry. /// A single encrypted field belonging to an Entry.
/// field_name, field_type, and value_len are stored in plaintext so that
/// `search` can show the schema without requiring the master key.
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct SecretField { pub struct SecretField {
pub id: Uuid, pub id: Uuid,
pub entry_id: Uuid, pub entry_id: Uuid,
pub field_name: String, pub field_name: String,
/// Inferred type: "string", "number", "boolean", "json"
pub field_type: String,
/// Length of the plaintext value in characters (0 for binary-like PEM)
pub value_len: i32,
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag /// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
pub encrypted: Vec<u8>, pub encrypted: Vec<u8>,
pub version: i64, pub version: i64,
@@ -54,8 +48,6 @@ pub struct EntryRow {
pub struct SecretFieldRow { pub struct SecretFieldRow {
pub id: Uuid, pub id: Uuid,
pub field_name: String, pub field_name: String,
pub field_type: String,
pub value_len: i32,
pub encrypted: Vec<u8>, pub encrypted: Vec<u8>,
} }

View File

@@ -1,5 +1,4 @@
use chrono::{DateTime, Local, Utc}; use chrono::{DateTime, Local, Utc};
use std::io::IsTerminal;
use std::str::FromStr; use std::str::FromStr;
/// Output format for all commands. /// Output format for all commands.
@@ -32,16 +31,12 @@ impl FromStr for OutputMode {
/// Resolve the effective output mode. /// Resolve the effective output mode.
/// - Explicit value from `--output` takes priority. /// - Explicit value from `--output` takes priority.
/// - TTY → text; non-TTY (piped/redirected) → json-compact. /// - Default is always `Json` (AI-first); use `-o text` for human-readable output.
pub fn resolve_output_mode(explicit: Option<&str>) -> anyhow::Result<OutputMode> { pub fn resolve_output_mode(explicit: Option<&str>) -> anyhow::Result<OutputMode> {
if let Some(s) = explicit { if let Some(s) = explicit {
return s.parse(); return s.parse();
} }
if std::io::stdout().is_terminal() { Ok(OutputMode::Json)
Ok(OutputMode::Text)
} else {
Ok(OutputMode::JsonCompact)
}
} }
/// Format a UTC timestamp for local human-readable output. /// Format a UTC timestamp for local human-readable output.