feat: store-format-v2 self-describing migration (EGB-703, folds in EGB-700)

Stage 2 of EGB-677. Makes the store self-describing and unifies the legacy
external `properties` blob suffix, via a copy-forward migration that never
destroys data until an explicit, gated finalize.

Scope decision (see eureka): the EGB-677 CEO plan's "flatten dotenv blobs to
basename" was dropped as LOSSY — it discards the restore relpath that makes
the store self-describing and adds basename collisions. Engineering analysis
(4 parallel design agents) showed the store is already relpath-self-describing;
the only real v1→v2 delta is the `properties` suffix. This implements the
minimal, safe v2 that achieves the epic's self-describing goal.

What's added:
- `.secrets-format` marker (committed, one line `2`). Absence ⇒ v1 (every
  pre-EGB-703 store). `_store_format()` reads it; `init` stamps fresh stores
  born-v2. `secrets which` prints `format: vN` (EGB-700 folded in).
- `_external_blob_suffix(type)` — single source of truth for the external
  suffix (v2: gradle-properties → properties; file unchanged). push/pull/verify
  all route through it, so v1 and v2 stores never disagree on blob location.
- `secrets migrate` — per-project copy-forward (writes `.properties.age` twins
  beside v1 blobs; idempotent; needs the project manifest), `--dry-run`
  (reports old→new, writes nothing), `--finalize` (store-wide, the only
  destructive step: gates on `verify --all` green + every v1 blob twinned,
  cuts a `pre-v2-migrate-<sha>` recovery tag, stamps the marker, then drops v1
  blobs; refuses without `--yes`/operator confirmation).

rekey and verify --all stay format-agnostic (recursive find walk) — no change.

21 new bats tests (test/migrate.bats): marker/born-v2, format-aware suffix,
v1 back-compat, dry-run, copy-forward idempotency, no-manifest die, finalize
gates (verify-not-green refusal, untwinned refusal, recovery tag, confirmation),
and full v1→window→finalize round-trip. Updated 4 existing tests for the
born-v2 suffix. Full suite 231/231, bash 3.2 clean.
This commit is contained in:
Brian Majewski 2026-06-07 16:07:31 -07:00
parent 144ff3692b
commit e2ad661da5
6 changed files with 487 additions and 10 deletions

270
test/migrate.bats Normal file
View file

