v0.2.0.0 feat: sync gradle.properties keys via .secrets-files (EGB-531)

Add a committed .secrets-files manifest that lets secrets track designated
keys from files outside the project root (motivating case:
~/.gradle/gradle.properties for Android Clerk publishable keys, which
Android Studio GUI builds read but terminal env vars can't reach).

- push extracts only the named keys, encrypts under <project>/external/
- pull MERGES them into the target, preserving unrelated keys/comments/order
- pure-bash merge (no sed/regex): exact-string key match, opaque values
- path validator: basename gradle.properties, within $HOME, no symlink/..
- external/ subdir keeps blobs out of the dotenv *.age globs; rekey + list
  recurse explicitly
- which reads back the manifest; list shows [external]; pre-commit blocks
  plaintext gradle.properties

Also fixes two latent bugs in 'secrets rekey' (never completed before, no
prior test): age-keygen refusing to overwrite key.txt, and an EXIT trap
referencing an out-of-scope local under set -u.

Tests: 80 -> 104.

Reviewed via /autoplan (CEO/Eng/DX). EGB-531.
This commit is contained in:
Brian Majewski 2026-05-26 12:42:04 -07:00
parent ac2195d830
commit 110ac514cc
7 changed files with 816 additions and 13 deletions

View file

@ -1045,3 +1045,307 @@ 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"* ]]
}