diff --git a/secrets b/secrets index 52b6129..b2f1559 100755 --- a/secrets +++ b/secrets @@ -53,6 +53,7 @@ check_cmd() { check_initialized() { if [ -d "$SECRETS_DIR/.git" ]; then + _check_store_version_skew return fi if [ "$STORE_SOURCE" != "default" ]; then @@ -539,6 +540,72 @@ _store_format() { echo 1 } +# ─── Client/store version skew (EGB-713) ────────────────────────────── +# +# The running client's own version, read from the VERSION file shipped beside +# the script. Empty/"0.0.0.0" if absent — treated as "unknown/oldest" so a +# missing VERSION never triggers a spurious nudge. +_client_version() { + local v="" + [ -f "$SCRIPT_DIR/VERSION" ] && v=$(head -1 "$SCRIPT_DIR/VERSION" 2>/dev/null | tr -d '\r\n[:space:]') + printf '%s' "${v:-0.0.0.0}" +} + +# Numeric four-field (MAJOR.MINOR.PATCH.MICRO) compare. Returns 0 iff $1 > $2. +# Per-field numeric (so 0.10.0.0 > 0.7.0.0); missing/garbage fields → 0. +_version_gt() { + local a="$1" b="$2" i ai bi; local -a af bf + IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b" + for i in 0 1 2 3; do + ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0 + bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0 + if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi + if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi + done + return 1 +} + +WRITER_VERSION_FILE_NAME=".secrets-writer-version" +# Highest client version recorded as having written to the store (empty if the +# store predates this feature — "minus the initial builds", silent by design). +_store_writer_version() { + local f="$SECRETS_DIR/$WRITER_VERSION_FILE_NAME" + if [ -f "$f" ]; then + head -1 "$f" 2>/dev/null | tr -d '\r\n[:space:]' + fi + return 0 +} + +# Raise the store's recorded writer-version to the client's version (monotonic; +# never lowers it). Called right before each store-committing `git add -A` so +# the stamp rides the same commit. Read paths (pull) never call this. +_stamp_writer_version() { + local cur cli + cur=$(_store_writer_version) + cli=$(_client_version) + if [ -z "$cur" ] || _version_gt "$cli" "$cur"; then + printf '%s\n' "$cli" > "$SECRETS_DIR/$WRITER_VERSION_FILE_NAME" + fi +} + +# Warn ONCE per invocation if the store was last written by a newer client than +# us. Non-fatal (read/write paths keep their exit codes). Silent when the store +# carries no writer-version (legacy) or is same/older than us. +_VERSION_SKEW_WARNED=0 +_check_store_version_skew() { + [ "$_VERSION_SKEW_WARNED" = 1 ] && return 0 + local sv cv + sv=$(_store_writer_version) + [ -n "$sv" ] || return 0 + cv=$(_client_version) + if _version_gt "$sv" "$cv"; then + _VERSION_SKEW_WARNED=1 + echo "NOTE: this store was last written by secrets v$sv; you're on v$cv." >&2 + echo " Update your secrets tool: git -C \"$SCRIPT_DIR\" pull" >&2 + fi + return 0 +} + # 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 @@ -1262,6 +1329,7 @@ commit_and_push_secrets() { # store missing the key.txt line would stage and push the private key. ensure_store_protections + _stamp_writer_version git -C "$SECRETS_DIR" add -A if git -C "$SECRETS_DIR" diff --cached --quiet 2>/dev/null; then info "No changes to push (secrets unchanged)" @@ -1819,6 +1887,7 @@ cmd_rekey() { # Commit and push (heal .gitignore first so add -A can't stage key.txt) ensure_store_protections + _stamp_writer_version git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "rekey all secrets" >/dev/null if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then @@ -1949,6 +2018,15 @@ cmd_which() { # EGB-700 (folded into EGB-703): surface the store format so users can tell # v1 from v2 during the migration window. v1 = legacy store, no format marker. echo "format: v$(_store_format)" + local _wv; _wv=$(_store_writer_version) + if [ -n "$_wv" ]; then + local _cv; _cv=$(_client_version) + if _version_gt "$_wv" "$_cv"; then + echo "written-by: v$_wv (you're on v$_cv — behind; run: git -C \"$SCRIPT_DIR\" pull)" + else + echo "written-by: v$_wv" + fi + fi # v2 manifest (.secrets.json): validate and summarize. Validation here # is deliberately fatal (symlink / malformed / unsupported version) so @@ -2212,6 +2290,7 @@ _migrate_project() { return 0 fi ensure_store_protections + _stamp_writer_version git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "migrate: copy-forward v2 twins for $project" >/dev/null 2>&1 || true # Push the twins so a --finalize on another machine sees them (finalize @@ -2302,6 +2381,7 @@ $untwinned cd into each project and run 'secrets migrate', then re-run 'secrets # No v1 blobs at all — just stamp the marker (dotenv/file-only store). printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME" ensure_store_protections + _stamp_writer_version git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "migrate: finalize store format v2" >/dev/null 2>&1 || true git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1 && git -C "$SECRETS_DIR" push >/dev/null 2>&1 || true @@ -2337,6 +2417,7 @@ $untwinned cd into each project and run 'secrets migrate', then re-run 'secrets done < <(find "$SECRETS_DIR" -type f -name '*.gradle-properties.age') ensure_store_protections + _stamp_writer_version git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "migrate: finalize store format v2 (drop $v1count v1 blob(s))" >/dev/null 2>&1 || true git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1 && git -C "$SECRETS_DIR" push >/dev/null 2>&1 || true diff --git a/test/version.bats b/test/version.bats new file mode 100644 index 0000000..fa12630 --- /dev/null +++ b/test/version.bats @@ -0,0 +1,106 @@ +#!/usr/bin/env bats +# EGB-713 version-skew nudge: writer-version stamp, numeric comparator, skew +# warning, `which` surface. bash 3.2: every standalone [[ ]] ends with || false. + +load test_helper + +VERSION_FILE() { echo "$(cd "$(dirname "$SECRETS_BIN")" && pwd)/VERSION"; } + +# ─── comparator contract ────────────────────────────────────────────── + +@test "version comparator orders 0.7.0.0 < 0.10.0.0 numerically (not lexically)" { + run bash -c ' + _version_gt() { + local a="$1" b="$2" i ai bi; local -a af bf + IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b" + for i in 0 1 2 3; do + ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0 + bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0 + if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi + if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi + done; return 1 + } + _version_gt 0.10.0.0 0.7.0.0 && echo "10gt7" + _version_gt 0.7.0.0 0.10.0.0 || echo "7not_gt_10" + _version_gt 0.7.0.0 0.7.0.0 || echo "equal_not_gt" + _version_gt 1.0.0.0 0.9.9.9 && echo "major_wins" + ' + [ "$status" -eq 0 ] + [[ "$output" == *"10gt7"* ]] || false + [[ "$output" == *"7not_gt_10"* ]] || false + [[ "$output" == *"equal_not_gt"* ]] || false + [[ "$output" == *"major_wins"* ]] || false +} + +# ─── stamp on write ─────────────────────────────────────────────────── + +@test "push stamps the store writer-version with the client version" { + init_with_remote + create_project_dir wvstamp + "$SECRETS_BIN" push wvstamp >/dev/null 2>&1 + [ -f "$SECRETS_DIR/.secrets-writer-version" ] + run cat "$SECRETS_DIR/.secrets-writer-version" + [ "$output" = "$(cat "$(VERSION_FILE)")" ] +} + +@test "writer-version stamp is monotonic (a push never lowers a higher stamp)" { + init_with_remote + create_project_dir wvmono + printf '9.9.9.9\n' > "$SECRETS_DIR/.secrets-writer-version" + "$SECRETS_BIN" push wvmono >/dev/null 2>&1 + run cat "$SECRETS_DIR/.secrets-writer-version" + [ "$output" = "9.9.9.9" ] +} + +@test "writer-version stamp is committed, not gitignored" { + init_with_remote + create_project_dir wvcommit + "$SECRETS_BIN" push wvcommit >/dev/null 2>&1 + run bash -c "git -C $SECRETS_DIR ls-files | grep -qx .secrets-writer-version" + [ "$status" -eq 0 ] +} + +# ─── skew warning on command ────────────────────────────────────────── + +@test "a store written by a newer version warns on a command (non-fatal)" { + init_with_remote + create_project_dir skewwarn + "$SECRETS_BIN" push skewwarn >/dev/null 2>&1 + printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version" + run "$SECRETS_BIN" list + [ "$status" -eq 0 ] + [[ "$output" == *"last written by secrets v99.0.0.0"* ]] || false + [[ "$output" == *"Update your secrets tool"* ]] || false +} + +@test "a store at the same/older version is silent" { + init_with_remote + create_project_dir noskew + "$SECRETS_BIN" push noskew >/dev/null 2>&1 + run "$SECRETS_BIN" list + [ "$status" -eq 0 ] + [[ "$output" != *"Update your secrets tool"* ]] || false +} + +@test "a store with no writer-version marker is silent (legacy store)" { + init_with_remote + create_project_dir legacynostamp + "$SECRETS_BIN" push legacynostamp >/dev/null 2>&1 + rm -f "$SECRETS_DIR/.secrets-writer-version" + run "$SECRETS_BIN" list + [ "$status" -eq 0 ] + [[ "$output" != *"Update your secrets tool"* ]] || false +} + +# ─── which surface ──────────────────────────────────────────────────── + +@test "which prints the store writer-version and a behind note" { + init_with_remote + create_project_dir whichwv + "$SECRETS_BIN" push whichwv >/dev/null 2>&1 + printf '99.0.0.0\n' > "$SECRETS_DIR/.secrets-writer-version" + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"written-by: v99.0.0.0"* ]] || false + [[ "$output" == *"behind"* ]] || false +}