@ -0,0 +1,270 @@
#!/usr/bin/env bats
# EGB-703 store-format-v2: marker, format-aware suffix, migrate (dry-run /
# copy-forward / finalize). bash 3.2: every standalone [[ ]] ends with || false.
load test_helper
# A v1 (legacy) store: born-v2 init, then strip the marker so it reads as v1
# and pushes write the legacy .gradle-properties.age suffix.
make_v1_store() {
init_with_remote
rm -f "$SECRETS_DIR/.secrets-format"
}
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"; }
# ─── Format marker + format-aware suffix (increment 1) ────────────────
@test "init stamps the store format marker as v2 (born-v2)" {
init_with_remote
[ -f "$SECRETS_DIR/.secrets-format" ]
[ "$(cat "$SECRETS_DIR/.secrets-format")" = "2" ]
}
@test "which prints format v2 for a born-v2 store" {
init_with_remote
create_project_dir whichv2
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"format: v2"* ]] || false
}
@test "which prints format v1 for a markerless (legacy) store" {
make_v1_store
create_project_dir whichv1
run "$SECRETS_BIN" which
[ "$status" -eq 0 ]
[[ "$output" == *"format: v1"* ]] || false
}
@test "push on a born-v2 store writes the properties blob as .properties.age" {
init_with_remote
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir v2push
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push v2push >/dev/null 2>&1
run bash -c "ls $SECRETS_DIR/v2push/external/*.properties.age"
[ "$status" -eq 0 ]
run bash -c "ls $SECRETS_DIR/v2push/external/*.gradle-properties.age 2>/dev/null"
[ "$status" -ne 0 ]
}
@test "push on a v1 store still writes .gradle-properties.age (back-compat)" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir v1push
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push v1push >/dev/null 2>&1
run bash -c "ls $SECRETS_DIR/v1push/external/*.gradle-properties.age"
[ "$status" -eq 0 ]
}
@test "the format marker is committed, not gitignored" {
init_with_remote
create_project_dir markercommit
"$SECRETS_BIN" push markercommit >/dev/null 2>&1
run bash -c "git -C $SECRETS_DIR ls-files | grep -qx .secrets-format"
[ "$status" -eq 0 ]
}
# ─── migrate --dry-run / copy-forward (increment 2) ───────────────────
@test "migrate --dry-run reports the rename and writes nothing" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir dryproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push dryproj >/dev/null 2>&1
run "$SECRETS_BIN" migrate --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"would migrate"* ]] || false
# nothing written
run bash -c "ls $SECRETS_DIR/dryproj/external/*.properties.age 2>/dev/null"
[ "$status" -ne 0 ]
# marker still absent (store still v1)
[ ! -f "$SECRETS_DIR/.secrets-format" ]
}
@test "migrate --dry-run on a dotenv-only project reports nothing to migrate" {
make_v1_store
create_project_dir dotenvonly
"$SECRETS_BIN" push dotenvonly >/dev/null 2>&1
run "$SECRETS_BIN" migrate --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"0 blob(s) would be copy-forwarded"* ]] || false
}
@test "migrate copy-forward creates the v2 twin and keeps the v1 blob (byte-identical)" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir cfproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push cfproj >/dev/null 2>&1
local old; old=$(ls "$SECRETS_DIR/cfproj/external/"*.gradle-properties.age)
run "$SECRETS_BIN" migrate
[ "$status" -eq 0 ]
local new; new=$(ls "$SECRETS_DIR/cfproj/external/"*.properties.age)
[ -f "$old" ] # v1 kept (non-destructive)
[ -f "$new" ] # v2 twin written
cmp -s "$old" "$new" # byte-identical ciphertext copy
}
@test "migrate copy-forward is idempotent" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir idemproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push idemproj >/dev/null 2>&1
"$SECRETS_BIN" migrate >/dev/null 2>&1
run "$SECRETS_BIN" migrate
[ "$status" -eq 0 ]
[[ "$output" == *"1 already present"* ]] || false
run bash -c "ls $SECRETS_DIR/idemproj/external/*.properties.age | wc -l | tr -d ' '"
[ "$output" = "1" ]
}
@test "migrate with no manifest in cwd dies with a directed message" {
make_v1_store
local dir="$WORK_DIR/nomanifest"; mkdir -p "$dir"; cd "$dir"
run "$SECRETS_BIN" migrate
[ "$status" -eq 1 ]
[[ "$output" == *".secrets.json"* ]] || false
}
@test "migrate on an already-v2 store is a no-op" {
init_with_remote
create_project_dir alreadyv2
run "$SECRETS_BIN" migrate
[ "$status" -eq 0 ]
[[ "$output" == *"already format v2"* ]] || false
}
@test "migrate unknown flag dies with usage" {
init_with_remote
create_project_dir mgflag
run "$SECRETS_BIN" migrate --bogus
[ "$status" -eq 1 ]
[[ "$output" == *"Unknown migrate flag"* ]] || false
}
@test "migrate leaves dotenv and file blobs untouched" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
m_file_src
create_project_dir mixproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\nfile ~/keystores/upload.keystore\n' > .secrets-files
"$SECRETS_BIN" push mixproj >/dev/null 2>&1
local envblob; envblob=$(ls "$SECRETS_DIR/mixproj/".env.age)
local fileblob; fileblob=$(ls "$SECRETS_DIR/mixproj/external/"*.file.age)
local envsum; envsum=$(cksum "$envblob")
local filesum; filesum=$(cksum "$fileblob")
"$SECRETS_BIN" migrate >/dev/null 2>&1
[ "$(cksum "$envblob")" = "$envsum" ] # dotenv blob unchanged
[ "$(cksum "$fileblob")" = "$filesum" ] # file blob unchanged
}
# ─── migrate --finalize (increment 3) ─────────────────────────────────
@test "finalize refuses when verify --all is not green" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir failverify
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push failverify >/dev/null 2>&1
"$SECRETS_BIN" migrate >/dev/null 2>&1
# corrupt the v2 twin so verify --all fails
printf 'garbage' > "$SECRETS_DIR/failverify/external/"*.properties.age
run "$SECRETS_BIN" migrate --finalize --yes
[ "$status" -eq 1 ]
[[ "$output" == *"not green"* ]] || false
# marker not stamped; v1 blob still present
[ ! -f "$SECRETS_DIR/.secrets-format" ]
run bash -c "ls $SECRETS_DIR/failverify/external/*.gradle-properties.age"
[ "$status" -eq 0 ]
}
@test "finalize refuses an un-twinned v1 blob (project not migrated)" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir untwinned
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push untwinned >/dev/null 2>&1
# do NOT migrate — leave the v1 blob with no twin
run "$SECRETS_BIN" migrate --finalize --yes
[ "$status" -eq 1 ]
[[ "$output" == *"no v2 twin"* ]] || false
run bash -c "ls $SECRETS_DIR/untwinned/external/*.gradle-properties.age"
[ "$status" -eq 0 ]
}
@test "finalize green path drops v1, keeps v2, stamps the marker" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir finproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push finproj >/dev/null 2>&1
"$SECRETS_BIN" migrate >/dev/null 2>&1
run "$SECRETS_BIN" migrate --finalize --yes
[ "$status" -eq 0 ]
[ "$(cat "$SECRETS_DIR/.secrets-format")" = "2" ]
run bash -c "ls $SECRETS_DIR/finproj/external/*.properties.age"
[ "$status" -eq 0 ]
run bash -c "ls $SECRETS_DIR/finproj/external/*.gradle-properties.age 2>/dev/null"
[ "$status" -ne 0 ]
}
@test "finalize cuts a recovery tag before deleting v1 blobs" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir tagproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push tagproj >/dev/null 2>&1
"$SECRETS_BIN" migrate >/dev/null 2>&1
"$SECRETS_BIN" migrate --finalize --yes >/dev/null 2>&1
local tag; tag=$(git -C "$SECRETS_DIR" tag | grep '^pre-v2-migrate-')
[ -n "$tag" ]
# the tagged commit still contains the v1 blob (tag cut before delete)
run bash -c "git -C $SECRETS_DIR ls-tree -r --name-only $tag | grep -q gradle-properties.age"
[ "$status" -eq 0 ]
}
@test "finalize without --yes aborts when not confirmed" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir confproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push confproj >/dev/null 2>&1
"$SECRETS_BIN" migrate >/dev/null 2>&1
run bash -c "echo '' | $SECRETS_BIN migrate --finalize"
[ "$status" -eq 1 ]
[[ "$output" == *"aborted"* ]] || false
[ ! -f "$SECRETS_DIR/.secrets-format" ]
}
@test "v1 client still reads during the migration window (after copy-forward, before finalize)" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir windowproj
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push windowproj >/dev/null 2>&1
"$SECRETS_BIN" migrate >/dev/null 2>&1
# store is still v1 (markerless); pull uses the .gradle-properties.age blob
rm "$HOME/.gradle/gradle.properties"
run "$SECRETS_BIN" pull windowproj
[ "$status" -eq 0 ]
grep -q '^beaconClerkPkTest=pk_test_abc$' "$HOME/.gradle/gradle.properties"
}
@test "post-finalize pull reads the v2 blob" {
make_v1_store
m_gradle_src $'beaconClerkPkTest=pk_test_abc\n'
create_project_dir postfin
printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files
"$SECRETS_BIN" push postfin >/dev/null 2>&1
"$SECRETS_BIN" migrate >/dev/null 2>&1
"$SECRETS_BIN" migrate --finalize --yes >/dev/null 2>&1
rm "$HOME/.gradle/gradle.properties"
run "$SECRETS_BIN" pull postfin
[ "$status" -eq 0 ]
grep -q '^beaconClerkPkTest=pk_test_abc$' "$HOME/.gradle/gradle.properties"
}