v0.1.0.0 feat: multi-store support (EGB-281) (#1)
* feat: multi-store support via .secrets-store + --store flag Layer four-rule store resolution on top of the existing SECRETS_DIR primitive so users can manage multiple isolated encrypted stores (work vs personal, per-client, etc.) without giving up the tool's small-bash-script pitch. Resolution order (highest first): 1. --store <dir> flag (parsed in main pre-pass) 2. .secrets-store file in cwd or any ancestor up to $HOME 3. SECRETS_DIR env var (legacy escape hatch) 4. ~/.secrets default resolve_store() updates both SECRETS_DIR and KEY_FILE so existing single-store codepaths just work. New cmd_which / where / status report the active store. cmd_init, push, pull, push_workspaces, pull_workspaces, list, rm, rekey, run, which all call resolve_store at entry. Hardening from the EGB-281 adversarial review: - F1: cmd_run EXIT trap is now a named function (not string-interpolated), so paths with apostrophes still get plaintext cleaned up - F2: symlinked .secrets-store files are skipped, never read - F3/F4: --store flag rejects flag-shaped values and empty --store= - F5: HOME unset is detected up-front with a directed error - F11: check_initialized / check_key give context-aware errors that name both recovery paths (git clone vs secrets init) when a teammate clones a project bound to a non-existent store on their machine Tests: 37 → 66 (29 new). HOME=\$TEST_TMPDIR added to test setup so the walk-up logic stays bounded inside fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: add Multiple stores section to README Five subsections walk users through: how store resolution works, how to set up a second store on a machine, how to bind a project, how teammates join a bound project, and how to undo or change a binding. SECRETS_DIR table entry now points readers at the new --store flag and .secrets-store file as the preferred mechanisms. * chore: bump version and changelog (v0.1.0.0) First formal release. EGB-281 adds multi-store support; this commit seeds the VERSION file (4-digit MAJOR.MINOR.PATCH.MICRO) and the CHANGELOG.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
edb1614941
commit
dd83007be0
7 changed files with 812 additions and 11 deletions
39
CHANGELOG.md
Normal file
39
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
|
||||
|
||||
## [0.1.0.0] - 2026-05-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Multiple stores per user.** Run `secrets push` and `secrets pull` against any encrypted store directory you choose, not just `~/.secrets/`. Use cases: keep work secrets isolated from personal, run a separate store per client, or onboard a teammate to one project without giving them every other project's keys.
|
||||
- **`.secrets-store` file** for per-project bindings. Drop a one-line file at the project root (e.g. `echo work > .secrets-store && git add .secrets-store && git commit`) and every machine that clones the project automatically uses `~/.secrets-work/` for that repo. No env var to remember, no per-machine setup.
|
||||
- **`--store <dir>` flag** for one-shot overrides on any subcommand. `secrets --store ~/.secrets-clientA pull myapp` works without touching files. Bare names like `--store work` expand to `$HOME/.secrets-work`. `--store default` is sugar for `~/.secrets`.
|
||||
- **`secrets which`** prints the active store path and which rule chose it (flag, `.secrets-store` file, env var, or default). Aliases: `secrets where`, `secrets status`.
|
||||
- **Directed errors for teammate onboarding.** When `.secrets-store` resolves to an uninitialized store or one missing `key.txt`, the error message names both recovery paths: `git clone <remote>` to join an existing store, or `secrets --store <name> init` to start fresh.
|
||||
- **Active-store echo.** `secrets push` and `secrets pull` print `==> Store: <path> (from <source>)` whenever a non-default store is active, so wrong-store mistakes surface immediately.
|
||||
|
||||
### Changed
|
||||
|
||||
- `secrets list` now hints at `secrets which` when a non-default store is active.
|
||||
- `cmd_help` documents the four-rule resolution order (`--store` > `.secrets-store` file > `SECRETS_DIR` > default).
|
||||
- Error messages for missing init / missing key file are now context-aware: they distinguish between "default store on a fresh machine" and "non-default store referenced by `.secrets-store`."
|
||||
|
||||
### Security
|
||||
|
||||
- **Path expansion in `.secrets-store` is literal-only.** No `eval`, no `$VAR` interpolation, no `$(...)` execution. A committed `.secrets-store` containing `$(rm -rf ~)` reads as plain text, not as a command.
|
||||
- **Walk-up bounded by `$HOME`.** `secrets` never reads `$HOME/.secrets-store`, never walks past `$HOME` to `/`, and never follows symlinked `.secrets-store` files. Symlinks (potential supply-chain attack via committed link to `~/.aws/credentials` or similar) are ignored.
|
||||
- **`KEY_FILE` re-derives after `--store` switches stores.** Previously, calling `secrets pull --store other` would have decrypted ciphertext from the new store using the default store's key. Now `secrets` updates both `SECRETS_DIR` and `KEY_FILE` together inside `resolve_store()`.
|
||||
- **`secrets run` cleanup survives paths with apostrophes.** The EXIT trap is now a named function rather than a string-interpolated command, so projects at e.g. `/Users/you/Mom's Mac/code` still get plaintext cleared after the wrapped command exits.
|
||||
- **Test isolation:** the bats suite now sets `HOME=$TEST_TMPDIR` so `.secrets-store` walk-up cannot wander into the developer's real home directory.
|
||||
- **`--store` flag value validation.** Empty values (`--store=`) and flag-shaped values (`--store --workspaces`) are rejected with directed errors instead of silently mapping to `~/.secrets-`-something.
|
||||
- **Unset `HOME` is detected** with a directed error before the script tries to expand it. Helps cron, sudo without `-H`, and minimal CI runners.
|
||||
|
||||
### Tests
|
||||
|
||||
- 37 → 66 tests. New coverage: store resolution rules and precedence, walk-up boundaries, command-injection prevention, key-file re-derivation across stores, teammate-onboarding error path, monorepo workspace binding, F1–F5 adversarial regressions.
|
||||
|
||||
[0.1.0.0]: https://github.com/bmajewski/secrets/releases/tag/v0.1.0.0
|
||||
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -45,6 +45,19 @@ CLAUDE.md # This file
|
|||
|
||||
`~/.secrets/key.txt` is the age identity (private key). It is gitignored and must be copied manually to each machine once.
|
||||
|
||||
## Multi-store resolution
|
||||
|
||||
The active store directory is picked by `resolve_store()` using these rules, highest precedence first:
|
||||
|
||||
1. `--store <dir>` flag (parsed in the main pre-pass into `STORE_OVERRIDE`).
|
||||
2. `.secrets-store` file in cwd or any ancestor, walk-up bounded by `$HOME` (never reads `$HOME/.secrets-store` itself or anything above).
|
||||
3. `SECRETS_DIR` env var (legacy escape hatch).
|
||||
4. `~/.secrets` default.
|
||||
|
||||
`resolve_store` mutates BOTH `SECRETS_DIR` and `KEY_FILE` so the existing single-store code paths just work. `STORE_SOURCE` reports which rule won. `_LAST_FOUND_AT` (when rule 2 fires) holds the path of the file that was read.
|
||||
|
||||
`.secrets-store` parsing is deliberately conservative: first non-empty non-comment line wins, no shell expansion (no `$VAR`, `$()`, backticks). Bare names map via `_expand_store_path`: `work` → `$HOME/.secrets-work`, `default` → `$HOME/.secrets`.
|
||||
|
||||
## Environment variable
|
||||
|
||||
`SECRETS_DIR` overrides the default `~/.secrets` location (useful for testing).
|
||||
`SECRETS_DIR` overrides the default `~/.secrets` location (useful for testing). Per-project bindings via `.secrets-store` file beat this env var; use `--store <dir>` for one-shot overrides that beat everything.
|
||||
|
|
|
|||
91
README.md
91
README.md
|
|
@ -223,6 +223,95 @@ Stopping the dev server (or any failing command) ends the process; the `EXIT` tr
|
|||
|
||||
If you prefer to keep decrypted files on disk for a long editing session, use `secrets pull` and `secrets clear` manually instead.
|
||||
|
||||
### Multiple stores
|
||||
|
||||
By default, all your encrypted secrets live in one store at `~/.secrets/`. That works great if you have one set of secrets shared across machines. If you want **separate stores** — for example, work secrets isolated from personal projects, or one store per client — `secrets` supports that without any special setup.
|
||||
|
||||
A "store" is just a directory with its own `.git` repo, age key, and remote. You can have as many as you want.
|
||||
|
||||
#### How a store gets picked
|
||||
|
||||
When you run `secrets push` or `secrets pull`, the tool resolves the active store using the first matching rule (highest precedence first):
|
||||
|
||||
```
|
||||
1. --store <dir> flag passed on the command line
|
||||
2. .secrets-store file in the current directory or any ancestor up to $HOME
|
||||
3. SECRETS_DIR environment variable (legacy escape hatch)
|
||||
4. ~/.secrets default
|
||||
```
|
||||
|
||||
Run `secrets which` from any project directory to see which rule won and which store is active. Aliases `secrets where` and `secrets status` do the same thing.
|
||||
|
||||
#### Set up a second store on this machine
|
||||
|
||||
```bash
|
||||
# Create a fresh store at ~/.secrets-work with its own age key
|
||||
secrets --store work init
|
||||
|
||||
# Connect it to a separate private GitHub repo
|
||||
cd ~/.secrets-work
|
||||
git remote add origin git@github.com:<you>/work-secrets.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
The bare name `work` expands to `$HOME/.secrets-work`. Use `secrets --store /any/abs/path init` if you want a custom location.
|
||||
|
||||
#### Bind a project to a non-default store
|
||||
|
||||
In any project directory, write a `.secrets-store` file with the store's name (or path) and commit it:
|
||||
|
||||
```bash
|
||||
cd ~/myapp
|
||||
echo work > .secrets-store
|
||||
git add .secrets-store
|
||||
git commit -m "use work secrets store"
|
||||
```
|
||||
|
||||
After that, every `secrets push` / `secrets pull` from this project (or any subdirectory) automatically uses `~/.secrets-work`. Teammates who clone the project get the same binding for free — the file is in the repo.
|
||||
|
||||
When you push or pull from a non-default store, `secrets` echoes which one is active so you can spot mistakes immediately:
|
||||
|
||||
```
|
||||
==> Pushing secrets for project: myapp
|
||||
==> Store: /Users/you/.secrets-work (from .secrets-store file (~/myapp/.secrets-store))
|
||||
```
|
||||
|
||||
#### Joining a teammate's bound project
|
||||
|
||||
If you clone a project that has a committed `.secrets-store: work` file but you don't have `~/.secrets-work` set up locally, `secrets pull` will tell you exactly what to do:
|
||||
|
||||
```
|
||||
ERROR: Store not initialized: /Users/you/.secrets-work
|
||||
Resolved from: .secrets-store file (~/myapp/.secrets-store)
|
||||
This path doesn't exist on this machine yet.
|
||||
|
||||
If you're joining a teammate's existing store:
|
||||
git clone <their-store-remote> /Users/you/.secrets-work
|
||||
# then copy their key.txt to /Users/you/.secrets-work/key.txt
|
||||
|
||||
If you want a fresh new store at this path:
|
||||
secrets --store /Users/you/.secrets-work init
|
||||
```
|
||||
|
||||
You'll need two things from the teammate who set it up:
|
||||
|
||||
1. **The git remote URL** of the work-secrets repo — clone it to `~/.secrets-work` (or wherever the `.secrets-store` file resolves to on your machine).
|
||||
2. **The age key file** (`key.txt`) — same as standing up any new machine. AirDrop, scp, or USB.
|
||||
|
||||
Once both are in place, `secrets pull` works.
|
||||
|
||||
#### Undo or change a binding
|
||||
|
||||
```bash
|
||||
# Stop using a non-default store for this project
|
||||
rm .secrets-store
|
||||
git commit -am "go back to default secrets store"
|
||||
|
||||
# Or change which store the project is bound to
|
||||
echo personal > .secrets-store
|
||||
git commit -am "switch to personal secrets"
|
||||
```
|
||||
|
||||
### Monorepo support
|
||||
|
||||
For projects with multiple packages (monorepos using `package.json` workspaces), add the `-w` flag to operate on all workspaces at once:
|
||||
|
|
@ -273,7 +362,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/`
|
|||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `SECRETS_DIR` | `~/.secrets` | Override the secrets store location |
|
||||
| `SECRETS_DIR` | `~/.secrets` | Override the secrets store location (legacy; prefer `--store` or a `.secrets-store` file — see "Multiple stores" above) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
|
|||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.1.0.0
|
||||
302
secrets
302
secrets
|
|
@ -6,10 +6,27 @@ set -euo pipefail
|
|||
# secrets — encrypted env file sync between machines
|
||||
# Uses age key-file encryption + a private git repo.
|
||||
|
||||
# F5: explicit HOME check. `set -u` would already error if HOME were unset,
|
||||
# but the message would be cryptic ("HOME: unbound variable" pointing at
|
||||
# the SECRETS_DIR default-init line). Check up front with a directed message
|
||||
# so cron/sudo/CI users know what to fix.
|
||||
: "${HOME:?HOME is not set; secrets needs a home directory to find or create the store}"
|
||||
|
||||
# Capture the user-provided SECRETS_DIR (if any) before defaulting.
|
||||
# resolve_store() uses this to honor SECRETS_DIR as the legacy escape hatch
|
||||
# while letting .secrets-store files take precedence per project.
|
||||
_USER_SECRETS_DIR="${SECRETS_DIR:-}"
|
||||
SECRETS_DIR="${SECRETS_DIR:-$HOME/.secrets}"
|
||||
KEY_FILE="$SECRETS_DIR/key.txt"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Set by resolve_store(). Reports which rule chose SECRETS_DIR.
|
||||
STORE_SOURCE="default"
|
||||
# Path to the .secrets-store file that won resolution, if any.
|
||||
_LAST_FOUND_AT=""
|
||||
# --store flag value, captured by the pre-pass.
|
||||
STORE_OVERRIDE=""
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
|
@ -20,11 +37,37 @@ check_cmd() {
|
|||
}
|
||||
|
||||
check_initialized() {
|
||||
[ -d "$SECRETS_DIR/.git" ] || die "Not initialized. Run: secrets init"
|
||||
if [ -d "$SECRETS_DIR/.git" ]; then
|
||||
return
|
||||
fi
|
||||
if [ "$STORE_SOURCE" != "default" ]; then
|
||||
die "Store not initialized: $SECRETS_DIR
|
||||
Resolved from: $STORE_SOURCE
|
||||
This path doesn't exist on this machine yet.
|
||||
|
||||
If you're joining a teammate's existing store:
|
||||
git clone <their-store-remote> $SECRETS_DIR
|
||||
# then copy their key.txt to $SECRETS_DIR/key.txt
|
||||
|
||||
If you want a fresh new store at this path:
|
||||
secrets --store $SECRETS_DIR init"
|
||||
fi
|
||||
die "Not initialized. Run: secrets init"
|
||||
}
|
||||
|
||||
check_key() {
|
||||
[ -f "$KEY_FILE" ] || die "Key file not found at $KEY_FILE. Run: secrets init"
|
||||
if [ -f "$KEY_FILE" ]; then
|
||||
return
|
||||
fi
|
||||
if [ "$STORE_SOURCE" != "default" ]; then
|
||||
die "Key file not found at $KEY_FILE
|
||||
The store at $SECRETS_DIR exists but has no key.txt.
|
||||
Resolved from: $STORE_SOURCE
|
||||
|
||||
If joining a teammate's store: copy their key.txt to $KEY_FILE.
|
||||
If this is your own new store: re-run init for this store path."
|
||||
fi
|
||||
die "Key file not found at $KEY_FILE. Run: secrets init"
|
||||
}
|
||||
|
||||
get_pubkey() {
|
||||
|
|
@ -48,6 +91,146 @@ derive_project_name() {
|
|||
basename "$PWD"
|
||||
}
|
||||
|
||||
# ─── Store resolution ──────────────────────────────────────────────────
|
||||
#
|
||||
# Resolution order (highest precedence first):
|
||||
# 1. --store <dir> flag (captured by main pre-pass into STORE_OVERRIDE)
|
||||
# 2. .secrets-store file in cwd or any ancestor up to but NOT including $HOME
|
||||
# 3. SECRETS_DIR env var (legacy escape hatch)
|
||||
# 4. $HOME/.secrets default
|
||||
#
|
||||
# .secrets-store file format: first non-empty non-comment line is the store
|
||||
# spec. Spec is one of:
|
||||
# - absolute path (/Users/you/.secrets-work)
|
||||
# - ~/path (expanded to $HOME/path)
|
||||
# - bare name (e.g. "work" → $HOME/.secrets-work; "default" → $HOME/.secrets)
|
||||
# Comments (#) and CRLF line endings are tolerated. NO shell expansion is
|
||||
# applied to the file content — `$VAR`, `$(...)`, and backticks are read as
|
||||
# literal characters to prevent code injection from a committed file.
|
||||
|
||||
# Take a store spec (path or bare name) and return an absolute directory.
|
||||
_expand_store_path() {
|
||||
local path="$1"
|
||||
case "$path" in
|
||||
/*)
|
||||
echo "$path"
|
||||
;;
|
||||
'~')
|
||||
echo "$HOME"
|
||||
;;
|
||||
'~/'*)
|
||||
echo "$HOME/${path#\~/}"
|
||||
;;
|
||||
.*|*/*)
|
||||
# Relative path or path containing /. Treat as path relative to cwd.
|
||||
echo "$path"
|
||||
;;
|
||||
default)
|
||||
# Sugar: --store default → the canonical default store
|
||||
echo "$HOME/.secrets"
|
||||
;;
|
||||
*)
|
||||
# Bare name like "work" → $HOME/.secrets-work
|
||||
echo "$HOME/.secrets-$path"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Read a .secrets-store file. Print the first non-empty non-comment line,
|
||||
# trimmed. Return 1 if no usable line is found.
|
||||
_parse_secrets_store_file() {
|
||||
local file="$1"
|
||||
local line
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
# Strip CRLF
|
||||
line="${line%$'\r'}"
|
||||
# Trim leading whitespace
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
# Trim trailing whitespace
|
||||
line="${line%"${line##*[![:space:]]}"}"
|
||||
[ -z "$line" ] && continue
|
||||
case "$line" in '#'*) continue ;; esac
|
||||
printf '%s\n' "$line"
|
||||
return 0
|
||||
done < "$file"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Walk up from cwd looking for .secrets-store. Bounded by $HOME — never
|
||||
# walks INTO or PAST $HOME. If cwd is outside $HOME entirely (e.g. /tmp),
|
||||
# the walk does not run. Symlinks are resolved with `cd -P`.
|
||||
# On success: prints expanded store dir, sets _LAST_FOUND_AT, returns 0.
|
||||
_find_secrets_store_file() {
|
||||
local dir
|
||||
dir=$(pwd -P 2>/dev/null) || dir="$PWD"
|
||||
local home_resolved
|
||||
home_resolved=$(cd -P "$HOME" 2>/dev/null && pwd -P) || home_resolved="$HOME"
|
||||
|
||||
while [ -n "$dir" ] && [ "$dir" != "/" ] && [ "$dir" != "$home_resolved" ]; do
|
||||
# Bound: only walk while we're strictly below $HOME.
|
||||
case "$dir" in
|
||||
"$home_resolved"/*) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
|
||||
# F2: never follow a symlinked .secrets-store. A committed symlink
|
||||
# could point at any user-readable file (~/.aws/credentials, /etc/passwd)
|
||||
# and trick the resolver into reading attacker-chosen content.
|
||||
if [ -L "$dir/.secrets-store" ]; then
|
||||
:
|
||||
elif [ -f "$dir/.secrets-store" ]; then
|
||||
local content
|
||||
if content=$(_parse_secrets_store_file "$dir/.secrets-store"); then
|
||||
local expanded
|
||||
expanded=$(_expand_store_path "$content")
|
||||
_LAST_FOUND_AT="$dir/.secrets-store"
|
||||
printf '%s\n' "$expanded"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
dir=$(dirname "$dir")
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve the active store directory and update SECRETS_DIR + KEY_FILE.
|
||||
# Sets STORE_SOURCE to one of:
|
||||
# "--store flag" | ".secrets-store file (<path>)" | "SECRETS_DIR env var" | "default"
|
||||
resolve_store() {
|
||||
local resolved=""
|
||||
local source=""
|
||||
|
||||
if [ -n "${STORE_OVERRIDE:-}" ]; then
|
||||
resolved=$(_expand_store_path "$STORE_OVERRIDE")
|
||||
source="--store flag"
|
||||
_LAST_FOUND_AT=""
|
||||
elif resolved=$(_find_secrets_store_file); then
|
||||
source=".secrets-store file ($_LAST_FOUND_AT)"
|
||||
elif [ -n "${_USER_SECRETS_DIR:-}" ]; then
|
||||
resolved="$_USER_SECRETS_DIR"
|
||||
source="SECRETS_DIR env var"
|
||||
_LAST_FOUND_AT=""
|
||||
else
|
||||
resolved="$HOME/.secrets"
|
||||
source="default"
|
||||
_LAST_FOUND_AT=""
|
||||
fi
|
||||
|
||||
SECRETS_DIR="$resolved"
|
||||
KEY_FILE="$SECRETS_DIR/key.txt"
|
||||
STORE_SOURCE="$source"
|
||||
}
|
||||
|
||||
# Echo "==> Store: ..." when SECRETS_DIR is not the canonical default.
|
||||
# Called from cmd_push/cmd_pull (and their workspace variants) after resolution.
|
||||
echo_store_if_non_default() {
|
||||
if [ "$SECRETS_DIR" != "$HOME/.secrets" ]; then
|
||||
info "Store: $SECRETS_DIR (from $STORE_SOURCE)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── End store resolution ──────────────────────────────────────────────
|
||||
|
||||
# Collect secret files from a directory:
|
||||
# .env, .env.*, .dev.vars (excluding .envrc, .environment-*)
|
||||
# Sets the COLLECTED_FILES array. Returns 1 if no files found.
|
||||
|
|
@ -117,6 +300,7 @@ HOOKEOF
|
|||
cmd_init() {
|
||||
check_cmd age
|
||||
check_cmd git
|
||||
resolve_store
|
||||
|
||||
if [ -d "$SECRETS_DIR/.git" ]; then
|
||||
die "Already initialized at $SECRETS_DIR. Key file preserved."
|
||||
|
|
@ -214,12 +398,14 @@ commit_and_push_secrets() {
|
|||
cmd_push() {
|
||||
check_cmd age
|
||||
check_cmd git
|
||||
resolve_store
|
||||
check_initialized
|
||||
check_key
|
||||
|
||||
local project
|
||||
project=$(derive_project_name "${1:-}")
|
||||
info "Pushing secrets for project: $project"
|
||||
echo_store_if_non_default
|
||||
|
||||
local pubkey
|
||||
pubkey=$(get_pubkey)
|
||||
|
|
@ -235,6 +421,7 @@ cmd_push_workspaces() {
|
|||
check_cmd age
|
||||
check_cmd git
|
||||
check_cmd jq
|
||||
resolve_store
|
||||
check_initialized
|
||||
check_key
|
||||
|
||||
|
|
@ -242,6 +429,7 @@ cmd_push_workspaces() {
|
|||
local monorepo_name
|
||||
monorepo_name=$(derive_project_name "")
|
||||
info "Pushing workspaces for monorepo: $monorepo_name"
|
||||
echo_store_if_non_default
|
||||
|
||||
local pubkey
|
||||
pubkey=$(get_pubkey)
|
||||
|
|
@ -274,6 +462,7 @@ cmd_push_workspaces() {
|
|||
cmd_pull() {
|
||||
check_cmd age
|
||||
check_cmd git
|
||||
resolve_store
|
||||
check_initialized
|
||||
check_key
|
||||
|
||||
|
|
@ -281,6 +470,7 @@ cmd_pull() {
|
|||
project=$(derive_project_name "${1:-}")
|
||||
local target_dir="$PWD"
|
||||
info "Pulling secrets for project: $project"
|
||||
echo_store_if_non_default
|
||||
|
||||
# Pull latest
|
||||
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
||||
|
|
@ -344,6 +534,7 @@ cmd_pull_workspaces() {
|
|||
check_cmd age
|
||||
check_cmd git
|
||||
check_cmd jq
|
||||
resolve_store
|
||||
check_initialized
|
||||
check_key
|
||||
|
||||
|
|
@ -351,6 +542,7 @@ cmd_pull_workspaces() {
|
|||
local monorepo_name
|
||||
monorepo_name=$(derive_project_name "")
|
||||
info "Pulling workspaces for monorepo: $monorepo_name"
|
||||
echo_store_if_non_default
|
||||
|
||||
# Pull latest from remote
|
||||
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
||||
|
|
@ -396,6 +588,7 @@ cmd_pull_workspaces() {
|
|||
}
|
||||
|
||||
cmd_list() {
|
||||
resolve_store
|
||||
check_initialized
|
||||
|
||||
local found=0
|
||||
|
|
@ -416,10 +609,17 @@ cmd_list() {
|
|||
if [ "$found" -eq 0 ]; then
|
||||
echo "No projects found. Run 'secrets push <project>' to add one."
|
||||
fi
|
||||
|
||||
# DX-7 hint: when a non-default store is active, point users at `secrets which`.
|
||||
if [ "$SECRETS_DIR" != "$HOME/.secrets" ]; then
|
||||
echo ""
|
||||
info "Showing projects in $SECRETS_DIR. Run 'secrets which' for details."
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_rm() {
|
||||
check_cmd git
|
||||
resolve_store
|
||||
check_initialized
|
||||
|
||||
local project="${1:-}"
|
||||
|
|
@ -443,6 +643,7 @@ cmd_rm() {
|
|||
cmd_rekey() {
|
||||
check_cmd age
|
||||
check_cmd git
|
||||
resolve_store
|
||||
check_initialized
|
||||
check_key
|
||||
|
||||
|
|
@ -564,6 +765,16 @@ cmd_clear_workspaces() {
|
|||
info "Cleared $total secret file(s) from workspace"
|
||||
}
|
||||
|
||||
# EXIT-trap helpers for cmd_run. Defined as functions (not inline string
|
||||
# traps) so $_RUN_PWD is dereferenced safely regardless of special chars
|
||||
# in the path. F1: trap "cd '$X'" breaks when X contains a single quote.
|
||||
_run_cleanup() {
|
||||
cd -- "${_RUN_PWD:-.}" 2>/dev/null && cmd_clear
|
||||
}
|
||||
_run_cleanup_workspaces() {
|
||||
cd -- "${_RUN_PWD:-.}" 2>/dev/null && cmd_clear_workspaces
|
||||
}
|
||||
|
||||
cmd_run() {
|
||||
local workspace_mode=false
|
||||
local project=""
|
||||
|
|
@ -580,18 +791,26 @@ cmd_run() {
|
|||
|
||||
[ $# -gt 0 ] || die "Usage: secrets run [-w] [--] <command...>"
|
||||
|
||||
# Pull secrets
|
||||
# F8: pin the project directory now so the EXIT trap clears the right
|
||||
# plaintext files even if the user's command does `cd` into another dir.
|
||||
# Use a global + named function (NOT string-interpolated trap) so paths
|
||||
# with special characters — apostrophes, dollar signs, spaces — work.
|
||||
# Single-quoting `$_RUN_PWD` into a string trap would break on any path
|
||||
# with a single quote (e.g. /Users/bri/it's-app), and the trap would
|
||||
# silently fail to clean up plaintext secrets. EGB-281 F1.
|
||||
_RUN_PWD="$PWD"
|
||||
|
||||
# Pull secrets (cmd_pull/cmd_pull_workspaces call resolve_store internally)
|
||||
if [ "$workspace_mode" = true ]; then
|
||||
cmd_pull_workspaces
|
||||
else
|
||||
cmd_pull "$project"
|
||||
fi
|
||||
|
||||
# Set trap to clear secrets on exit (normal, error, interrupt, terminate)
|
||||
if [ "$workspace_mode" = true ]; then
|
||||
trap 'cmd_clear_workspaces' EXIT
|
||||
trap _run_cleanup_workspaces EXIT
|
||||
else
|
||||
trap 'cmd_clear' EXIT
|
||||
trap _run_cleanup EXIT
|
||||
fi
|
||||
|
||||
# Execute the command, capturing exit code (don't let set -e kill us)
|
||||
|
|
@ -600,6 +819,12 @@ cmd_run() {
|
|||
exit "$rc"
|
||||
}
|
||||
|
||||
cmd_which() {
|
||||
resolve_store
|
||||
echo "store: $SECRETS_DIR"
|
||||
echo "source: $STORE_SOURCE"
|
||||
}
|
||||
|
||||
cmd_help() {
|
||||
cat << 'EOF'
|
||||
secrets — encrypted secret file sync between machines
|
||||
|
|
@ -616,12 +841,28 @@ Usage:
|
|||
secrets list List all projects and their secret files
|
||||
secrets rm <project> Remove a project's secrets from the repo
|
||||
secrets rekey Re-encrypt all secrets with a new key
|
||||
secrets which Show the active store path and which rule chose it
|
||||
secrets where Alias for `which`
|
||||
secrets status Alias for `which`
|
||||
|
||||
Tracked files: .env, .env.*, .dev.vars
|
||||
|
||||
If [project] is omitted, it is derived from the current directory's
|
||||
git remote (if available) or the directory name.
|
||||
|
||||
Stores:
|
||||
Most users have one ~/.secrets/ store. To use a separate store (e.g.
|
||||
for work secrets vs personal), use any of these resolution rules
|
||||
(highest precedence first):
|
||||
|
||||
1. --store <dir> flag secrets --store ~/.secrets-work pull
|
||||
2. .secrets-store file in project echo work > .secrets-store && git add ...
|
||||
3. SECRETS_DIR env var (legacy) SECRETS_DIR=~/.secrets-work secrets pull
|
||||
4. ~/.secrets default
|
||||
|
||||
Bare names ("work") expand to ~/.secrets-work. The name "default"
|
||||
resolves to ~/.secrets. Run `secrets which` to inspect the active store.
|
||||
|
||||
Workspaces:
|
||||
With -w/--workspaces, reads package.json "workspaces" field to find
|
||||
workspace directories. Each workspace's secret files are stored under
|
||||
|
|
@ -629,12 +870,58 @@ Workspaces:
|
|||
are stored under <monorepo>/ directly. Requires jq.
|
||||
|
||||
Environment:
|
||||
SECRETS_DIR Path to secrets repo (default: ~/.secrets)
|
||||
SECRETS_DIR Path to secrets repo (default: ~/.secrets). See also
|
||||
the .secrets-store file and --store flag above.
|
||||
EOF
|
||||
}
|
||||
|
||||
# ─── Main ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Pre-pass: extract --store flag from anywhere in the args (before `--` only).
|
||||
# Lets `secrets --store work push`, `secrets push --store work`, and
|
||||
# `secrets run --store work cmd ...` all work consistently. After `--`,
|
||||
# args belong to the user's command and are passed through untouched.
|
||||
ARGS=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--)
|
||||
# Stop pre-pass; pass `--` and everything after through untouched
|
||||
# so the user's command (e.g. `secrets run -- cmd --store foo`) is
|
||||
# not mangled.
|
||||
ARGS+=("$@")
|
||||
break
|
||||
;;
|
||||
--store)
|
||||
[ $# -ge 2 ] || die "--store requires a directory or store name"
|
||||
# F3: reject values that look like another flag — almost always a typo
|
||||
# (`secrets --store push` → user dropped the value, would silently use
|
||||
# ~/.secrets-push and surface a confusing "not initialized" error).
|
||||
case "$2" in
|
||||
--|-*) die "--store value looks like a flag: $2 (did you forget the value?)" ;;
|
||||
esac
|
||||
STORE_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--store=*)
|
||||
# F4: --store= with empty value used to silently fall through to the
|
||||
# next rule. Treat it as a typo too.
|
||||
[ -n "${1#--store=}" ] || die "--store= requires a value"
|
||||
STORE_OVERRIDE="${1#--store=}"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
# Re-set positional params. Bulletproof against empty array under `set -u`.
|
||||
if [ ${#ARGS[@]} -gt 0 ]; then
|
||||
set -- "${ARGS[@]}"
|
||||
else
|
||||
set --
|
||||
fi
|
||||
|
||||
case "${1:-help}" in
|
||||
init) cmd_init ;;
|
||||
push)
|
||||
|
|
@ -665,6 +952,7 @@ case "${1:-help}" in
|
|||
list) cmd_list ;;
|
||||
rm) cmd_rm "${2:-}" ;;
|
||||
rekey) cmd_rekey ;;
|
||||
which|where|status) cmd_which ;;
|
||||
help|--help|-h) cmd_help ;;
|
||||
*) die "Unknown command: $1. Run 'secrets help' for usage." ;;
|
||||
esac
|
||||
|
|
|
|||
|
|
@ -519,3 +519,356 @@ EOF
|
|||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"No secret files"* ]]
|
||||
}
|
||||
|
||||
# ─── EGB-281: multi-store resolution ──────────────────────────────────
|
||||
|
||||
@test "which reports default store when no overrides" {
|
||||
unset SECRETS_DIR
|
||||
cd "$HOME"
|
||||
mkdir -p subdir
|
||||
cd subdir
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"$HOME/.secrets"* ]]
|
||||
[[ "$output" == *"source: default"* ]]
|
||||
}
|
||||
|
||||
@test "which uses .secrets-store file in cwd" {
|
||||
unset SECRETS_DIR
|
||||
mkdir -p "$HOME/.secrets-work"
|
||||
create_bound_project_dir myapp "~/.secrets-work"
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"$HOME/.secrets-work"* ]]
|
||||
[[ "$output" == *".secrets-store file"* ]]
|
||||
}
|
||||
|
||||
@test "--store flag overrides .secrets-store file and SECRETS_DIR env" {
|
||||
export SECRETS_DIR="$HOME/.secrets-from-env"
|
||||
create_bound_project_dir myapp "~/.secrets-from-file"
|
||||
run "$SECRETS_BIN" --store "$HOME/.secrets-from-flag" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"$HOME/.secrets-from-flag"* ]]
|
||||
[[ "$output" == *"--store flag"* ]]
|
||||
}
|
||||
|
||||
@test "which walks up to find .secrets-store in ancestor" {
|
||||
unset SECRETS_DIR
|
||||
mkdir -p "$HOME/.secrets-work"
|
||||
mkdir -p "$WORK_DIR/repo/sub/deep"
|
||||
echo "~/.secrets-work" > "$WORK_DIR/repo/.secrets-store"
|
||||
cd "$WORK_DIR/repo/sub/deep"
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"$HOME/.secrets-work"* ]]
|
||||
}
|
||||
|
||||
@test "which walk-up stops at HOME boundary, does not read \$HOME/.secrets-store" {
|
||||
unset SECRETS_DIR
|
||||
echo "should-not-be-used" > "$HOME/.secrets-store"
|
||||
mkdir -p "$WORK_DIR/repo"
|
||||
cd "$WORK_DIR/repo"
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" != *"should-not-be-used"* ]]
|
||||
[[ "$output" == *"$HOME/.secrets"* ]]
|
||||
[[ "$output" == *"source: default"* ]]
|
||||
}
|
||||
|
||||
@test "which from outside HOME falls through to default" {
|
||||
unset SECRETS_DIR
|
||||
cd /tmp
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"$HOME/.secrets"* ]]
|
||||
[[ "$output" == *"source: default"* ]]
|
||||
}
|
||||
|
||||
@test "empty .secrets-store falls through to next rule" {
|
||||
unset SECRETS_DIR
|
||||
mkdir -p "$WORK_DIR/repo"
|
||||
cd "$WORK_DIR/repo"
|
||||
: > .secrets-store
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"source: default"* ]]
|
||||
}
|
||||
|
||||
@test "comment-only .secrets-store falls through" {
|
||||
unset SECRETS_DIR
|
||||
mkdir -p "$WORK_DIR/repo"
|
||||
cd "$WORK_DIR/repo"
|
||||
printf '# this is a comment\n \n# another\n' > .secrets-store
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"source: default"* ]]
|
||||
}
|
||||
|
||||
@test "bare name 'work' resolves to ~/.secrets-work" {
|
||||
unset SECRETS_DIR
|
||||
mkdir -p "$HOME/.secrets-work/.git"
|
||||
mkdir -p "$WORK_DIR/repo"
|
||||
echo "work" > "$WORK_DIR/repo/.secrets-store"
|
||||
cd "$WORK_DIR/repo"
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"$HOME/.secrets-work"* ]]
|
||||
}
|
||||
|
||||
@test "~/-prefix in .secrets-store expands to HOME" {
|
||||
unset SECRETS_DIR
|
||||
mkdir -p "$HOME/.secrets-x"
|
||||
mkdir -p "$WORK_DIR/repo"
|
||||
echo "~/.secrets-x" > "$WORK_DIR/repo/.secrets-store"
|
||||
cd "$WORK_DIR/repo"
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"$HOME/.secrets-x"* ]]
|
||||
}
|
||||
|
||||
@test ".secrets-store with command injection content does not execute" {
|
||||
unset SECRETS_DIR
|
||||
mkdir -p "$WORK_DIR/repo"
|
||||
cd "$WORK_DIR/repo"
|
||||
local pwn_marker="/tmp/secrets-pwn-$$"
|
||||
rm -f "$pwn_marker"
|
||||
echo "\$(touch $pwn_marker)" > .secrets-store
|
||||
run "$SECRETS_BIN" which
|
||||
[ ! -f "$pwn_marker" ]
|
||||
}
|
||||
|
||||
@test "pull --store uses the target store's key, not the default key" {
|
||||
unset SECRETS_DIR
|
||||
# Set up store A (default) with its own key
|
||||
"$SECRETS_BIN" init >/dev/null 2>&1
|
||||
cd "$HOME/.secrets" && git remote add origin "$REMOTE_DIR" && cd -
|
||||
|
||||
# Set up store B at a different path, with its OWN key
|
||||
local STORE_B="$HOME/.secrets-b"
|
||||
"$SECRETS_BIN" --store "$STORE_B" init >/dev/null 2>&1
|
||||
|
||||
# Push secrets to STORE B using B's key
|
||||
create_project_dir testproj
|
||||
"$SECRETS_BIN" --store "$STORE_B" push testproj >/dev/null 2>&1
|
||||
|
||||
# Pull to a new dir using --store B (must use B's key.txt, not the default)
|
||||
local pull_dir="$WORK_DIR/pull-target"
|
||||
mkdir -p "$pull_dir"
|
||||
cd "$pull_dir"
|
||||
run "$SECRETS_BIN" --store "$STORE_B" pull testproj
|
||||
[ "$status" -eq 0 ]
|
||||
[ -f "$pull_dir/.env" ]
|
||||
grep -q "SECRET_KEY=abc123" "$pull_dir/.env"
|
||||
}
|
||||
|
||||
@test "run accepts --store flag" {
|
||||
init_with_remote
|
||||
create_project_dir testproj
|
||||
"$SECRETS_BIN" push testproj >/dev/null 2>&1
|
||||
rm "$WORK_DIR/testproj/.env" "$WORK_DIR/testproj/.env.staging"
|
||||
|
||||
run "$SECRETS_BIN" --store "$SECRETS_DIR" run -- cat .env
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"SECRET_KEY=abc123"* ]]
|
||||
}
|
||||
|
||||
@test "uninitialized store referenced by .secrets-store gives directed error" {
|
||||
unset SECRETS_DIR
|
||||
"$SECRETS_BIN" init >/dev/null 2>&1
|
||||
mkdir -p "$WORK_DIR/repo"
|
||||
echo "missing-store" > "$WORK_DIR/repo/.secrets-store"
|
||||
cd "$WORK_DIR/repo"
|
||||
|
||||
run "$SECRETS_BIN" pull
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"git clone"* ]]
|
||||
[[ "$output" == *"--store"* ]]
|
||||
}
|
||||
|
||||
@test "push -w ignores per-workspace .secrets-store, uses monorepo root binding" {
|
||||
unset SECRETS_DIR
|
||||
mkdir -p "$HOME/.secrets-monorepo"
|
||||
"$SECRETS_BIN" --store "$HOME/.secrets-monorepo" init >/dev/null 2>&1
|
||||
cd "$HOME/.secrets-monorepo" && git remote add origin "$REMOTE_DIR" && \
|
||||
git commit --allow-empty -m "init" >/dev/null 2>&1 && \
|
||||
(git push -u origin main >/dev/null 2>&1 || git push -u origin master >/dev/null 2>&1) && \
|
||||
cd -
|
||||
|
||||
local mono="$WORK_DIR/myapp"
|
||||
mkdir -p "$mono/apps/web"
|
||||
cat > "$mono/package.json" << 'PKG'
|
||||
{"workspaces": ["apps/*"]}
|
||||
PKG
|
||||
echo "ROOT=top" > "$mono/.env"
|
||||
echo "WEB=val" > "$mono/apps/web/.env"
|
||||
echo "monorepo" > "$mono/.secrets-store"
|
||||
echo "other-store" > "$mono/apps/web/.secrets-store"
|
||||
git init "$mono" >/dev/null 2>&1
|
||||
cd "$mono"
|
||||
|
||||
run "$SECRETS_BIN" push -w
|
||||
[ "$status" -eq 0 ]
|
||||
[ -f "$HOME/.secrets-monorepo/myapp/.env.age" ]
|
||||
[ -f "$HOME/.secrets-monorepo/myapp/apps/web/.env.age" ]
|
||||
[ ! -d "$HOME/.secrets-other-store" ]
|
||||
}
|
||||
|
||||
@test "push echoes Store info when non-default store is active" {
|
||||
unset SECRETS_DIR
|
||||
mkdir -p "$HOME/.secrets-work"
|
||||
"$SECRETS_BIN" --store "$HOME/.secrets-work" init >/dev/null 2>&1
|
||||
cd "$HOME/.secrets-work" && git remote add origin "$REMOTE_DIR" && \
|
||||
git commit --allow-empty -m "init" >/dev/null 2>&1 && \
|
||||
(git push -u origin main >/dev/null 2>&1 || git push -u origin master >/dev/null 2>&1) && \
|
||||
cd -
|
||||
|
||||
create_project_dir myapp
|
||||
run "$SECRETS_BIN" --store "$HOME/.secrets-work" push myapp
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"Store: $HOME/.secrets-work"* ]]
|
||||
}
|
||||
|
||||
# ─── EGB-281: gap-filler tests (auto-decided during /ship coverage audit) ─
|
||||
|
||||
@test "--store with missing argument errors out" {
|
||||
run "$SECRETS_BIN" --store
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"--store requires"* ]]
|
||||
}
|
||||
|
||||
@test "--store=value (equals form) is accepted" {
|
||||
unset SECRETS_DIR
|
||||
mkdir -p "$HOME/.secrets-equals"
|
||||
cd "$HOME"
|
||||
run "$SECRETS_BIN" --store="$HOME/.secrets-equals" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"$HOME/.secrets-equals"* ]]
|
||||
}
|
||||
|
||||
@test "where and status are aliases of which" {
|
||||
unset SECRETS_DIR
|
||||
cd "$HOME"
|
||||
mkdir -p subdir
|
||||
cd subdir
|
||||
run "$SECRETS_BIN" where
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"source:"* ]]
|
||||
|
||||
run "$SECRETS_BIN" status
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"source:"* ]]
|
||||
}
|
||||
|
||||
@test "--store default sugar resolves to ~/.secrets" {
|
||||
unset SECRETS_DIR
|
||||
cd "$HOME"
|
||||
run "$SECRETS_BIN" --store default which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"$HOME/.secrets"* ]]
|
||||
}
|
||||
|
||||
@test "missing key.txt in non-default store gives directed error" {
|
||||
unset SECRETS_DIR
|
||||
# Make a "store" directory with .git but no key.txt — simulates a teammate
|
||||
# who cloned the remote but hasn't received the key yet.
|
||||
local STORE_NOKEY="$HOME/.secrets-nokey"
|
||||
mkdir -p "$STORE_NOKEY"
|
||||
git init "$STORE_NOKEY" >/dev/null 2>&1
|
||||
mkdir -p "$WORK_DIR/proj"
|
||||
echo "nokey" > "$WORK_DIR/proj/.secrets-store"
|
||||
echo "VAL=x" > "$WORK_DIR/proj/.env"
|
||||
cd "$WORK_DIR/proj"
|
||||
|
||||
run "$SECRETS_BIN" push myapp
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"key.txt"* ]]
|
||||
[[ "$output" == *"teammate"* ]]
|
||||
}
|
||||
|
||||
@test "CRLF line endings in .secrets-store are tolerated" {
|
||||
unset SECRETS_DIR
|
||||
mkdir -p "$HOME/.secrets-crlf"
|
||||
mkdir -p "$WORK_DIR/proj"
|
||||
printf '~/.secrets-crlf\r\n' > "$WORK_DIR/proj/.secrets-store"
|
||||
cd "$WORK_DIR/proj"
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"$HOME/.secrets-crlf"* ]]
|
||||
}
|
||||
|
||||
@test "list hints at 'secrets which' when non-default store is active" {
|
||||
unset SECRETS_DIR
|
||||
mkdir -p "$HOME/.secrets-x"
|
||||
"$SECRETS_BIN" --store "$HOME/.secrets-x" init >/dev/null 2>&1
|
||||
|
||||
run "$SECRETS_BIN" --store "$HOME/.secrets-x" list
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"secrets which"* ]]
|
||||
}
|
||||
|
||||
# ─── EGB-281: adversarial-review regression tests (F1-F5) ─────────────
|
||||
|
||||
@test "F1: cmd_run cleans up plaintext when project path contains apostrophe" {
|
||||
init_with_remote
|
||||
# Project dir whose name contains a single quote — string-interpolated trap
|
||||
# form would close its quoting early on this path and silently fail to clean up.
|
||||
local QUOTED_DIR="$WORK_DIR/dont-leak'apostrophe-test"
|
||||
mkdir -p "$QUOTED_DIR"
|
||||
echo "SECRET_KEY=should-not-leak" > "$QUOTED_DIR/.env"
|
||||
cd "$QUOTED_DIR"
|
||||
# Push with the auto-derived project name (matches the dir name, including the ')
|
||||
"$SECRETS_BIN" push >/dev/null 2>&1
|
||||
rm "$QUOTED_DIR/.env"
|
||||
|
||||
# Run a command, then verify .env is cleared by the EXIT trap
|
||||
run "$SECRETS_BIN" run -- cat .env
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"should-not-leak"* ]]
|
||||
# CRITICAL: the trap must have cleaned up — .env must NOT exist on disk.
|
||||
# If F1 regressed (string-interpolated trap), the file would still be here.
|
||||
[ ! -f "$QUOTED_DIR/.env" ]
|
||||
}
|
||||
|
||||
@test "F2: symlinked .secrets-store is skipped, not followed" {
|
||||
unset SECRETS_DIR
|
||||
# Create a sensitive-looking target outside the project
|
||||
local TARGET="$HOME/.secret-target"
|
||||
echo "/etc/passwd" > "$TARGET"
|
||||
mkdir -p "$WORK_DIR/proj"
|
||||
ln -s "$TARGET" "$WORK_DIR/proj/.secrets-store"
|
||||
cd "$WORK_DIR/proj"
|
||||
run "$SECRETS_BIN" which
|
||||
[ "$status" -eq 0 ]
|
||||
# The symlink should be ignored, falling through to default
|
||||
[[ "$output" != *"/etc/passwd"* ]]
|
||||
[[ "$output" == *"$HOME/.secrets"* ]]
|
||||
[[ "$output" == *"source: default"* ]]
|
||||
}
|
||||
|
||||
@test "F3: --store rejects flag-shaped value" {
|
||||
run "$SECRETS_BIN" --store --workspaces which
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"looks like a flag"* ]]
|
||||
}
|
||||
|
||||
@test "F3: --store rejects literal --" {
|
||||
run "$SECRETS_BIN" --store -- which
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"looks like a flag"* ]]
|
||||
}
|
||||
|
||||
@test "F4: --store= empty value is rejected" {
|
||||
run "$SECRETS_BIN" --store= which
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"requires a value"* ]]
|
||||
}
|
||||
|
||||
@test "F5: HOME unset gives directed error" {
|
||||
# Capture current HOME so we can restore for teardown
|
||||
local SAVED_HOME="$HOME"
|
||||
unset HOME
|
||||
run "$SECRETS_BIN" which
|
||||
export HOME="$SAVED_HOME" # restore before assertions in case bats relies on it
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"HOME"* ]]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,11 +14,18 @@ setup() {
|
|||
export TEST_TMPDIR
|
||||
TEST_TMPDIR=$(mktemp -d)
|
||||
|
||||
# Isolate HOME so .secrets-store walk-up cannot stray into the real
|
||||
# developer's home directory (or pick up files in / if HOME happens to
|
||||
# not be a real ancestor of /tmp). EGB-281 F9.
|
||||
export HOME="$TEST_TMPDIR"
|
||||
|
||||
# Secrets repo lives in temp
|
||||
export SECRETS_DIR="$TEST_TMPDIR/secrets-repo"
|
||||
|
||||
# Working directory for simulating project dirs
|
||||
export WORK_DIR="$TEST_TMPDIR/work"
|
||||
# Working directory for simulating project dirs (kept under $HOME so
|
||||
# the .secrets-store walk-up logic, which is bounded by $HOME, can find
|
||||
# files placed in test fixtures).
|
||||
export WORK_DIR="$HOME/work"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
# Create a bare "remote" repo for push/pull testing
|
||||
|
|
@ -50,3 +57,14 @@ create_project_dir() {
|
|||
echo "DB_HOST=staging.db.example.com" > "$dir/.env.staging"
|
||||
cd "$dir"
|
||||
}
|
||||
|
||||
# Helper: create a project dir bound to a store via .secrets-store file
|
||||
create_bound_project_dir() {
|
||||
local name="$1"
|
||||
local store_value="$2"
|
||||
local dir="$WORK_DIR/$name"
|
||||
mkdir -p "$dir"
|
||||
echo "SECRET_KEY=abc123" > "$dir/.env"
|
||||
echo "$store_value" > "$dir/.secrets-store"
|
||||
cd "$dir"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue