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
7e6ddf3a12
7 changed files with 812 additions and 11 deletions
|
|
@ -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"* ]]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue