From 2f36fe18988702460d28a31b3ae6fe8df1d56c6a Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Mon, 8 Jun 2026 10:31:00 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20twin-rule=20write=20targets=20=E2=80=94?= =?UTF-8?q?=20dual-write=20existing,=20v2-only=20for=20new=20(additive=20v?= =?UTF-8?q?2,=20EGB-712)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- secrets | 47 ++++++++++++++++++++++++++++++----------------- test/migrate.bats | 45 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/secrets b/secrets index 5b41f03..ed6e1f4 100755 --- a/secrets +++ b/secrets @@ -539,21 +539,6 @@ _store_format() { echo 1 } -# The on-disk blob suffix for an external entry, format-aware. v2 unifies -# the legacy `gradle-properties` suffix to `properties` (matching the JSON -# manifest `type`); `file` is unchanged in both formats. The slug + this -# suffix + `.age` is the external blob name. This is the single source of -# truth for the suffix — push, pull, verify all route through it so a v1 -# and a v2 store can never disagree on where a blob lives. -_external_blob_suffix() { - local mtype="$1" - if [ "$mtype" = "gradle-properties" ] && [ "$(_store_format)" = "2" ]; then - echo "properties" - else - echo "$mtype" - fi -} - # Resolve the on-disk path of an external blob for READING. Tries the v2 suffix # (.properties.age) first, then falls back to the v1 (.gradle-properties.age) for # `properties` externals, so an upgraded client finds the blob whichever format @@ -579,6 +564,26 @@ _resolve_external_blob_read() { esac } +# The on-disk path(s) to WRITE for an external blob, one per line. For a +# `properties` external this is the v2 suffix (.properties.age) ALWAYS, plus the +# v1 suffix (.gradle-properties.age) WHEN a v1 twin already exists in the store +# (dual-write keeps old clients fresh; a brand-new external is v2-only — the +# intended forcing function, additive v2 / EGB-712). `file` externals have a +# single suffix in both formats. Independent of the store marker. +_external_blob_write_targets() { + local project="$1" slug="$2" mtype="$3" + local base="$SECRETS_DIR/$project/external/$slug" + case "$mtype" in + file) + echo "$base.file.age" ;; + properties|gradle-properties) + echo "$base.properties.age" + [ -f "$base.gradle-properties.age" ] && echo "$base.gradle-properties.age" ;; + *) + echo "$base.$mtype.age" ;; + esac +} + # 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. @@ -677,7 +682,11 @@ push_external_files() { # EGB-652: whole-file sync — encrypt the file verbatim (binary-safe). mkdir -p "$SECRETS_DIR/$project/external" local fslug; fslug=$(_secrets_files_slug "$mpath") - age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$fslug.$(_external_blob_suffix file).age" "$expanded" + local wt + while IFS= read -r wt; do + [ -n "$wt" ] || continue + age -r "$pubkey" -o "$wt" "$expanded" + done < <(_external_blob_write_targets "$project" "$fslug" file) info "Encrypted file $mpath" pushed=$((pushed + 1)) continue @@ -706,7 +715,11 @@ push_external_files() { fi mkdir -p "$SECRETS_DIR/$project/external" local slug; slug=$(_secrets_files_slug "$mpath") - age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$slug.$(_external_blob_suffix "$mtype").age" "$tmp" + local wt + while IFS= read -r wt; do + [ -n "$wt" ] || continue + age -r "$pubkey" -o "$wt" "$tmp" + done < <(_external_blob_write_targets "$project" "$slug" "$mtype") rm -f "$tmp" info "Extracted $found key(s) from $mpath" pushed=$((pushed + 1)) diff --git a/test/migrate.bats b/test/migrate.bats index e4a9091..92e113c 100644 --- a/test/migrate.bats +++ b/test/migrate.bats @@ -10,6 +10,19 @@ make_v1_store() { init_with_remote rm -f "$SECRETS_DIR/.secrets-format" } +# Simulate an old (v1) client's properties blob: copy the pushed v2 +# .properties.age to its v1 .gradle-properties.age twin (KEEPS both present). +m_fake_v1_twin() { + local proj="$1" v2 + v2=$(ls "$SECRETS_DIR/$proj/external/"*.properties.age) + cp "$v2" "${v2%.properties.age}.gradle-properties.age" +} +# Like m_fake_v1_twin but RENAMES (leaves ONLY the v1 blob) — for copy-forward fixtures. +m_make_v1_only() { + local proj="$1" v2 + v2=$(ls "$SECRETS_DIR/$proj/external/"*.properties.age) + mv "$v2" "${v2%.properties.age}.gradle-properties.age" +} 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"; } @@ -49,13 +62,41 @@ m_file_src() { mkdir -p "$HOME/keystores"; printf 'KS\x00\x01\x02\xffDATA\n' > [ "$status" -ne 0 ] } -@test "push on a v1 store still writes .gradle-properties.age (back-compat)" { +@test "push on a v1 store writes the v2 suffix for a fresh external (additive v2)" { 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" + run bash -c "ls $SECRETS_DIR/v1push/external/*.properties.age" + [ "$status" -eq 0 ] +} + +@test "push writes the v2 suffix for a fresh external even on a v1 store" { + make_v1_store + m_gradle_src $'beaconClerkPkTest=pk_test_abc\n' + create_project_dir freshv1 + printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files + "$SECRETS_BIN" push freshv1 >/dev/null 2>&1 + run bash -c "ls $SECRETS_DIR/freshv1/external/*.properties.age" + [ "$status" -eq 0 ] + run bash -c "ls $SECRETS_DIR/freshv1/external/*.gradle-properties.age 2>/dev/null" + [ "$status" -ne 0 ] +} + +@test "push dual-writes the v1 twin so old clients stay fresh" { + make_v1_store + m_gradle_src $'beaconClerkPkTest=pk_test_old\n' + create_project_dir dualwrite + printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files + "$SECRETS_BIN" push dualwrite >/dev/null 2>&1 + m_fake_v1_twin dualwrite + m_gradle_src $'beaconClerkPkTest=pk_test_new\n' + "$SECRETS_BIN" push dualwrite >/dev/null 2>&1 + rm -f "$SECRETS_DIR/dualwrite/external/"*.properties.age + rm -f "$HOME/.gradle/gradle.properties" + "$SECRETS_BIN" pull dualwrite >/dev/null 2>&1 + run grep -q 'beaconClerkPkTest=pk_test_new' "$HOME/.gradle/gradle.properties" [ "$status" -eq 0 ] }