secrets/test/secrets.bats
Brian Majewski 7c3a76e8c1
v0.1.1.0 feat: optional remote URL in .secrets-store (EGB-282) (#2)
* chore: ignore .gstack/ (per-project local state)

* feat: optional remote URL in .secrets-store (EGB-282)

A second whitespace-separated token after the store name in .secrets-store
is treated as the store's git remote URL. When a teammate clones a project
bound to a store they don't have locally yet, the directed missing-store
error now fills in `git clone <url> <path>` so they can copy-paste instead
of asking the original setter for the URL.

Backward compatible: single-token .secrets-store files (the v0.1.0.x
format) continue to work and produce the existing `<their-store-remote>`
placeholder.

Security hardening (caught by adversarial review during /ship):
- The rendered git clone line is meant to be copy-pasted by a teammate.
  Without sanitization, `work evil.git;rm -rf ~` would render verbatim
  and execute `rm -rf ~` on paste. The parser now rejects URLs containing
  shell metacharacters (;&|<>$`(){}*?!"'\\), control characters (incl.
  ANSI escape sequences that could spoof terminal output), and embedded
  whitespace. Rejected URLs are dropped with a stderr warning; the error
  falls back to the safe placeholder.
- Switched from `set -- $line` to `read -r spec rest` so the URL field
  isn't glob-expanded or word-split — important so `work *` from a
  populated directory doesn't leak filenames into the URL field.

Tests 72 → 80. New: backward compat, SSH+HTTPS+~/-prefix URL forms,
comment-and-URL form, four named injection vectors (shell metachar,
backtick, $(), ANSI escape), multi-token URL, glob char, and a positive
test asserting standard git URL chars round-trip unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.1.1.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:54:45 -07:00

1047 lines
31 KiB
Bash

#!/usr/bin/env bats
load test_helper
# ─── init ──────────────────────────────────────────────────────────────
@test "init creates repo with key and gitignore" {
run "$SECRETS_BIN" init
[ "$status" -eq 0 ]
[ -d "$SECRETS_DIR/.git" ]
[ -f "$SECRETS_DIR/key.txt" ]
[ -f "$SECRETS_DIR/.gitignore" ]
grep -q "key.txt" "$SECRETS_DIR/.gitignore"
grep -qF '!**/.env.*.age' "$SECRETS_DIR/.gitignore"
}
@test "init installs pre-commit hook" {
run "$SECRETS_BIN" init
[ "$status" -eq 0 ]
[ -x "$SECRETS_DIR/.git/hooks/pre-commit" ]
}
@test "init warns if already initialized" {
"$SECRETS_BIN" init >/dev/null 2>&1
local key_before
key_before=$(cat "$SECRETS_DIR/key.txt")
run "$SECRETS_BIN" init
[ "$status" -eq 1 ]
[[ "$output" == *"Already initialized"* ]]
# Key must not be overwritten
local key_after
key_after=$(cat "$SECRETS_DIR/key.txt")
[ "$key_before" = "$key_after" ]
}
@test "init fails without age" {
# Create a temp PATH without age
local fake_path="$TEST_TMPDIR/fake-bin"
mkdir -p "$fake_path"
ln -s "$(which git)" "$fake_path/git"
ln -s "$(which bash)" "$fake_path/bash"
ln -s "$(which mkdir)" "$fake_path/mkdir"
ln -s "$(which cat)" "$fake_path/cat"
ln -s "$(which chmod)" "$fake_path/chmod"
ln -s "$(which cp)" "$fake_path/cp"
ln -s "$(which basename)" "$fake_path/basename"
ln -s "$(which dirname)" "$fake_path/dirname"
ln -s "$(which cd)" "$fake_path/cd" 2>/dev/null || true
run env PATH="$fake_path" "$SECRETS_BIN" init
[ "$status" -eq 1 ]
[[ "$output" == *"age"* ]]
}
# ─── push ──────────────────────────────────────────────────────────────
@test "push encrypts .env files" {
init_with_remote
create_project_dir testproj
run "$SECRETS_BIN" push testproj
[ "$status" -eq 0 ]
[ -f "$SECRETS_DIR/testproj/.env.age" ]
[ -f "$SECRETS_DIR/testproj/.env.staging.age" ]
}
@test "push errors with no .env files" {
init_with_remote
mkdir -p "$WORK_DIR/empty"
cd "$WORK_DIR/empty"
run "$SECRETS_BIN" push testproj
[ "$status" -eq 1 ]
[[ "$output" == *"No secret files"* ]]
}
@test "push errors with missing key" {
init_with_remote
create_project_dir testproj
rm "$SECRETS_DIR/key.txt"
run "$SECRETS_BIN" push testproj
[ "$status" -eq 1 ]
[[ "$output" == *"Key file"* ]]
}
@test "push derives project name from dirname" {
init_with_remote
create_project_dir myproject
# Don't pass explicit project name
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[ -d "$SECRETS_DIR/myproject" ]
}
@test "push succeeds on repeated push (age is non-deterministic)" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
# Push again — age produces different ciphertext each time, so this creates a new commit
run "$SECRETS_BIN" push testproj
[ "$status" -eq 0 ]
}
@test "push encrypts .dev.vars files" {
init_with_remote
create_project_dir testproj
echo "CF_SECRET=wrangler123" > "$WORK_DIR/testproj/.dev.vars"
run "$SECRETS_BIN" push testproj
[ "$status" -eq 0 ]
[ -f "$SECRETS_DIR/testproj/.env.age" ]
[ -f "$SECRETS_DIR/testproj/.dev.vars.age" ]
}
@test "pull decrypts .dev.vars files" {
init_with_remote
create_project_dir testproj
echo "CF_SECRET=wrangler123" > "$WORK_DIR/testproj/.dev.vars"
"$SECRETS_BIN" push testproj >/dev/null 2>&1
local pull_dir="$WORK_DIR/pull-devvars"
mkdir -p "$pull_dir"
cd "$pull_dir"
run "$SECRETS_BIN" pull testproj
[ "$status" -eq 0 ]
[ "$(cat "$pull_dir/.dev.vars")" = "CF_SECRET=wrangler123" ]
}
@test "pre-commit blocks plaintext .dev.vars files" {
init_with_remote
cd "$SECRETS_DIR"
echo "LEAKED=true" > .dev.vars
git add -f .dev.vars
run git commit -m "should fail"
[ "$status" -eq 1 ]
[[ "$output" == *"Plaintext"* ]]
}
# ─── pull ──────────────────────────────────────────────────────────────
@test "pull decrypts files correctly" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
# Pull into a different directory
local pull_dir="$WORK_DIR/pull-target"
mkdir -p "$pull_dir"
cd "$pull_dir"
run "$SECRETS_BIN" pull testproj
[ "$status" -eq 0 ]
[ -f "$pull_dir/.env" ]
[ -f "$pull_dir/.env.staging" ]
[ "$(cat "$pull_dir/.env")" = "SECRET_KEY=abc123" ]
[ "$(cat "$pull_dir/.env.staging")" = "DB_HOST=staging.db.example.com" ]
}
@test "pull errors for nonexistent project" {
init_with_remote
run "$SECRETS_BIN" pull nonexistent
[ "$status" -eq 1 ]
[[ "$output" == *"not found"* ]]
}
@test "pull errors with missing key" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
rm "$SECRETS_DIR/key.txt"
local pull_dir="$WORK_DIR/pull-target"
mkdir -p "$pull_dir"
cd "$pull_dir"
run "$SECRETS_BIN" pull testproj
[ "$status" -eq 1 ]
[[ "$output" == *"Key file"* ]]
}
@test "pull overwrites existing files" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
local pull_dir="$WORK_DIR/pull-target"
mkdir -p "$pull_dir"
echo "OLD_VALUE=stale" > "$pull_dir/.env"
cd "$pull_dir"
run "$SECRETS_BIN" pull testproj
[ "$status" -eq 0 ]
[ "$(cat "$pull_dir/.env")" = "SECRET_KEY=abc123" ]
}
@test "pull reinstalls missing pre-commit hook" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
# Remove the hook
rm -f "$SECRETS_DIR/.git/hooks/pre-commit"
[ ! -f "$SECRETS_DIR/.git/hooks/pre-commit" ]
local pull_dir="$WORK_DIR/pull-target"
mkdir -p "$pull_dir"
cd "$pull_dir"
run "$SECRETS_BIN" pull testproj
[ "$status" -eq 0 ]
[ -x "$SECRETS_DIR/.git/hooks/pre-commit" ]
[[ "$output" == *"Reinstalled"* ]]
}
# ─── list ──────────────────────────────────────────────────────────────
@test "list shows projects and files" {
init_with_remote
create_project_dir projA
"$SECRETS_BIN" push projA >/dev/null 2>&1
create_project_dir projB
"$SECRETS_BIN" push projB >/dev/null 2>&1
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" == *"projA"* ]]
[[ "$output" == *"projB"* ]]
}
@test "list shows empty message" {
"$SECRETS_BIN" init >/dev/null 2>&1
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" == *"No projects"* ]]
}
# ─── rm ────────────────────────────────────────────────────────────────
@test "rm removes project from repo" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
[ -d "$SECRETS_DIR/testproj" ]
run "$SECRETS_BIN" rm testproj
[ "$status" -eq 0 ]
[ ! -d "$SECRETS_DIR/testproj" ]
}
@test "rm errors for nonexistent project" {
init_with_remote
run "$SECRETS_BIN" rm nonexistent
[ "$status" -eq 1 ]
[[ "$output" == *"not found"* ]]
}
# ─── pre-commit hook ──────────────────────────────────────────────────
@test "pre-commit blocks plaintext env files" {
init_with_remote
cd "$SECRETS_DIR"
echo "LEAKED=true" > .env.test
git add -f .env.test
run git commit -m "should fail"
[ "$status" -eq 1 ]
[[ "$output" == *"Plaintext"* ]]
}
@test "pre-commit allows .age files" {
init_with_remote
cd "$SECRETS_DIR"
mkdir -p testproj
echo "encrypted-blob" > testproj/.env.test.age
git add testproj/.env.test.age
run git commit -m "should succeed"
[ "$status" -eq 0 ]
}
# ─── clear ─────────────────────────────────────────────────────────────
@test "clear removes plaintext secret files" {
init_with_remote
create_project_dir testproj
echo "CF_SECRET=wrangler123" > "$WORK_DIR/testproj/.dev.vars"
# Verify files exist
[ -f "$WORK_DIR/testproj/.env" ]
[ -f "$WORK_DIR/testproj/.env.staging" ]
[ -f "$WORK_DIR/testproj/.dev.vars" ]
run "$SECRETS_BIN" clear
[ "$status" -eq 0 ]
[[ "$output" == *"Cleared 3"* ]]
# Files should be gone
[ ! -f "$WORK_DIR/testproj/.env" ]
[ ! -f "$WORK_DIR/testproj/.env.staging" ]
[ ! -f "$WORK_DIR/testproj/.dev.vars" ]
}
@test "clear does nothing when no secret files exist" {
mkdir -p "$WORK_DIR/empty"
cd "$WORK_DIR/empty"
run "$SECRETS_BIN" clear
[ "$status" -eq 0 ]
[[ "$output" == *"No secret files"* ]]
}
@test "clear does not remove non-secret files" {
mkdir -p "$WORK_DIR/mixed"
cd "$WORK_DIR/mixed"
echo "SECRET=yes" > .env
echo "config" > .envrc
echo "other" > app.js
run "$SECRETS_BIN" clear
[ "$status" -eq 0 ]
[ ! -f "$WORK_DIR/mixed/.env" ]
[ -f "$WORK_DIR/mixed/.envrc" ]
[ -f "$WORK_DIR/mixed/app.js" ]
}
@test "clear --workspaces removes secrets from all workspaces" {
local mono
mono=$(create_monorepo)
cd "$mono"
# Verify files exist
[ -f "$mono/.env" ]
[ -f "$mono/apps/web/.env.staging" ]
[ -f "$mono/apps/api/.env" ]
run "$SECRETS_BIN" clear --workspaces
[ "$status" -eq 0 ]
[[ "$output" == *"Cleared"* ]]
# All should be gone
[ ! -f "$mono/.env" ]
[ ! -f "$mono/apps/web/.env.staging" ]
[ ! -f "$mono/apps/api/.env" ]
}
# ─── run ───────────────────────────────────────────────────────────────
@test "run pulls secrets, runs command, then clears" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
# Remove plaintext files
rm "$WORK_DIR/testproj/.env" "$WORK_DIR/testproj/.env.staging"
# Run a command that reads the secret
run "$SECRETS_BIN" run cat .env
[ "$status" -eq 0 ]
[[ "$output" == *"SECRET_KEY=abc123"* ]]
# After run completes, plaintext files should be cleared
[ ! -f "$WORK_DIR/testproj/.env" ]
[ ! -f "$WORK_DIR/testproj/.env.staging" ]
}
@test "run clears secrets even if command fails" {
init_with_remote
create_project_dir testproj
"$SECRETS_BIN" push testproj >/dev/null 2>&1
# Remove plaintext files
rm "$WORK_DIR/testproj/.env" "$WORK_DIR/testproj/.env.staging"
# Run a command that will fail (set +e so bats captures it)
run "$SECRETS_BIN" run false
[ "$status" -ne 0 ]
# Secrets should still be cleared
[ ! -f "$WORK_DIR/testproj/.env" ]
[ ! -f "$WORK_DIR/testproj/.env.staging" ]
}
@test "run errors with no command" {
run "$SECRETS_BIN" run
[ "$status" -eq 1 ]
[[ "$output" == *"Usage"* ]]
}
@test "run passes arguments through to command" {
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 with multiple args
run "$SECRETS_BIN" run ls -la .env
[ "$status" -eq 0 ]
[[ "$output" == *".env"* ]]
}
@test "run supports -- separator" {
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" run -- cat .env
[ "$status" -eq 0 ]
[[ "$output" == *"SECRET_KEY=abc123"* ]]
}
# ─── workspaces ────────────────────────────────────────────────────────
# Helper: create a monorepo with package.json workspaces
create_monorepo() {
local dir="$WORK_DIR/myapp"
mkdir -p "$dir/apps/web" "$dir/apps/api" "$dir/packages/auth"
cat > "$dir/package.json" << 'PKGJSON'
{
"name": "myapp",
"workspaces": ["apps/*", "packages/*"]
}
PKGJSON
# Root env
echo "ROOT_SECRET=top" > "$dir/.env"
# Workspace envs
echo "WEB_DB=webdb" > "$dir/apps/web/.env.staging"
echo "API_KEY=abc" > "$dir/apps/api/.env"
# packages/auth has no .env — should be skipped silently
# Init a git repo so derive_project_name can use dirname
git init "$dir" >/dev/null 2>&1
echo "$dir"
}
@test "push --workspaces encrypts root and workspace env files" {
init_with_remote
local mono
mono=$(create_monorepo)
cd "$mono"
run "$SECRETS_BIN" push --workspaces
[ "$status" -eq 0 ]
# Root env
[ -f "$SECRETS_DIR/myapp/.env.age" ]
# Workspace envs
[ -f "$SECRETS_DIR/myapp/apps/web/.env.staging.age" ]
[ -f "$SECRETS_DIR/myapp/apps/api/.env.age" ]
# packages/auth should NOT have a dir (no .env files)
[ ! -d "$SECRETS_DIR/myapp/packages/auth" ]
}
@test "pull --workspaces decrypts into correct directories" {
init_with_remote
local mono
mono=$(create_monorepo)
cd "$mono"
"$SECRETS_BIN" push --workspaces >/dev/null 2>&1
# Remove the original env files
rm "$mono/.env" "$mono/apps/web/.env.staging" "$mono/apps/api/.env"
run "$SECRETS_BIN" pull --workspaces
[ "$status" -eq 0 ]
# Verify decrypted into correct locations
[ "$(cat "$mono/.env")" = "ROOT_SECRET=top" ]
[ "$(cat "$mono/apps/web/.env.staging")" = "WEB_DB=webdb" ]
[ "$(cat "$mono/apps/api/.env")" = "API_KEY=abc" ]
}
@test "push --workspaces errors without package.json" {
init_with_remote
mkdir -p "$WORK_DIR/nopkg"
cd "$WORK_DIR/nopkg"
run "$SECRETS_BIN" push --workspaces
[ "$status" -eq 1 ]
[[ "$output" == *"No package.json"* ]]
}
@test "push --workspaces errors without workspaces field" {
init_with_remote
mkdir -p "$WORK_DIR/nows"
echo '{"name": "nows"}' > "$WORK_DIR/nows/package.json"
cd "$WORK_DIR/nows"
run "$SECRETS_BIN" push --workspaces
[ "$status" -eq 1 ]
[[ "$output" == *"No workspaces"* ]]
}
@test "push --workspaces errors when no env files anywhere" {
init_with_remote
local dir="$WORK_DIR/empty-mono"
mkdir -p "$dir/apps/web" "$dir/packages/lib"
cat > "$dir/package.json" << 'EOF'
{"workspaces": ["apps/*", "packages/*"]}
EOF
git init "$dir" >/dev/null 2>&1
cd "$dir"
run "$SECRETS_BIN" push --workspaces
[ "$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"* ]]
# Source line must include both the rule name AND the resolved file path,
# not the empty parens (".secrets-store file ()") that v0.1.0.0 shipped.
[[ "$output" == *".secrets-store file ("*"$WORK_DIR/myapp/.secrets-store)"* ]]
}
@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"* ]]
}
# ─── EGB-282: optional remote URL in .secrets-store ──────────────────
@test "EGB-282: .secrets-store without URL still works (backward compat)" {
unset SECRETS_DIR
mkdir -p "$HOME/.secrets-work"
mkdir -p "$WORK_DIR/proj"
# Single token only — same as v0.1.0.0 format
echo "work" > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"$HOME/.secrets-work"* ]]
}
@test "EGB-282: .secrets-store with URL parses both tokens" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
echo "work git@github.com:acme/work-secrets.git" > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
# Store doesn't exist yet — pull should fail with the directed error
# that includes the actual URL (not the placeholder).
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"git clone git@github.com:acme/work-secrets.git $HOME/.secrets-work"* ]]
# Placeholder must NOT appear when a real URL was supplied
[[ "$output" != *"<their-store-remote>"* ]]
}
@test "EGB-282: missing-store error still works without URL (placeholder)" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
echo "missing-only" > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
# No URL given — placeholder is the right behavior.
[[ "$output" == *"<their-store-remote>"* ]]
}
@test "EGB-282: https URL is preserved literally" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
echo "work https://github.com/acme/work-secrets.git" > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"https://github.com/acme/work-secrets.git"* ]]
}
@test "EGB-282: ~/-prefixed path with URL works" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
printf '~/.secrets-x git@github.com:acme/x.git\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"git clone git@github.com:acme/x.git $HOME/.secrets-x"* ]]
}
@test "EGB-282: comments before URL line are still skipped" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
printf '# this binding was set by alice\n# please do not delete\nwork git@github.com:acme/work-secrets.git\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"git clone git@github.com:acme/work-secrets.git"* ]]
}
# ─── EGB-282 adversarial regressions: URL injection prevention ────────
@test "EGB-282 SECURITY: URL with shell metachars is dropped (rm -rf payload)" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
# The classic copy-paste shell injection: a `;` after the "URL" splits
# the rendered git clone into two commands, the second of which is the
# attacker payload. The teammate copy-pasting the directed-error one-liner
# would execute `rm -rf ~`. The parser must reject this.
printf 'work evil.git;rm -rf ~\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
# Must use the placeholder, NOT the attacker URL
[[ "$output" == *"<their-store-remote>"* ]]
[[ "$output" != *"rm -rf"* ]]
# And must have warned the user that something was dropped
[[ "$output" == *"WARNING"* ]]
[[ "$output" == *"unsafe"* ]]
}
@test "EGB-282 SECURITY: URL with backticks is dropped" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
printf 'work evil.git`whoami`\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"<their-store-remote>"* ]]
}
@test "EGB-282 SECURITY: URL with command substitution \$() is dropped" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
printf 'work evil.git$(whoami)\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"<their-store-remote>"* ]]
}
@test "EGB-282 SECURITY: URL with ANSI escape is dropped (terminal-spoof prevention)" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
# ESC ([ \x1b) lets a malicious URL render differently from what gets pasted
printf 'work evil.git\x1b[2K\r\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"<their-store-remote>"* ]]
}
@test "EGB-282 SECURITY: multi-token URL ('work url1 url2') is dropped" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
# `git clone url1 url2 /path` would clone url1 into directory `url2` — wrong
# behavior either way. Treat any whitespace inside the URL token as unsafe.
printf 'work url1 url2\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"<their-store-remote>"* ]]
}
@test "EGB-282 SECURITY: glob char in URL is dropped (no expansion either way)" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
# In v0.1.1.0-alpha (set -- $line), this used to expand to filenames.
# The fixed parser uses `read -r`, so it doesn't glob — but glob chars
# are still rejected as suspicious.
printf 'work /tmp/*\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"<their-store-remote>"* ]]
}
@test "EGB-282: spec parsing is glob-safe (work * does NOT expand)" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
# Create files in cwd that would expand if `set -- $line` were used.
touch "$WORK_DIR/proj/file1" "$WORK_DIR/proj/file2"
printf 'work *\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
# Spec is the literal "work" (resolves to ~/.secrets-work). The "*" gets
# rejected as unsafe URL and dropped. Resolution works; no globbing.
[[ "$output" == *"$HOME/.secrets-work"* ]]
}
@test "EGB-282: URL with - + _ : / @ . is preserved (positive test)" {
unset SECRETS_DIR
mkdir -p "$WORK_DIR/proj"
# Standard git URL chars must NOT be rejected
printf 'work git+ssh://user@host:2222/path/to-repo_v2.git\n' > "$WORK_DIR/proj/.secrets-store"
cd "$WORK_DIR/proj"
run "$SECRETS_BIN" pull
[ "$status" -eq 1 ]
[[ "$output" == *"git+ssh://user@host:2222/path/to-repo_v2.git"* ]]
}