Merge pull request 'brian/egb-531-secrets-support-gradleproperties-files-not-just-dotenv' (#1) from brian/egb-531-secrets-support-gradleproperties-files-not-just-dotenv into main

Reviewed-on: https://codeberg.org/egbt/secrets/pulls/1
This commit is contained in:
EGBT Technologies 2026-05-26 23:59:38 +02:00
commit 92db25a184
7 changed files with 960 additions and 13 deletions

View file

@ -5,6 +5,31 @@ 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.2.0.0] - 2026-05-26
### Added
- **Sync designated keys from external files (Gradle properties).** A new committed `.secrets-files` manifest lets `secrets` track specific keys from files that live *outside* the project root — the motivating case being `~/.gradle/gradle.properties`, where Android builds read Clerk publishable keys (`beaconClerkPkTest`, `beaconClerkPkLive`) that Android Studio's GUI builds can only get from that persistent global file, not from terminal env vars. One entry per line: `gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive`. (EGB-531)
- **push** extracts only the named keys and encrypts them under `<project>/external/` in the store.
- **pull** *merges* those keys into the target file, preserving every unrelated key, comment, and line order. An existing managed key is updated in place; the target is backed up to `gradle.properties.secrets-bak` before each merge.
- `secrets which` reads back the parsed manifest; `secrets list` shows `[external]` entries; `secrets rekey` re-encrypts external blobs alongside dotenv ones.
- Backward compatible: no `.secrets-files` → identical behavior to before.
### Security
- **The merge is pure bash with exact-string key matching — no `sed`/regex.** This is deliberate: a `sed`-based substitution would corrupt values containing `&`, `\`, or `/` (common in API keys) and would false-match substring keys (`beaconClerkPk` vs `beaconClerkPkTest`). Values are treated as opaque literals and round-trip byte-exact.
- **External write targets are validated against an attacker-controlled path.** Because the target path comes from a *committed* manifest, the writable target is locked down: basename must be `gradle.properties`, the path must resolve inside `$HOME`, `..` traversal is rejected, and symlinked targets (file or parent dir) are refused — blocking a malicious manifest from appending decrypted keys to `~/.gitconfig`, `~/.bashrc`, etc. Manifest parsing rejects shell metacharacters and control characters in paths and keys, mirroring the `.secrets-store` posture. Writes are atomic (temp-in-same-dir + rename), mode-preserving, and default to `600` on create.
- **Storage isolation.** External blobs live in a `<project>/external/` subdir so the existing broad `*.age` globs (pull, list, rekey) structurally never decrypt them into the working directory or orphan them.
- **Note on plaintext.** Merged Gradle keys are written as permanent plaintext into the target file (`secrets clear` does not remove them) — appropriate for publishable/low-secrecy values like Clerk publishable keys, by design.
### Fixed
- **`secrets rekey` was broken and never completed.** Two latent bugs, exposed by the new rekey test: (1) `age-keygen -o key.txt` aborts because age-keygen refuses to overwrite an existing file — the new key is now generated into a temp dir and moved into place only on success, so the old key survives a failed rotation; (2) the `EXIT` trap referenced the function-local `$tmpdir` after the function returned, erroring under `set -u` and leaking the plaintext temp dir — the temp dir is now removed explicitly and the trap cleared on normal completion.
### Tests
- 80 → 113 (+33). New coverage: manifest parse/read-back, key extraction across `=`/`:`/space separators, merge (preserve unrelated/comments/order, substring-key isolation, sed-metachar value round-trip, duplicate-key collapse, continuation-line safety, idempotency), path validation (wrong basename, outside `$HOME`, symlinked target, symlinked parent dir), first-create mode `600`, manifest injection/symlink/unsafe-key rejection, rekey round-trip of external blobs, glob isolation (blob not leaked to cwd), `list` surfacing, pre-commit blocking plaintext `gradle.properties`, workspace (`push -w`/`pull -w`) external sync, multi-entry manifests, partial-key push warnings, missing-blob pull warnings, source-side comment/continuation skipping, and backward compatibility.
## [0.1.1.0] - 2026-05-09
### Added

View file

@ -26,16 +26,18 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek
- Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable)
- Storage: Private git repo at `~/.secrets/`
- Convention: Tracks `.env`, `.env.*`, and `.dev.vars` (not `.envrc`, `.environment-*`)
- External files: `.secrets-files` manifest tracks designated keys from files outside the project (e.g. `~/.gradle/gradle.properties`) — merged, not overwritten (EGB-531, see below)
- Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq`
- Safety: Pre-commit hook rejects plaintext secret files
- Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`)
- Portability: must run on system bash 3.2 (macOS) — no associative arrays or bash-4 features
## Project Structure
```
secrets # CLI script (~300 lines bash)
secrets # CLI script (~600 lines bash)
hooks/pre-commit # Pre-commit hook template
test/
secrets.bats # bats-core test suite (25 tests)
secrets.bats # bats-core test suite (113 tests)
test_helper.bash # Shared setup/teardown
README.md # User-facing documentation
CLAUDE.md # This file
@ -58,6 +60,19 @@ The active store directory is picked by `resolve_store()` using these rules, hig
`.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`. An optional remote URL after the spec on the same line is captured as `_REMOTE_URL` and passed through to `check_initialized`, which uses it to fill in a runnable `git clone <url> <path>` in the missing-store error (EGB-282). The URL is parsed via `read -r spec rest` (no `set -- $line`, no glob expansion) and then **sanitized**: any URL containing shell metacharacters (`;&|<>$\`(){}*?!"'\\`), control characters (incl. ANSI escapes), or whitespace is dropped with a stderr warning. The directed error then falls back to the `<their-store-remote>` placeholder. This matters because 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 the payload on paste. Internal flow: `_parse_secrets_store_file` returns `<spec>\t<url>`; `_find_secrets_store_file` returns `<dir>\t<source-path>\t<url>`; `resolve_store` splits the 3-tuple via `IFS=$'\t' read -r ...`.
## External files (.secrets-files) — EGB-531
`.secrets-files` is a committed, project-root manifest declaring keys to sync from files **outside** the project (motivating case: `~/.gradle/gradle.properties`, which Android Studio GUI builds read but terminal env vars can't reach). One entry per line: `<type> <path> <key>...`. Only type `gradle-properties` is supported; the type token leaves room for future types **without** a plugin-dispatch framework (build the concrete case — a deliberate scope cut).
Key design decisions (all driven by /autoplan review):
- **Wire-in is at command scope** (`cmd_push`/`cmd_pull`), via `push_external_files` / `pull_external_files`, **not** inside `push_dir_to_project` / `pull_project_to_dir` (those loop per-workspace and `pull_project_to_dir` uses stdout as a data channel).
- **Storage:** blobs live in `$SECRETS_DIR/<project>/external/<slug>.gradle-properties.age`. The `external/` subdir keeps them out of the existing non-recursive `*.age` / `.*.age` globs (pull, list, rekey), so the old dotenv path can never decrypt a blob into cwd. `cmd_rekey` and `cmd_list` recurse into `external/` explicitly (rekey MUST, or the blob is orphaned after rotation = data loss). `<slug>` = manifest path token with non-`[A-Za-z0-9._-]` chars → `_`, plus a `cksum` suffix of the original path so paths that clean to the same string (`a/b` vs `a_b`) don't collide. Machine-independent (derived from the committed manifest token, not the expanded path).
- **Merge is pure bash, no `sed`/regex** (`merge_gradle_keys`): exact-string key comparison (avoids `beaconClerkPk` vs `beaconClerkPkTest` substring bug), value treated as opaque literal (survives `& \ /` in values). Updates a managed key in place at its first occurrence, collapses duplicates, appends new keys, preserves unrelated lines/comments/order. Continuation lines (trailing odd backslashes, tracked by `_trailing_bs_odd`) are never matched as keys. Atomic write: temp in the same dir → `chmod` to match (or `600` on create) → `mv`. Backs up to `<target>.secrets-bak` before each merge.
- **Properties separator parsing** (`_props_get`): key ends at the first `=`, `:`, or whitespace (after lstrip); handles `key=value`, `key = value`, `key:value`, `key value`; last definition wins.
- **Security:** the write target comes from a committed file, so `_validate_external_target_path` locks it down — basename must be `gradle.properties`, must resolve inside `$HOME` (deepest-existing-ancestor resolved, symlink target/parent refused, `..` rejected). This blocks a malicious manifest from appending decrypted keys to `~/.gitconfig`/`~/.bashrc`. `_parse_secrets_files_manifest` rejects shell metacharacters/control chars in path and keys (path allows `[A-Za-z0-9/._~-]` only; keys allow `[A-Za-z0-9._-]` + space), mirrors the `.secrets-store` posture (no shell expansion, symlinked manifest skipped).
- **Plaintext tradeoff (accepted, documented):** merged keys are permanent plaintext in the target; `secrets clear` does not remove them. Fine for the Clerk *publishable* keys this was built for; not for high-value secrets (use `secrets run` + `.env`).
## Deploy Configuration
- Platform: NONE (distributed via `git clone` from GitHub)

View file

@ -54,6 +54,8 @@ flowchart TD
Files like `.envrc` (direnv) and `.environment-*` are intentionally **not** tracked.
Beyond project files, `secrets` can also sync designated keys from files that live *outside* the project — like `~/.gradle/gradle.properties` — merging them in without clobbering unrelated keys. See [External files (Gradle properties)](#external-files-gradle-properties).
## Prerequisites
- **macOS** (uses Homebrew for installation)
@ -346,6 +348,47 @@ Inside `~/.secrets/`, workspace secrets are organized by path:
Requires `jq` (`brew install jq`).
### External files (Gradle properties)
Some credentials don't live in your project at all. Android builds, for example, read keys from `~/.gradle/gradle.properties` — a global file, outside any project, shared by every Gradle project on your machine (the project's own `gradle.properties` is git-tracked, so it's the wrong home for secrets). `secrets` can sync specific keys from such a file without touching the unrelated keys around them.
You declare what to sync in a committed `.secrets-files` manifest at your project root, one entry per line:
```
# <type> <path> <keys...>
gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
```
- **type**`gradle-properties` (the only supported type today).
- **path** — absolute or `~/`-relative. The basename must be `gradle.properties` and must resolve inside `$HOME`.
- **keys** — the property names to sync. Only these keys are read on push and merged on pull; everything else in the file is left alone.
#### Syncing to a second machine
On the machine that already has the keys set:
```bash
cd ~/myapp
echo "gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive" > .secrets-files
git add .secrets-files && git commit -m "sync gradle Clerk keys"
secrets push
# ==> Extracted 2 key(s) from ~/.gradle/gradle.properties
```
`secrets push` extracts just those keys, encrypts them, and stores them under `<project>/external/` in your secrets repo. Run `secrets which` from the project to confirm the manifest parsed.
On the other machine (after the usual key + repo setup):
```bash
cd ~/myapp
secrets pull
# ==> Merged 2 key(s) into ~/.gradle/gradle.properties (beaconClerkPkTest, beaconClerkPkLive)
```
`secrets pull` merges those keys into the local `~/.gradle/gradle.properties`, leaving every other key untouched. If a managed key already exists, its value is updated in place; comments, ordering, and unrelated entries are preserved. The file is backed up to `gradle.properties.secrets-bak` before each merge.
> **Note:** unlike `.env` files, merged Gradle keys are written as **permanent plaintext** into the target file — `secrets clear` does **not** remove them. This is appropriate for publishable / low-secrecy values (like Clerk publishable keys, `pk_*`). For high-value secrets that should never sit on disk, use `secrets run` with a `.env` instead.
## Safety features
- **`secrets run` auto-clears** — plaintext files are deleted when the command exits, errors, or is interrupted with Ctrl-C
@ -389,7 +432,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/`
## Development
```bash
# Run the test suite (37 tests)
# Run the test suite (113 tests)
brew install bats-core
bats test/secrets.bats
```

View file

@ -1 +1 @@
0.1.1.0
0.2.0.0

View file

@ -1,9 +1,10 @@
#!/usr/bin/env bash
# Pre-commit hook for the secrets repo.
# Rejects staged files matching .env patterns without .age extension.
# Rejects staged plaintext secret files (.env, .dev.vars, gradle.properties)
# without a .age extension.
# This is a safety net, not a security boundary (--no-verify bypasses it).
BLOCKED=$(git diff --cached --name-only | grep -E '\.(env|dev\.vars)' | grep -v '\.age$' || true)
BLOCKED=$(git diff --cached --name-only | grep -E '(\.env|\.dev\.vars|gradle\.properties)' | grep -v '\.age$' || true)
if [ -n "$BLOCKED" ]; then
echo "ERROR: Plaintext secret files staged for commit:"
echo "$BLOCKED"

444
secrets
View file

@ -319,6 +319,342 @@ collect_env_files() {
[ ${#COLLECTED_FILES[@]} -gt 0 ]
}
# ─── External files (.secrets-files) — EGB-531 ─────────────────────────
#
# A committed, project-local `.secrets-files` manifest declares files that
# live OUTSIDE the project root (e.g. global Gradle properties) whose
# *designated keys* should sync. One entry per non-empty non-comment line:
#
# gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
# <type> <path> <key> [key...]
#
# push extracts only the named keys → encrypts a subset blob under
# <project>/external/. pull decrypts it and MERGES those keys into <path>,
# preserving every unrelated line. Only type `gradle-properties` is
# supported (the type token leaves room for future types without a plugin
# framework). Parsing mirrors `.secrets-store`: no shell expansion, no
# symlink following, conservative sanitization. The merge is pure bash
# (no sed/regex) with exact-string key matching, so it is safe against
# values containing `& \ /` and against substring keys
# (beaconClerkPk vs beaconClerkPkTest).
SECRETS_FILES_NAME=".secrets-files"
SUPPORTED_EXTERNAL_TYPE="gradle-properties"
# True if the string ends in an odd number of backslashes (a Java/Gradle
# properties line-continuation). Used to skip continuation lines when
# matching keys so an unrelated continued value is never clobbered.
_trailing_bs_odd() {
local s="$1" n=0
while [ "${s%\\}" != "$s" ]; do s="${s%\\}"; n=$((n + 1)); done
[ $((n % 2)) -eq 1 ]
}
# Parse .secrets-files. Emits "<type>\t<path>\t<keys>" per valid entry on
# stdout; warnings (skipped/unsafe lines) to stderr. Conservative: rejects
# shell/control chars and `..` in the path, restricts keys to a safe
# charset. No shell expansion is applied to file content.
_parse_secrets_files_manifest() {
local file="$1"
local line lineno=0 mtype mpath mkeys
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
line="${line%$'\r'}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[ -z "$line" ] && continue
case "$line" in '#'*) continue ;; esac
# `read -r` does not glob-expand and keeps the key list intact in $mkeys.
read -r mtype mpath mkeys <<< "$line"
if [ "$mtype" != "$SUPPORTED_EXTERNAL_TYPE" ]; then
echo "WARNING: $file line $lineno: unknown type '$mtype' (supported: $SUPPORTED_EXTERNAL_TYPE). Skipping." >&2
continue
fi
if [ -z "$mpath" ] || [ -z "$mkeys" ]; then
echo "WARNING: $file line $lineno: expected '<type> <path> <key> [key...]'. Skipping." >&2
continue
fi
# Path: allow alnum and / . _ ~ - only; reject everything else (blocks
# \$ \` ; & | ( ) * ? whitespace etc.) and reject `..` traversal.
case "$mpath" in
*[!A-Za-z0-9/._~-]* | *..*)
echo "WARNING: $file line $lineno: unsafe characters in path. Paths may contain letters, digits, / . _ - ~ only. Skipping." >&2
continue
;;
esac
# Keys: space-separated; each may contain alnum and . _ - (covers the
# systemProp. prefix). Reject anything else (=, *, control chars, ...).
case "$mkeys" in
*[!A-Za-z0-9._\ -]*)
echo "WARNING: $file line $lineno: unsafe characters in key list. Skipping." >&2
continue
;;
esac
printf '%s\t%s\t%s\n' "$mtype" "$mpath" "$mkeys"
done < "$file"
}
# Validate a writable external target path. Refuses anything that isn't a
# `gradle.properties` resolving inside $HOME, blocks `..`, and refuses
# symlinks (target file or its parent dir) — the path comes from a
# committed file, so it is attacker-controllable. Returns 0 if safe.
_validate_external_target_path() {
local p="$1"
local base; base=$(basename "$p")
if [ "$base" != "gradle.properties" ]; then
echo "ERROR: $SECRETS_FILES_NAME: target basename must be 'gradle.properties' (got '$base'). Refusing." >&2
return 1
fi
case "$p" in *..*) echo "ERROR: $SECRETS_FILES_NAME: target path may not contain '..'. Refusing." >&2; return 1 ;; esac
local home_real; home_real=$(cd -P "$HOME" 2>/dev/null && pwd -P) || home_real="$HOME"
local dir; dir=$(dirname "$p")
# Resolve the deepest existing ancestor and require it inside $HOME.
local probe="$dir"
while [ ! -e "$probe" ] && [ "$probe" != "/" ] && [ -n "$probe" ]; do
probe=$(dirname "$probe")
done
local probe_real
probe_real=$(cd -P "$probe" 2>/dev/null && pwd -P) || {
echo "ERROR: $SECRETS_FILES_NAME: cannot resolve target ancestor '$probe'. Refusing." >&2
return 1
}
case "$probe_real/" in
"$home_real"/*) ;;
*) echo "ERROR: $SECRETS_FILES_NAME: target '$p' must resolve inside \$HOME. Refusing." >&2; return 1 ;;
esac
if [ -L "$p" ]; then
echo "ERROR: $SECRETS_FILES_NAME: target '$p' is a symlink. Refusing to follow." >&2
return 1
fi
if [ -e "$dir" ] && [ -L "$dir" ]; then
echo "ERROR: $SECRETS_FILES_NAME: target dir '$dir' is a symlink. Refusing." >&2
return 1
fi
return 0
}
# Print the value of an exact key from a properties file (last definition
# wins, matching Gradle). Returns 1 if the key is absent. Pure bash; the
# key is compared by string equality (never as a regex), and the value is
# read verbatim. Skips comments and continuation lines.
_props_get() {
local file="$1" want="$2"
[ -f "$file" ] || return 1
local line t key after val="" found=0 cont=0
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
if [ "$cont" -eq 1 ]; then
_trailing_bs_odd "$line" && cont=1 || cont=0
continue
fi
_trailing_bs_odd "$line" && cont=1 || cont=0
t="${line#"${line%%[![:space:]]*}"}"
case "$t" in ''|'#'*|'!'*) continue ;; esac
key="${t%%[=:[:space:]]*}"
[ "$key" = "$want" ] || continue
after="${t#"$key"}"
after="${after#"${after%%[![:space:]]*}"}"
case "$after" in [=:]*) after="${after#?}" ;; esac
after="${after#"${after%%[![:space:]]*}"}"
val="$after"
found=1
done < "$file"
[ "$found" -eq 1 ] || return 1
printf '%s' "$val"
}
# Turn a manifest path token into a machine-independent blob slug.
# A bare char-replace would collide (e.g. a/b and a_b both → a_b), so append a
# checksum of the original path to keep distinct targets' blobs distinct.
_secrets_files_slug() {
local p="$1"
local clean="${p//[!A-Za-z0-9._-]/_}"
local sum
sum=$(printf '%s' "$p" | cksum | cut -d' ' -f1)
printf '%s-%s' "$clean" "$sum"
}
# Merge managed key=value lines (from $2) into target file $1, preserving
# all unrelated lines/comments/order. Updates a managed key in place (first
# occurrence), collapses duplicates, appends new keys. Atomic + mode-safe.
merge_gradle_keys() {
local target="$1" kvfile="$2"
local -a mkey=() mval=() seen=()
local line key val i
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue
key="${line%%=*}"
val="${line#*=}"
mkey+=("$key"); mval+=("$val"); seen+=(0)
done < "$kvfile"
local n=${#mkey[@]}
[ "$n" -gt 0 ] || return 0
local dir; dir=$(dirname "$target")
mkdir -p "$dir"
local tmp; tmp=$(mktemp "$dir/.gradle-merge.XXXXXX") || return 1
local cont=0 t tkey matched
if [ -f "$target" ]; then
while IFS= read -r line || [ -n "$line" ]; do
local stripped="${line%$'\r'}"
if [ "$cont" -eq 1 ]; then
printf '%s\n' "$line" >> "$tmp"
_trailing_bs_odd "$stripped" && cont=1 || cont=0
continue
fi
_trailing_bs_odd "$stripped" && cont=1 || cont=0
t="${stripped#"${stripped%%[![:space:]]*}"}"
case "$t" in ''|'#'*|'!'*) printf '%s\n' "$line" >> "$tmp"; continue ;; esac
tkey="${t%%[=:[:space:]]*}"
matched=-1
for ((i = 0; i < n; i++)); do
if [ "$tkey" = "${mkey[$i]}" ]; then matched=$i; break; fi
done
if [ "$matched" -ge 0 ]; then
if [ "${seen[$matched]}" -eq 0 ]; then
printf '%s=%s\n' "${mkey[$matched]}" "${mval[$matched]}" >> "$tmp"
seen[$matched]=1
fi
else
printf '%s\n' "$line" >> "$tmp"
fi
done < "$target"
fi
for ((i = 0; i < n; i++)); do
if [ "${seen[$i]}" -eq 0 ]; then
printf '%s=%s\n' "${mkey[$i]}" "${mval[$i]}" >> "$tmp"
fi
done
# Back up the existing target before overwriting (seatbelt for the merge
# engine touching a hand-maintained file), preserve mode, atomic rename.
if [ -f "$target" ]; then
cp "$target" "$target.secrets-bak" 2>/dev/null || true
local mode
mode=$(stat -f '%Lp' "$target" 2>/dev/null || stat -c '%a' "$target" 2>/dev/null || echo 600)
chmod "$mode" "$tmp" 2>/dev/null || true
else
chmod 600 "$tmp" 2>/dev/null || true
fi
# TOCTOU recheck before the write into $HOME.
if [ -L "$target" ]; then
rm -f "$tmp"
echo "ERROR: $target became a symlink; aborting merge." >&2
return 1
fi
mv "$tmp" "$target"
}
# Encrypt the managed keys declared in <root>/.secrets-files into
# <project>/external/. Returns 0 if at least one entry was pushed, 1 if
# there is no usable manifest. Dies on unsafe targets or all-missing keys.
push_external_files() {
local root="$1" project="$2" pubkey="$3"
local manifest="$root/$SECRETS_FILES_NAME"
[ -e "$manifest" ] || return 1
if [ -L "$manifest" ]; then
echo "WARNING: $manifest is a symlink; ignoring." >&2
return 1
fi
[ -f "$manifest" ] || return 1
local pushed=0 mtype mpath mkeys
while IFS=$'\t' read -r mtype mpath mkeys; do
[ -n "$mtype" ] || continue
local expanded; expanded=$(_expand_store_path "$mpath")
if ! _validate_external_target_path "$expanded"; then
die "Refusing unsafe external target in $SECRETS_FILES_NAME: $mpath"
fi
if [ ! -f "$expanded" ]; then
echo "WARNING: source '$mpath' not found on this machine; skipping." >&2
continue
fi
local tmp; tmp=$(mktemp)
local found=0 k v
for k in $mkeys; do
if v=$(_props_get "$expanded" "$k"); then
if _trailing_bs_odd "$v"; then
# A trailing odd backslash means a multi-line (continuation) value.
# We only support single-line values; syncing this would write a
# dangling backslash that turns the next target line into a
# continuation and corrupts the file. Skip it loudly.
echo "WARNING: key '$k' in $mpath has a multi-line (continuation) value — not supported, skipping." >&2
else
printf '%s=%s\n' "$k" "$v" >> "$tmp"
found=$((found + 1))
fi
else
echo "WARNING: key '$k' not found in $mpath — not synced. Set it locally first, or remove it from $SECRETS_FILES_NAME." >&2
fi
done
if [ "$found" -eq 0 ]; then
rm -f "$tmp"
die "No managed keys found in $mpath (looked for: $mkeys)."
fi
mkdir -p "$SECRETS_DIR/$project/external"
local slug; slug=$(_secrets_files_slug "$mpath")
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$slug.$mtype.age" "$tmp"
rm -f "$tmp"
info "Extracted $found key(s) from $mpath"
pushed=$((pushed + 1))
done < <(_parse_secrets_files_manifest "$manifest")
[ "$pushed" -gt 0 ]
}
# Decrypt the external blobs named by <root>/.secrets-files and merge their
# keys into the declared targets. Skips (with a warning) unsafe targets or
# missing blobs rather than aborting the whole pull.
pull_external_files() {
local root="$1" project="$2"
local manifest="$root/$SECRETS_FILES_NAME"
[ -e "$manifest" ] || return 0
if [ -L "$manifest" ]; then
echo "WARNING: $manifest is a symlink; ignoring." >&2
return 0
fi
[ -f "$manifest" ] || return 0
local mtype mpath mkeys
while IFS=$'\t' read -r mtype mpath mkeys; do
[ -n "$mtype" ] || continue
local expanded; expanded=$(_expand_store_path "$mpath")
if ! _validate_external_target_path "$expanded"; then
echo "WARNING: skipping unsafe external target: $mpath" >&2
continue
fi
local slug; slug=$(_secrets_files_slug "$mpath")
local blob="$SECRETS_DIR/$project/external/$slug.$mtype.age"
if [ ! -f "$blob" ]; then
echo "WARNING: $SECRETS_FILES_NAME names '$mpath' but no encrypted data exists in the store yet. Run 'secrets push' on a machine that has these keys. Skipping." >&2
continue
fi
local tmp; tmp=$(mktemp)
if ! age -d -i "$KEY_FILE" -o "$tmp" "$blob"; then
rm -f "$tmp"
die "Decryption failed for external target $mpath."
fi
local k k_esc
for k in $mkeys; do
# Escape regex-special '.' so e.g. systemProp.foo can't match systemPropXfoo.
k_esc=$(printf '%s' "$k" | sed 's/\./\\./g')
grep -q "^$k_esc=" "$tmp" 2>/dev/null || echo "WARNING: '$k' listed in $SECRETS_FILES_NAME but absent from synced data for $mpath." >&2
done
if [ ! -d "$(dirname "$expanded")" ]; then
echo "WARNING: creating $(dirname "$expanded")" >&2
fi
local count; count=$(grep -c '=' "$tmp" 2>/dev/null || true); count=${count:-0}
if merge_gradle_keys "$expanded" "$tmp"; then
local klist; klist=$(printf '%s' "$mkeys" | tr ' ' ',' | sed 's/,/, /g')
info "Merged $count key(s) into $expanded ($klist)"
else
echo "WARNING: failed to merge keys into $expanded — target left unchanged." >&2
fi
rm -f "$tmp"
done < <(_parse_secrets_files_manifest "$manifest")
}
# Read package.json workspaces and expand globs to actual directories.
# Prints one workspace path per line (relative to the monorepo root).
get_workspaces() {
@ -353,7 +689,7 @@ install_hook() {
# Inline hook if template not found (e.g. secrets installed standalone)
cat > "$hook_dst" << 'HOOKEOF'
#!/usr/bin/env bash
BLOCKED=$(git diff --cached --name-only | grep -E '\.(env|dev\.vars)' | grep -v '\.age$' || true)
BLOCKED=$(git diff --cached --name-only | grep -E '(\.env|\.dev\.vars|gradle\.properties)' | grep -v '\.age$' || true)
if [ -n "$BLOCKED" ]; then
echo "ERROR: Plaintext secret files staged for commit:"
echo "$BLOCKED"
@ -480,8 +816,11 @@ cmd_push() {
local pubkey
pubkey=$(get_pubkey)
if ! push_dir_to_project "$PWD" "$project" "$pubkey"; then
die "No secret files (.env, .env.*, .dev.vars) found in $PWD"
local did=0
if push_dir_to_project "$PWD" "$project" "$pubkey"; then did=1; fi
if push_external_files "$PWD" "$project" "$pubkey"; then did=1; fi
if [ "$did" -eq 0 ]; then
die "No secret files (.env, .env.*, .dev.vars) or $SECRETS_FILES_NAME entries found in $PWD"
fi
commit_and_push_secrets "update $project"
@ -522,6 +861,12 @@ cmd_push_workspaces() {
fi
done <<< "$workspaces"
# External files (.secrets-files) are monorepo-root-scoped, like
# .secrets-store — handle once, not per-workspace.
if push_external_files "$root" "$monorepo_name" "$pubkey"; then
total=$((total + 1))
fi
if [ "$total" -eq 0 ]; then
die "No secret files found in any workspace"
fi
@ -569,6 +914,9 @@ cmd_pull() {
info "Decrypted $count file(s) into $target_dir"
# Merge any external files (.secrets-files) declared in this project.
pull_external_files "$PWD" "$project"
# Reinstall hook if missing
if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then
install_hook
@ -644,6 +992,12 @@ cmd_pull_workspaces() {
fi
done <<< "$workspaces"
# External files (.secrets-files) are monorepo-root-scoped — once.
if [ -f "$root/$SECRETS_FILES_NAME" ] && [ ! -L "$root/$SECRETS_FILES_NAME" ]; then
pull_external_files "$root" "$monorepo_name"
total=$((total + 1))
fi
if [ "$total" -eq 0 ]; then
die "No secrets found for any workspace in $monorepo_name"
fi
@ -674,6 +1028,14 @@ cmd_list() {
echo " $(basename "$f" .age)"
found=1
done
# External files live in a subdir, invisible to the globs above.
if [ -d "${dir}external" ]; then
for f in "${dir}external"/*.age; do
[ -f "$f" ] || continue
echo " [external] $(basename "$f" .age)"
found=1
done
fi
done
if [ "$found" -eq 0 ]; then
@ -720,7 +1082,10 @@ cmd_rekey() {
# Create temp dir with cleanup trap
local tmpdir
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT INT TERM
# `${tmpdir:-}` guards against `set -u` if the trap somehow fires after the
# function returns (the local would be out of scope). Normal completion
# cleans up explicitly and clears the trap at the end of this function.
trap 'rm -rf "${tmpdir:-}"' EXIT INT TERM
info "Decrypting all files with current key..."
@ -741,6 +1106,20 @@ cmd_rekey() {
fi
file_count=$((file_count + 1))
done
# External files live in a subdir; rekey them too or they become
# undecryptable after rotation.
if [ -d "${dir}external" ]; then
mkdir -p "$tmpdir/$project/external"
for f in "${dir}external"/*.age; do
[ -f "$f" ] || continue
local ename
ename=$(basename "$f" .age)
if ! age -d -i "$KEY_FILE" -o "$tmpdir/$project/external/$ename" "$f"; then
die "Decryption failed for $project/external/$ename. Rekey aborted. Old key preserved."
fi
file_count=$((file_count + 1))
done
fi
done
if [ "$file_count" -eq 0 ]; then
@ -749,8 +1128,12 @@ cmd_rekey() {
info "Decrypted $file_count file(s). Generating new key pair..."
# Generate new key (overwrites old)
age-keygen -o "$KEY_FILE" 2>&1
# Generate the new key. age-keygen refuses to overwrite an existing
# file, so generate into the temp dir and move it into place only on
# success — the old key stays intact if generation fails.
local newkey="$tmpdir/key.txt.new"
age-keygen -o "$newkey" 2>&1
mv "$newkey" "$KEY_FILE"
local pubkey
pubkey=$(get_pubkey)
@ -768,6 +1151,15 @@ cmd_rekey() {
name=$(basename "$f")
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
done
if [ -d "${dir}external" ]; then
mkdir -p "$SECRETS_DIR/$project/external"
for f in "${dir}external"/*; do
[ -f "$f" ] || continue
local ename
ename=$(basename "$f")
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/${ename}.age" "$f"
done
fi
done
# Commit and push
@ -787,6 +1179,11 @@ cmd_rekey() {
echo ""
echo "WARNING: Old ciphertext remains in git history."
echo "For full rotation, create a fresh repo."
# Clean up the plaintext temp dir while $tmpdir is still in scope, then
# drop the trap so it can't fire (and error under set -u) post-return.
rm -rf "$tmpdir"
trap - EXIT INT TERM
}
cmd_clear() {
@ -893,6 +1290,22 @@ cmd_which() {
resolve_store
echo "store: $SECRETS_DIR"
echo "source: $STORE_SOURCE"
# Read back any external-file manifest in cwd (validates the format and
# gives the user a way to confirm it parsed, since there's no add-file
# command). Skips symlinked manifests.
local manifest="$PWD/$SECRETS_FILES_NAME"
if [ -f "$manifest" ] && [ ! -L "$manifest" ]; then
local mtype mpath mkeys printed=0
while IFS=$'\t' read -r mtype mpath mkeys; do
[ -n "$mtype" ] || continue
if [ "$printed" -eq 0 ]; then
echo "external files ($SECRETS_FILES_NAME at $manifest):"
printed=1
fi
echo " $mtype $mpath $mkeys"
done < <(_parse_secrets_files_manifest "$manifest")
fi
}
cmd_help() {
@ -946,6 +1359,25 @@ Workspaces:
<monorepo>/<workspace-path>/ in the secrets repo. Root secret files
are stored under <monorepo>/ directly. Requires jq.
External files (.secrets-files):
Sync specific keys from files OUTSIDE the project root (e.g. global
Gradle properties). Create a committed .secrets-files in the project
root, one entry per line:
# <type> <path> <keys...>
gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
On push, the named keys are extracted from <path> and encrypted under
<project>/external/ in the store. On pull, they are MERGED back into
<path>, leaving all unrelated keys untouched. Only type
'gradle-properties' is supported; the target basename must be
'gradle.properties' and must resolve inside $HOME. Run 'secrets which'
from the project to confirm the manifest parsed.
Note: merged keys are written as permanent plaintext into the target
file (suitable for publishable/low-secrecy values). 'secrets clear'
does NOT remove them.
Environment:
SECRETS_DIR Path to secrets repo (default: ~/.secrets). See also
the .secrets-store file and --store flag above.

View file

@ -1045,3 +1045,434 @@ PKG
[ "$status" -eq 1 ]
[[ "$output" == *"git+ssh://user@host:2222/path/to-repo_v2.git"* ]]
}
# ─── EGB-531: gradle.properties external file support ──────────────────
# Helper: write a fake global gradle.properties under the sandboxed HOME.
gradle_src() {
mkdir -p "$HOME/.gradle"
printf '%s' "$1" > "$HOME/.gradle/gradle.properties"
}
# Helper: bind a project dir to a gradle entry via .secrets-files, cd into it.
gradle_project() {
local name="${1:-gproj}"
local keys="${2:-beaconClerkPkTest beaconClerkPkLive}"
local dir="$WORK_DIR/$name"
mkdir -p "$dir"
printf 'gradle-properties ~/.gradle/gradle.properties %s\n' "$keys" > "$dir/.secrets-files"
cd "$dir"
}
@test "EGB-531: which shows parsed .secrets-files entries" {
init_with_remote
gradle_project gproj
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"gradle-properties"* ]]
[[ "$output" == *"~/.gradle/gradle.properties"* ]]
[[ "$output" == *"beaconClerkPkTest"* ]]
}
@test "EGB-531: push extracts managed keys into external/ blob (no .env needed)" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_test_abc\nbeaconClerkPkLive=pk_live_xyz\nunrelated=keep\n'
gradle_project gproj
run "$SECRETS_BIN" push gproj
[ "$status" -eq 0 ]
[[ "$output" == *"Extracted 2 key"* ]]
run bash -c "ls $SECRETS_DIR/gproj/external/*.gradle-properties.age"
[ "$status" -eq 0 ]
}
@test "EGB-531: push dies if all managed keys missing from source" {
init_with_remote
gradle_src $'somethingelse=1\n'
gradle_project gproj
run "$SECRETS_BIN" push gproj
[ "$status" -eq 1 ]
[[ "$output" == *"not found"* ]]
}
@test "EGB-531: pull merges managed keys, preserves unrelated entries" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_test_abc\nbeaconClerkPkLive=pk_live_xyz\n'
gradle_project gproj
"$SECRETS_BIN" push gproj >/dev/null 2>&1
# Simulate a second machine: target holds only unrelated keys
gradle_src $'unrelated.key=keepme\norg.gradle.jvmargs=-Xmx2g\n'
run "$SECRETS_BIN" pull gproj
[ "$status" -eq 0 ]
[[ "$output" == *"Merged 2 key"* ]]
grep -q 'beaconClerkPkTest=pk_test_abc' "$HOME/.gradle/gradle.properties"
grep -q 'beaconClerkPkLive=pk_live_xyz' "$HOME/.gradle/gradle.properties"
grep -q 'unrelated.key=keepme' "$HOME/.gradle/gradle.properties"
grep -q 'org.gradle.jvmargs=-Xmx2g' "$HOME/.gradle/gradle.properties"
}
@test "EGB-531: merge does NOT touch a substring key" {
init_with_remote
gradle_src $'beaconClerkPkTest=secretval\n'
gradle_project gproj beaconClerkPkTest
"$SECRETS_BIN" push gproj >/dev/null 2>&1
# Target has the shorter key AND a stale managed key
gradle_src $'beaconClerkPk=DONOTCHANGE\nbeaconClerkPkTest=old\n'
run "$SECRETS_BIN" pull gproj
[ "$status" -eq 0 ]
grep -q '^beaconClerkPk=DONOTCHANGE$' "$HOME/.gradle/gradle.properties"
grep -q '^beaconClerkPkTest=secretval$' "$HOME/.gradle/gradle.properties"
}
@test "EGB-531: value with sed/regex metacharacters round-trips byte-exact" {
init_with_remote
local val='a/b&c\d.e|f$g'
gradle_src "$(printf 'beaconClerkPkTest=%s\n' "$val")"
gradle_project gproj beaconClerkPkTest
"$SECRETS_BIN" push gproj >/dev/null 2>&1
gradle_src $'other=1\n'
run "$SECRETS_BIN" pull gproj
[ "$status" -eq 0 ]
run grep -F "beaconClerkPkTest=$val" "$HOME/.gradle/gradle.properties"
[ "$status" -eq 0 ]
}
@test "EGB-531: colon and space separators are parsed" {
init_with_remote
gradle_src $'beaconClerkPkTest : pk_colon\nbeaconClerkPkLive pk_space\n'
gradle_project gproj
"$SECRETS_BIN" push gproj >/dev/null 2>&1
gradle_src $'x=1\n'
run "$SECRETS_BIN" pull gproj
[ "$status" -eq 0 ]
grep -q '^beaconClerkPkTest=pk_colon$' "$HOME/.gradle/gradle.properties"
grep -q '^beaconClerkPkLive=pk_space$' "$HOME/.gradle/gradle.properties"
}
@test "EGB-531: pull is idempotent (second pull leaves file byte-identical)" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_test_abc\nbeaconClerkPkLive=pk_live_xyz\n'
gradle_project gproj
"$SECRETS_BIN" push gproj >/dev/null 2>&1
gradle_src $'unrelated=x\n# a comment\n'
"$SECRETS_BIN" pull gproj >/dev/null 2>&1
cp "$HOME/.gradle/gradle.properties" "$TEST_TMPDIR/snap1"
"$SECRETS_BIN" pull gproj >/dev/null 2>&1
run diff "$TEST_TMPDIR/snap1" "$HOME/.gradle/gradle.properties"
[ "$status" -eq 0 ]
}
@test "EGB-531: merge preserves comments and blank lines" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
gradle_project gproj beaconClerkPkTest
"$SECRETS_BIN" push gproj >/dev/null 2>&1
gradle_src $'# header comment\n\nunrelated=x\n'
run "$SECRETS_BIN" pull gproj
[ "$status" -eq 0 ]
grep -q '^# header comment$' "$HOME/.gradle/gradle.properties"
grep -q '^unrelated=x$' "$HOME/.gradle/gradle.properties"
grep -q '^beaconClerkPkTest=pk_test_abc$' "$HOME/.gradle/gradle.properties"
}
@test "EGB-531: duplicate managed key in target collapses to one canonical line" {
init_with_remote
gradle_src $'beaconClerkPkTest=newval\n'
gradle_project gproj beaconClerkPkTest
"$SECRETS_BIN" push gproj >/dev/null 2>&1
gradle_src $'beaconClerkPkTest=old1\nx=1\nbeaconClerkPkTest=old2\n'
run "$SECRETS_BIN" pull gproj
[ "$status" -eq 0 ]
run grep -c '^beaconClerkPkTest=' "$HOME/.gradle/gradle.properties"
[ "$output" -eq 1 ]
grep -q '^beaconClerkPkTest=newval$' "$HOME/.gradle/gradle.properties"
grep -q '^x=1$' "$HOME/.gradle/gradle.properties"
}
@test "EGB-531: continuation-line-adjacent managed key is not clobbered" {
init_with_remote
gradle_src $'beaconClerkPkTest=realval\n'
gradle_project gproj beaconClerkPkTest
"$SECRETS_BIN" push gproj >/dev/null 2>&1
# 'beaconClerkPkTest=...' here is a CONTINUATION of unrelated's value, not a definition
printf 'unrelated=foo\\\nbeaconClerkPkTest=continuation\n' > "$HOME/.gradle/gradle.properties"
run "$SECRETS_BIN" pull gproj
[ "$status" -eq 0 ]
# continuation line preserved verbatim
grep -q '^beaconClerkPkTest=continuation$' "$HOME/.gradle/gradle.properties"
# and the real managed key appended
grep -q '^beaconClerkPkTest=realval$' "$HOME/.gradle/gradle.properties"
}
@test "EGB-531: first-create target gets mode 600" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
gradle_project gproj beaconClerkPkTest
"$SECRETS_BIN" push gproj >/dev/null 2>&1
rm -f "$HOME/.gradle/gradle.properties"
run "$SECRETS_BIN" pull gproj
[ "$status" -eq 0 ]
[ -f "$HOME/.gradle/gradle.properties" ]
local mode
mode=$(stat -f '%Lp' "$HOME/.gradle/gradle.properties" 2>/dev/null || stat -c '%a' "$HOME/.gradle/gradle.properties")
[ "$mode" = "600" ]
}
@test "EGB-531: target with wrong basename is refused" {
init_with_remote
mkdir -p "$HOME/.gradle"
printf 'beaconClerkPkTest=x\n' > "$HOME/.gradle/custom.properties"
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
printf 'gradle-properties ~/.gradle/custom.properties beaconClerkPkTest\n' > .secrets-files
run "$SECRETS_BIN" push gproj
[ "$status" -eq 1 ]
[[ "$output" == *"gradle.properties"* ]]
}
@test "EGB-531: target outside HOME is refused" {
init_with_remote
local outside
outside=$(mktemp -d)
mkdir -p "$outside/.gradle"
printf 'beaconClerkPkTest=x\n' > "$outside/.gradle/gradle.properties"
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
printf 'gradle-properties %s/.gradle/gradle.properties beaconClerkPkTest\n' "$outside" > .secrets-files
run "$SECRETS_BIN" push gproj
rm -rf "$outside"
[ "$status" -eq 1 ]
[[ "$output" == *"HOME"* ]]
}
@test "EGB-531: symlinked target is refused" {
init_with_remote
mkdir -p "$HOME/.gradle"
printf 'beaconClerkPkTest=x\n' > "$HOME/realgradle"
ln -s "$HOME/realgradle" "$HOME/.gradle/gradle.properties"
gradle_project gproj beaconClerkPkTest
run "$SECRETS_BIN" push gproj
[ "$status" -eq 1 ]
[[ "$output" == *"symlink"* ]]
}
@test "EGB-531: unknown type in manifest warns and skips" {
init_with_remote
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
printf 'gradle-props ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
echo "X=1" > .env
run "$SECRETS_BIN" push gproj
[[ "$output" == *"unknown type"* ]]
[ ! -d "$SECRETS_DIR/gproj/external" ]
}
@test "EGB-531: malformed manifest line (no path/keys) is skipped with warning" {
init_with_remote
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
printf 'gradle-properties\n' > .secrets-files
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"WARNING"* ]]
}
@test "EGB-531: manifest path with command-substitution chars is rejected" {
init_with_remote
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
local pwn="$TEST_TMPDIR/pwn-$$"
rm -f "$pwn"
printf 'gradle-properties ~/.gradle/gradle.properties$(touch %s) beaconClerkPkTest\n' "$pwn" > .secrets-files
run "$SECRETS_BIN" which
[ ! -f "$pwn" ]
[[ "$output" == *"WARNING"* ]]
}
@test "EGB-531: symlinked .secrets-files is ignored" {
init_with_remote
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$HOME/realmanifest"
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
ln -s "$HOME/realmanifest" .secrets-files
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" != *"beaconClerkPkTest"* ]]
}
@test "EGB-531: rekey re-encrypts the external blob (still decryptable after)" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
gradle_project gproj beaconClerkPkTest
"$SECRETS_BIN" push gproj >/dev/null 2>&1
"$SECRETS_BIN" rekey >/dev/null 2>&1
gradle_src $'other=1\n'
run "$SECRETS_BIN" pull gproj
[ "$status" -eq 0 ]
grep -q '^beaconClerkPkTest=pk_test_abc$' "$HOME/.gradle/gradle.properties"
}
@test "EGB-531: gradle blob is NOT decrypted into cwd by dotenv pull" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
echo "DOTENV=1" > .env
"$SECRETS_BIN" push gproj >/dev/null 2>&1
local pulldir="$WORK_DIR/pull-gproj"
mkdir -p "$pulldir"
cp "$WORK_DIR/gproj/.secrets-files" "$pulldir/.secrets-files"
cd "$pulldir"
run "$SECRETS_BIN" pull gproj
[ "$status" -eq 0 ]
[ -f "$pulldir/.env" ]
[ ! -f "$pulldir/gradle.properties" ]
}
@test "EGB-531: list shows external entry" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_test_abc\n'
gradle_project gproj beaconClerkPkTest
"$SECRETS_BIN" push gproj >/dev/null 2>&1
run "$SECRETS_BIN" list
[ "$status" -eq 0 ]
[[ "$output" == *"external"* ]]
}
@test "EGB-531: no .secrets-files behaves exactly as before (backward compat)" {
init_with_remote
create_project_dir plainproj
run "$SECRETS_BIN" push plainproj
[ "$status" -eq 0 ]
[ ! -d "$SECRETS_DIR/plainproj/external" ]
}
@test "EGB-531: pre-commit blocks plaintext gradle.properties in store" {
init_with_remote
cd "$SECRETS_DIR"
echo "beaconClerkPkTest=leak" > gradle.properties
git add -f gradle.properties
run git commit -m "should fail"
[ "$status" -eq 1 ]
[[ "$output" == *"Plaintext"* ]]
}
# ── EGB-531: coverage for warning/error branches, workspaces, multi-entry ──
@test "EGB-531: push -w pushes external blob once at monorepo root" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_w\n'
local mono="$WORK_DIR/mono"
mkdir -p "$mono/apps/web"
printf '{"workspaces":["apps/*"]}\n' > "$mono/package.json"
echo "ROOT=1" > "$mono/.env"
echo "WEB=1" > "$mono/apps/web/.env"
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$mono/.secrets-files"
git init "$mono" >/dev/null 2>&1
cd "$mono"
run "$SECRETS_BIN" push -w
[ "$status" -eq 0 ]
# External blob pushed exactly once (not once per workspace)
run bash -c "ls $SECRETS_DIR/mono/external/*.gradle-properties.age 2>/dev/null | wc -l | tr -d ' '"
[ "$output" = "1" ]
}
@test "EGB-531: pull -w merges external keys at monorepo root" {
init_with_remote
gradle_src $'beaconClerkPkTest=pk_w\n'
local mono="$WORK_DIR/mono"
mkdir -p "$mono/apps/web"
printf '{"workspaces":["apps/*"]}\n' > "$mono/package.json"
echo "ROOT=1" > "$mono/.env"
echo "WEB=1" > "$mono/apps/web/.env"
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$mono/.secrets-files"
git init "$mono" >/dev/null 2>&1
cd "$mono"
"$SECRETS_BIN" push -w >/dev/null 2>&1
gradle_src $'unrelated=keep\n'
run "$SECRETS_BIN" pull -w
[ "$status" -eq 0 ]
grep -q '^beaconClerkPkTest=pk_w$' "$HOME/.gradle/gradle.properties"
grep -q '^unrelated=keep$' "$HOME/.gradle/gradle.properties"
}
@test "EGB-531: push warns for missing key but still syncs present ones" {
init_with_remote
gradle_src $'beaconClerkPkTest=present\n'
gradle_project gproj
run "$SECRETS_BIN" push gproj
[ "$status" -eq 0 ]
[[ "$output" == *"beaconClerkPkLive"* ]]
[[ "$output" == *"not found"* ]]
[[ "$output" == *"Extracted 1 key"* ]]
}
@test "EGB-531: pull warns when manifest entry has no blob in store" {
init_with_remote
create_project_dir gproj
"$SECRETS_BIN" push gproj >/dev/null 2>&1
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$WORK_DIR/gproj/.secrets-files"
cd "$WORK_DIR/gproj"
run "$SECRETS_BIN" pull gproj
[ "$status" -eq 0 ]
[[ "$output" == *"no encrypted data exists"* ]]
}
@test "EGB-531: multi-entry manifest syncs each target" {
init_with_remote
mkdir -p "$HOME/.gradle" "$HOME/.gradle-b"
printf 'beaconClerkPkTest=a\n' > "$HOME/.gradle/gradle.properties"
printf 'beaconClerkPkLive=b\n' > "$HOME/.gradle-b/gradle.properties"
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\ngradle-properties ~/.gradle-b/gradle.properties beaconClerkPkLive\n' > .secrets-files
run "$SECRETS_BIN" push gproj
[ "$status" -eq 0 ]
run bash -c "ls $SECRETS_DIR/gproj/external/*.age 2>/dev/null | wc -l | tr -d ' '"
[ "$output" = "2" ]
printf 'x=1\n' > "$HOME/.gradle/gradle.properties"
printf 'y=1\n' > "$HOME/.gradle-b/gradle.properties"
run "$SECRETS_BIN" pull gproj
[ "$status" -eq 0 ]
grep -q '^beaconClerkPkTest=a$' "$HOME/.gradle/gradle.properties"
grep -q '^beaconClerkPkLive=b$' "$HOME/.gradle-b/gradle.properties"
}
@test "EGB-531: manifest with unsafe key chars is skipped with warning" {
init_with_remote
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
printf 'gradle-properties ~/.gradle/gradle.properties bad=key\n' > .secrets-files
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"WARNING"* ]]
[[ "$output" != *"bad=key"* ]]
}
@test "EGB-531: symlinked parent dir of target is refused" {
init_with_remote
mkdir -p "$HOME/realdir"
printf 'beaconClerkPkTest=x\n' > "$HOME/realdir/gradle.properties"
ln -s "$HOME/realdir" "$HOME/.gradle"
gradle_project gproj beaconClerkPkTest
run "$SECRETS_BIN" push gproj
[ "$status" -eq 1 ]
[[ "$output" == *"symlink"* ]]
}
@test "EGB-531: push skips a multi-line (continuation) managed value with a warning" {
init_with_remote
mkdir -p "$HOME/.gradle"
# beaconClerkPkTest has a continuation value (trailing backslash); Live is single-line
printf '%s' $'beaconClerkPkTest=part1\\\npart2\nbeaconClerkPkLive=fine\n' > "$HOME/.gradle/gradle.properties"
gradle_project gproj
run "$SECRETS_BIN" push gproj
[ "$status" -eq 0 ]
[[ "$output" == *"multi-line"* ]]
[[ "$output" == *"Extracted 1 key"* ]]
}
@test "EGB-531: push skips comment and continuation lines in source" {
init_with_remote
mkdir -p "$HOME/.gradle"
printf '%s' $'! bang comment\nunrelated=foo\\\nbeaconClerkPkTest=is_a_continuation\nbeaconClerkPkTest=realkey\n' > "$HOME/.gradle/gradle.properties"
gradle_project gproj beaconClerkPkTest
"$SECRETS_BIN" push gproj >/dev/null 2>&1
gradle_src $'z=1\n'
run "$SECRETS_BIN" pull gproj
[ "$status" -eq 0 ]
# The continuation line that looks like the key must NOT win; the real
# definition must, and the '!' comment must be ignored.
grep -q '^beaconClerkPkTest=realkey$' "$HOME/.gradle/gradle.properties"
}