feat: external entries via .secrets.json + legacy absorb + properties rail (EGB-677 stage 1)

- .secrets.json external[] drives push/pull: type 'properties' (alias of
  gradle-properties; blob suffix stays legacy-compatible in stage 1) and
  type 'file'; same charset rails as the legacy parser
- push absorbs uncovered .secrets-files entries into the manifest
  (idempotent, gradle-properties → properties) with a delete hint
- pull: manifest wins entirely; a coexisting .secrets-files warns as
  superseded instead of being silently ignored
- basename rail generalized: properties targets must end '.properties'
  (was exact 'gradle.properties') — rc files/gitconfig still blocked;
  EGB-531 wrong-basename test updated for the sanctioned change
This commit is contained in:
Brian Majewski 2026-06-07 08:44:02 -07:00
parent 884da0965c
commit d7e1400487
3 changed files with 322 additions and 32 deletions

206
secrets
View file

@ -425,9 +425,17 @@ _parse_secrets_files_manifest() {
_validate_external_target_path() { _validate_external_target_path() {
local p="$1" mtype="${2:-gradle-properties}" local p="$1" mtype="${2:-gradle-properties}"
local base; base=$(basename "$p") local base; base=$(basename "$p")
if [ "$mtype" = "gradle-properties" ] && [ "$base" != "gradle.properties" ]; then if [ "$mtype" = "gradle-properties" ]; then
echo "ERROR: $SECRETS_FILES_NAME: target basename must be 'gradle.properties' (got '$base'). Refusing." >&2 # EGB-677: generalized from exact 'gradle.properties' to any
return 1 # '*.properties' basename — still blocks merging key=value lines
# into ~/.bashrc / ~/.gitconfig style targets.
case "$base" in
*.properties) ;;
*)
echo "ERROR: properties target basename must end in '.properties' (got '$base'). Refusing." >&2
return 1
;;
esac
fi fi
case "$p" in *..*) echo "ERROR: $SECRETS_FILES_NAME: target path may not contain '..'. Refusing." >&2; return 1 ;; esac 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 home_real; home_real=$(cd -P "$HOME" 2>/dev/null && pwd -P) || home_real="$HOME"
@ -575,13 +583,11 @@ merge_gradle_keys() {
# there is no usable manifest. Dies on unsafe targets or all-missing keys. # there is no usable manifest. Dies on unsafe targets or all-missing keys.
push_external_files() { push_external_files() {
local root="$1" project="$2" pubkey="$3" local root="$1" project="$2" pubkey="$3"
local manifest="$root/$SECRETS_FILES_NAME" # Entries come from .secrets.json (EGB-677) plus any legacy
[ -e "$manifest" ] || return 1 # .secrets-files entries the manifest doesn't cover yet.
if [ -L "$manifest" ]; then local entries
echo "WARNING: $manifest is a symlink; ignoring." >&2 entries=$(_external_entries_for_push "$root")
return 1 [ -n "$entries" ] || return 1
fi
[ -f "$manifest" ] || return 1
local pushed=0 mtype mpath mkeys local pushed=0 mtype mpath mkeys
while IFS=$'\t' read -r mtype mpath mkeys; do while IFS=$'\t' read -r mtype mpath mkeys; do
@ -631,7 +637,7 @@ push_external_files() {
rm -f "$tmp" rm -f "$tmp"
info "Extracted $found key(s) from $mpath" info "Extracted $found key(s) from $mpath"
pushed=$((pushed + 1)) pushed=$((pushed + 1))
done < <(_parse_secrets_files_manifest "$manifest") done <<< "$entries"
[ "$pushed" -gt 0 ] [ "$pushed" -gt 0 ]
} }
@ -641,13 +647,11 @@ push_external_files() {
# missing blobs rather than aborting the whole pull. # missing blobs rather than aborting the whole pull.
pull_external_files() { pull_external_files() {
local root="$1" project="$2" local root="$1" project="$2"
local manifest="$root/$SECRETS_FILES_NAME" # .secrets.json wins entirely when present (EGB-677); legacy
[ -e "$manifest" ] || return 0 # .secrets-files only drives manifest-less projects.
if [ -L "$manifest" ]; then local entries
echo "WARNING: $manifest is a symlink; ignoring." >&2 entries=$(_external_entries_for_pull "$root")
return 0 [ -n "$entries" ] || return 0
fi
[ -f "$manifest" ] || return 0
local mtype mpath mkeys local mtype mpath mkeys
while IFS=$'\t' read -r mtype mpath mkeys; do while IFS=$'\t' read -r mtype mpath mkeys; do
@ -709,7 +713,7 @@ pull_external_files() {
echo "WARNING: failed to merge keys into $expanded — target left unchanged." >&2 echo "WARNING: failed to merge keys into $expanded — target left unchanged." >&2
fi fi
rm -f "$tmp" rm -f "$tmp"
done < <(_parse_secrets_files_manifest "$manifest") done <<< "$entries"
} }
# ─── Manifest (.secrets.json) — EGB-677 store format v2, stage 1 ────── # ─── Manifest (.secrets.json) — EGB-677 store format v2, stage 1 ──────
@ -833,6 +837,133 @@ cmd_add() {
info "Commit the manifest so other machines pick it up. To undo: edit $SECRETS_JSON_NAME and remove the entry." info "Commit the manifest so other machines pick it up. To undo: edit $SECRETS_JSON_NAME and remove the entry."
} }
# Emit "<type>\t<path>\t<keys>" tuples from a .secrets.json external[]
# array — the same wire format _parse_secrets_files_manifest produces, so
# push_external_files / pull_external_files consume either source
# unchanged. JSON type 'properties' maps to the legacy tuple token
# 'gradle-properties' so blob suffixes (and existing store blobs) stay
# stable in stage 1. Applies the same conservative charset checks as the
# legacy parser — jq guarantees well-formed JSON, not safe VALUES.
_json_external_entries() {
local manifest="$1"
check_cmd jq
local etype epath ekeys
while IFS=$'\t' read -r etype epath ekeys; do
[ -n "$etype" ] || continue
case "$etype" in
properties|gradle-properties)
etype="gradle-properties"
if [ -z "$ekeys" ]; then
echo "WARNING: $SECRETS_JSON_NAME: properties entry '$epath' has no keys. Skipping." >&2
continue
fi
;;
file)
if [ -n "$ekeys" ]; then
echo "WARNING: $SECRETS_JSON_NAME: 'file' entries take no keys ('$epath' lists '$ekeys'). Skipping." >&2
continue
fi
;;
*)
echo "WARNING: $SECRETS_JSON_NAME: unknown external type '$etype' (supported: properties file). Skipping." >&2
continue
;;
esac
case "$epath" in
''|*[!A-Za-z0-9/._~-]*|*..*)
echo "WARNING: $SECRETS_JSON_NAME: unsafe characters in external path '$epath'. Skipping." >&2
continue
;;
esac
case "$ekeys" in
*[!A-Za-z0-9._\ -]*)
echo "WARNING: $SECRETS_JSON_NAME: unsafe characters in key list for '$epath'. Skipping." >&2
continue
;;
esac
printf '%s\t%s\t%s\n' "$etype" "$epath" "$ekeys"
done < <(jq -r '.external // [] | .[] | [.type, .path, ((.keys // []) | join(" "))] | @tsv' "$manifest")
}
# External tuples for PUSH: .secrets.json entries first, then legacy
# .secrets-files entries whose (type, path) the manifest doesn't cover —
# the absorb set, which cmd_push folds into the manifest after a
# successful push so the two sources converge.
_external_entries_for_push() {
local root="$1"
local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME"
local seen="" t p k
if [ -f "$json" ] && [ ! -L "$json" ]; then
while IFS=$'\t' read -r t p k; do
[ -n "$t" ] || continue
printf '%s\t%s\t%s\n' "$t" "$p" "$k"
seen="$seen$t|$p"$'\n'
done < <(_json_external_entries "$json")
fi
if [ -e "$legacy" ]; then
if [ -L "$legacy" ]; then
echo "WARNING: $legacy is a symlink; ignoring." >&2
elif [ -f "$legacy" ]; then
while IFS=$'\t' read -r t p k; do
[ -n "$t" ] || continue
case "$seen" in *"$t|$p"$'\n'*) continue ;; esac
printf '%s\t%s\t%s\n' "$t" "$p" "$k"
done < <(_parse_secrets_files_manifest "$legacy")
fi
fi
}
# External tuples for PULL: the manifest wins entirely when present;
# legacy .secrets-files is only consulted in manifest-less projects.
_external_entries_for_pull() {
local root="$1"
local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME"
if [ -f "$json" ] && [ ! -L "$json" ]; then
if [ -f "$legacy" ] && [ ! -L "$legacy" ]; then
echo "WARNING: $legacy is superseded by $SECRETS_JSON_NAME and was ignored on pull. Run 'secrets push' to absorb it, then delete it." >&2
fi
_json_external_entries "$json"
return 0
fi
[ -e "$legacy" ] || return 0
if [ -L "$legacy" ]; then
echo "WARNING: $legacy is a symlink; ignoring." >&2
return 0
fi
[ -f "$legacy" ] && _parse_secrets_files_manifest "$legacy"
return 0
}
# JSON array of legacy .secrets-files entries NOT yet in the manifest —
# what cmd_push absorbs. gradle-properties becomes 'properties' on the
# JSON side. Parser warnings suppressed (push_external_files re-parses
# and warns once).
_legacy_absorb_json() {
local root="$1"
local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME"
local out="[]"
if [ ! -f "$legacy" ] || [ -L "$legacy" ]; then
printf '%s' "$out"
return 0
fi
local seen=""
if [ -f "$json" ] && [ ! -L "$json" ]; then
seen=$(jq -r '.external // [] | .[] | ((if .type == "properties" then "gradle-properties" else .type end) + "|" + .path)' "$json")
fi
local t p k s found jtype
while IFS=$'\t' read -r t p k; do
[ -n "$t" ] || continue
found=0
while IFS= read -r s; do [ "$s" = "$t|$p" ] && { found=1; break; }; done <<< "$seen"
[ "$found" -eq 1 ] && continue
jtype="$t"; [ "$t" = "gradle-properties" ] && jtype="properties"
out=$(printf '%s' "$out" | jq --arg type "$jtype" --arg path "$p" --arg keys "$k" \
'. + [if $type == "file" then {type: $type, path: $path}
else {type: $type, path: $path, keys: ($keys | split(" ") | map(select(length > 0)))} end]')
done < <(_parse_secrets_files_manifest "$legacy" 2>/dev/null)
printf '%s' "$out"
}
# Quietly emit "ws-dir/basename" for every env file in a package.json # Quietly emit "ws-dir/basename" for every env file in a package.json
# workspace under <root>. Emits nothing (and never dies) when <root> is # workspace under <root>. Emits nothing (and never dies) when <root> is
# not a workspace monorepo or jq is unavailable — plain `push` calls this # not a workspace monorepo or jq is unavailable — plain `push` calls this
@ -1178,20 +1309,39 @@ cmd_push() {
fi fi
# ── Manifest write AFTER successful encryption (bootstrap ordering) ── # ── Manifest write AFTER successful encryption (bootstrap ordering) ──
if [ "$count" -gt 0 ] && [ -n "$to_add" ] && [ "$frozen" = false ] \ # Two independent reasons to write: dotenv auto-adds, and absorbing a
&& { [ "$auto_add" = true ] || [ "$have_manifest" = false ]; }; then # legacy .secrets-files (gradle-properties → properties) so the two
local add_json # external sources converge on the manifest.
add_json=$(printf '%s' "$to_add" | jq -R -s 'split("\n") | map(select(length > 0))') local absorbed_json="[]" n_absorbed=0
if [ "$frozen" = false ]; then
absorbed_json=$(_legacy_absorb_json "$PWD")
n_absorbed=$(printf '%s' "$absorbed_json" | jq 'length')
fi
local write_adds=false
if [ -n "$to_add" ] && { [ "$auto_add" = true ] || [ "$have_manifest" = false ]; }; then
write_adds=true
fi
if [ "$did" -eq 1 ] && [ "$frozen" = false ] \
&& { [ "$write_adds" = true ] || [ "$n_absorbed" -gt 0 ]; }; then
local add_json="[]"
[ "$write_adds" = true ] && add_json=$(printf '%s' "$to_add" | jq -R -s 'split("\n") | map(select(length > 0))')
if [ "$have_manifest" = true ]; then if [ "$have_manifest" = true ]; then
jq --argjson add "$add_json" '.dotenv = ((.dotenv // []) + $add)' "$manifest" \ jq --argjson add "$add_json" --argjson ext "$absorbed_json" \
'.dotenv = ((.dotenv // []) + $add) | .external = ((.external // []) + $ext)' "$manifest" \
| _write_manifest_canonical "$manifest" || die "Failed to update $manifest" | _write_manifest_canonical "$manifest" || die "Failed to update $manifest"
else else
jq -n --argjson add "$add_json" '{version: '"$MANIFEST_VERSION"', dotenv: $add}' \ jq -n --argjson add "$add_json" --argjson ext "$absorbed_json" \
'{version: '"$MANIFEST_VERSION"', dotenv: $add} | if ($ext | length) > 0 then .external = $ext else . end' \
| _write_manifest_canonical "$manifest" || die "Failed to write $manifest" | _write_manifest_canonical "$manifest" || die "Failed to write $manifest"
fi fi
while IFS= read -r e; do if [ "$write_adds" = true ]; then
[ -n "$e" ] && info "Added '$e' to $SECRETS_JSON_NAME" while IFS= read -r e; do
done <<< "$to_add" [ -n "$e" ] && info "Added '$e' to $SECRETS_JSON_NAME"
done <<< "$to_add"
fi
if [ "$n_absorbed" -gt 0 ]; then
info "Absorbed $n_absorbed entr(y/ies) from $SECRETS_FILES_NAME into $SECRETS_JSON_NAME (gradle-properties → properties). $SECRETS_FILES_NAME can be deleted."
fi
info "Commit the manifest so other machines pick it up. To undo an entry: edit $SECRETS_JSON_NAME (or use 'secrets push --frozen' to skip auto-add)." info "Commit the manifest so other machines pick it up. To undo an entry: edit $SECRETS_JSON_NAME (or use 'secrets push --frozen' to skip auto-add)."
fi fi

View file

@ -244,3 +244,138 @@ load test_helper
[ "$status" -eq 1 ] [ "$status" -eq 1 ]
[[ "$output" == *"project-relative"* ]] || false [[ "$output" == *"project-relative"* ]] || false
} }
# ─── C: legacy absorb + external entries via .secrets.json ─────────────
# Local fixtures (mirror secrets.bats EGB-531/652 helpers)
m_gradle_src() { mkdir -p "$HOME/.gradle"; printf '%s' "$1" > "$HOME/.gradle/gradle.properties"; }
m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' > "$HOME/keystores/upload.keystore"; }
@test "push absorbs .secrets-files into .secrets.json (properties + file)" {
init_with_remote
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
m_file_src
local dir="$WORK_DIR/absorbproj"; mkdir -p "$dir"
echo "K=v" > "$dir/.env"
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\nfile ~/keystores/upload.keystore\n' > "$dir/.secrets-files"
cd "$dir"
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[[ "$output" == *"Absorbed"* ]] || false
run jq -r '.external | length' .secrets.json
[ "$output" = "2" ]
run jq -r '.external[] | select(.path == "~/.gradle/gradle.properties") | .type' .secrets.json
[ "$output" = "properties" ]
run jq -r '.external[] | select(.type == "file") | .path' .secrets.json
[ "$output" = "~/keystores/upload.keystore" ]
# stage 1: blob naming stays legacy-compatible
run bash -c "ls $SECRETS_DIR/absorbproj/external/*.gradle-properties.age"
[ "$status" -eq 0 ]
}
@test "absorb is idempotent — second push adds no duplicate externals" {
init_with_remote
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
local dir="$WORK_DIR/absorb2"; mkdir -p "$dir"
echo "K=v" > "$dir/.env"
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$dir/.secrets-files"
cd "$dir"
"$SECRETS_BIN" push >/dev/null 2>&1
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
run jq -r '.external | length' .secrets.json
[ "$output" = "1" ]
}
@test "external properties entry in .secrets.json drives push without .secrets-files" {
init_with_remote
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
local dir="$WORK_DIR/jsonextproj"; mkdir -p "$dir"
printf '{"version":2,"external":[{"type":"properties","path":"~/.gradle/gradle.properties","keys":["beaconClerkPkTest"]}]}\n' > "$dir/.secrets.json"
cd "$dir"
run "$SECRETS_BIN" push jsonextproj
[ "$status" -eq 0 ]
[[ "$output" == *"Extracted 1 key"* ]] || false
run bash -c "ls $SECRETS_DIR/jsonextproj/external/*.gradle-properties.age"
[ "$status" -eq 0 ]
}
@test "pull merges properties keys sourced from .secrets.json" {
init_with_remote
m_gradle_src $'beaconClerkPkTest=pk_test_abc\nunrelated=keep\n'
local dir="$WORK_DIR/jsonpull"; mkdir -p "$dir"
printf '{"version":2,"external":[{"type":"properties","path":"~/.gradle/gradle.properties","keys":["beaconClerkPkTest"]}]}\n' > "$dir/.secrets.json"
cd "$dir"
"$SECRETS_BIN" push jsonpull >/dev/null 2>&1
m_gradle_src $'beaconClerkPkTest=STALE\nunrelated=keep\n'
run "$SECRETS_BIN" pull jsonpull
[ "$status" -eq 0 ]
run grep -c 'beaconClerkPkTest=pk_test_abc' "$HOME/.gradle/gradle.properties"
[ "$output" = "1" ]
run grep -c 'unrelated=keep' "$HOME/.gradle/gradle.properties"
[ "$output" = "1" ]
}
@test "properties rail generalized: any *.properties basename is accepted" {
init_with_remote
mkdir -p "$HOME/.config"
printf 'apiKey=abc123\n' > "$HOME/.config/app.properties"
local dir="$WORK_DIR/genprops"; mkdir -p "$dir"
printf '{"version":2,"external":[{"type":"properties","path":"~/.config/app.properties","keys":["apiKey"]}]}\n' > "$dir/.secrets.json"
cd "$dir"
run "$SECRETS_BIN" push genprops
[ "$status" -eq 0 ]
[[ "$output" == *"Extracted 1 key"* ]] || false
}
@test "properties rail still blocks a non-.properties target" {
init_with_remote
printf 'PATH=/evil\n' > "$HOME/.bashrc"
local dir="$WORK_DIR/evilprops"; mkdir -p "$dir"
printf '{"version":2,"external":[{"type":"properties","path":"~/.bashrc","keys":["PATH"]}]}\n' > "$dir/.secrets.json"
cd "$dir"
run "$SECRETS_BIN" push evilprops
[ "$status" -eq 1 ]
[[ "$output" == *".properties"* ]] || false
}
@test "file entry via .secrets.json round-trips binary with mode 600" {
init_with_remote
m_file_src
local dir="$WORK_DIR/jsonfile"; mkdir -p "$dir"
printf '{"version":2,"external":[{"type":"file","path":"~/keystores/upload.keystore"}]}\n' > "$dir/.secrets.json"
cd "$dir"
"$SECRETS_BIN" push jsonfile >/dev/null 2>&1
cp "$HOME/keystores/upload.keystore" "$TEST_TMPDIR/orig.keystore"
rm "$HOME/keystores/upload.keystore"
run "$SECRETS_BIN" pull jsonfile
[ "$status" -eq 0 ]
cmp -s "$HOME/keystores/upload.keystore" "$TEST_TMPDIR/orig.keystore"
local mode
mode=$(stat -f '%Lp' "$HOME/keystores/upload.keystore" 2>/dev/null || stat -c '%a' "$HOME/keystores/upload.keystore")
[ "$mode" = "600" ]
}
@test "json file entry with keys is rejected with a warning" {
init_with_remote
m_file_src
local dir="$WORK_DIR/badfile"; mkdir -p "$dir"
echo "K=v" > "$dir/.env"
printf '{"version":2,"external":[{"type":"file","path":"~/keystores/upload.keystore","keys":["nope"]}]}\n' > "$dir/.secrets.json"
cd "$dir"
run "$SECRETS_BIN" push badfile
[ "$status" -eq 0 ]
[[ "$output" == *"no keys"* ]] || false
run bash -c "ls $SECRETS_DIR/badfile/external/*.file.age 2>/dev/null"
[ "$status" -ne 0 ]
}
@test "pull warns that .secrets-files is superseded when .secrets.json exists" {
init_with_remote
create_project_dir superproj
"$SECRETS_BIN" push superproj >/dev/null 2>&1
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
run "$SECRETS_BIN" pull superproj
[ "$status" -eq 0 ]
[[ "$output" == *"superseded"* ]] || false
}

