refactor: dedup external extractor + read guards, warn on legacy-pull nested blobs (EGB-701)

EGB-677 stage-1 structural cleanups, no behavior change for the v2 happy path:

1. cmd_which reuses _json_external_entries (the push/pull extractor) instead of
   its own duplicated jq @tsv projection, so `which` applies the same
   properties->gradle-properties normalization + skip-with-warning rules the
   sync path does and can't drift from it.
2. The two external-manifest read guards are factored into _json_readable
   (plain regular file, silent) / _legacy_readable (warn+skip a symlinked
   .secrets-files), shared by _external_entries_for_push/_pull.
3. The legacy (manifest-less) pull path now warns when nested <project>/<relpath>.age
   blobs exist that its non-recursive globs can't see (external/ excluded —
   pull_external_files handles those), so it never silently under-restores.

Tests: +4 in test/manifest.bats (normalized which display, malformed external
skipped by which, nested-blob warning fires, external-only no false warning).
Full suite green (286/286).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brian Majewski 2026-06-18 10:08:13 -07:00
parent 57f1280262
commit 1ee096cc33
3 changed files with 132 additions and 23 deletions

View file

@ -642,6 +642,31 @@ m_nojq_path() {
[[ "$output" == *"k1"* ]] || false
}
# EGB-701 item 1: `which` and the push/pull external extractor share one
# helper (_json_external_entries), so `which` applies the same
# properties→gradle-properties normalization the sync path uses — no drift.
@test "which normalizes a properties external to the gradle-properties token (EGB-701)" {
create_project_dir whichnorm
printf '{"version":2,"dotenv":[".env"],"external":[{"type":"properties","path":"~/.gradle/gradle.properties","keys":["k1"]}]}\n' > .secrets.json
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"gradle-properties"* ]] || false
}
# EGB-701 item 1: a malformed external (a properties entry with no keys) is
# skipped by the sync path; routing `which` through the shared extractor means
# `which` skips+warns it too, so it faithfully shows what actually syncs
# rather than printing an entry push/pull silently drop.
@test "which skips a malformed external entry the sync path would drop (EGB-701)" {
create_project_dir whichmalformed
printf '{"version":2,"dotenv":[".env"],"external":[{"type":"properties","path":"~/.gradle/gradle.properties"}]}\n' > .secrets.json
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"has no keys"* ]] || false
# The skipped entry's path must NOT appear in the printed manifest summary.
[[ "$output" != *" gradle-properties ~/.gradle/gradle.properties"* ]] || false
}
# ─── F: ship Step 7 coverage backfill (audit gaps) ─────────────────────
@test "which flags an unsafe dotenv entry with the UNSAFE marker" {
@ -739,6 +764,46 @@ m_nojq_path() {
[ "$(cat packages/web/.env.development)" = "N=nested" ]
}
@test "legacy (manifest-less) pull warns about nested blobs it can't restore (EGB-701)" {
# The legacy pull path globs only top-level *.age/.*.age. A nested dotenv
# blob (<project>/<relpath>.age) written by a manifest-driven push on another
# machine is invisible to those globs — restored nothing, counted nothing.
# The fix: warn so a manifest-less pull never silently under-restores.
init_with_remote
create_project_dir nestlegacy
mkdir -p packages/web
echo "N=nested" > packages/web/.env.development
"$SECRETS_BIN" add packages/web/.env.development >/dev/null
"$SECRETS_BIN" push >/dev/null 2>&1
[ -f "$SECRETS_DIR/nestlegacy/packages/web/.env.development.age" ]
# Simulate a machine with no manifest: drop .secrets.json + local files,
# forcing the legacy non-recursive glob branch.
rm -f .secrets.json
rm -rf packages
run "$SECRETS_BIN" pull nestlegacy
[ "$status" -eq 0 ]
# The warning names the nested blob and points at the manifest as the fix.
[[ "$output" == *"packages/web/.env.development"* ]] || false
[[ "$output" == *"$SECRETS_JSON_NAME"* || "$output" == *".secrets.json"* ]] || false
# The legacy path genuinely can't restore it (the warning is the contract).
[ ! -f packages/web/.env.development ]
}
@test "legacy pull does NOT warn about external/ blobs (handled separately, EGB-701)" {
# external/<slug>.age blobs are restored by pull_external_files, not the
# dotenv globs, so they must not trip the nested-blob warning.
init_with_remote
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir extnolwarn
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push >/dev/null 2>&1
[ -d "$SECRETS_DIR/extnolwarn/external" ]
run "$SECRETS_BIN" pull extnolwarn
[ "$status" -eq 0 ]
[[ "$output" != *"can't restore"* ]] || false
[[ "$output" != *"nested encrypted"* ]] || false
}
@test "list shows a nested manifest blob" {
init_with_remote
create_project_dir nestlist