View file

@ -1217,15 +1217,17 @@ gradle_project() {
[ "$mode" = "600" ] [ "$mode" = "600" ]
} }
@test "EGB-531: target with wrong basename is refused" { @test "EGB-531: target with non-.properties basename is refused" {
# EGB-677 generalized the rail from exact 'gradle.properties' to any
# '*.properties' basename — shell rc files and gitconfig stay blocked.
init_with_remote init_with_remote
mkdir -p "$HOME/.gradle" mkdir -p "$HOME/.gradle"
printf 'beaconClerkPkTest=x\n' > "$HOME/.gradle/custom.properties" printf 'beaconClerkPkTest=x\n' > "$HOME/.gradle/evil.sh"
mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj"
printf 'gradle-properties ~/.gradle/custom.properties beaconClerkPkTest\n' > .secrets-files printf 'gradle-properties ~/.gradle/evil.sh beaconClerkPkTest\n' > .secrets-files
run "$SECRETS_BIN" push gproj run "$SECRETS_BIN" push gproj
[ "$status" -eq 1 ] [ "$status" -eq 1 ]
[[ "$output" == *"gradle.properties"* ]] || false [[ "$output" == *".properties"* ]] || false
} }
@test "EGB-531: target outside HOME is refused" { @test "EGB-531: target outside HOME is refused" {
@ -1404,6 +1406,9 @@ gradle_project() {
init_with_remote init_with_remote
create_project_dir gproj create_project_dir gproj
"$SECRETS_BIN" push gproj >/dev/null 2>&1 "$SECRETS_BIN" push gproj >/dev/null 2>&1
# EGB-677: drop the bootstrap .secrets.json so the legacy manifest path
# is exercised (with a manifest present, .secrets-files is superseded).
rm -f "$WORK_DIR/gproj/.secrets.json"
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$WORK_DIR/gproj/.secrets-files" printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$WORK_DIR/gproj/.secrets-files"
cd "$WORK_DIR/gproj" cd "$WORK_DIR/gproj"
run "$SECRETS_BIN" pull gproj run "$SECRETS_BIN" pull gproj