From 110ac514ccacea124b3ea7110b4adfab6691af28 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Tue, 26 May 2026 12:42:04 -0700 Subject: [PATCH 1/3] v0.2.0.0 feat: sync gradle.properties keys via .secrets-files (EGB-531) Add a committed .secrets-files manifest that lets secrets track designated keys from files outside the project root (motivating case: ~/.gradle/gradle.properties for Android Clerk publishable keys, which Android Studio GUI builds read but terminal env vars can't reach). - push extracts only the named keys, encrypts under /external/ - pull MERGES them into the target, preserving unrelated keys/comments/order - pure-bash merge (no sed/regex): exact-string key match, opaque values - path validator: basename gradle.properties, within $HOME, no symlink/.. - external/ subdir keeps blobs out of the dotenv *.age globs; rekey + list recurse explicitly - which reads back the manifest; list shows [external]; pre-commit blocks plaintext gradle.properties Also fixes two latent bugs in 'secrets rekey' (never completed before, no prior test): age-keygen refusing to overwrite key.txt, and an EXIT trap referencing an out-of-scope local under set -u. Tests: 80 -> 104. Reviewed via /autoplan (CEO/Eng/DX). EGB-531. --- CHANGELOG.md | 25 +++ CLAUDE.md | 21 ++- README.md | 45 ++++- VERSION | 2 +- hooks/pre-commit | 5 +- secrets | 427 +++++++++++++++++++++++++++++++++++++++++++++- test/secrets.bats | 304 +++++++++++++++++++++++++++++++++ 7 files changed, 816 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5bf502..557254b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme. +## [0.2.0.0] - 2026-05-26 + +### Added + +- **Sync designated keys from external files (Gradle properties).** A new committed `.secrets-files` manifest lets `secrets` track specific keys from files that live *outside* the project root — the motivating case being `~/.gradle/gradle.properties`, where Android builds read Clerk publishable keys (`beaconClerkPkTest`, `beaconClerkPkLive`) that Android Studio's GUI builds can only get from that persistent global file, not from terminal env vars. One entry per line: `gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive`. (EGB-531) + - **push** extracts only the named keys and encrypts them under `/external/` in the store. + - **pull** *merges* those keys into the target file, preserving every unrelated key, comment, and line order. An existing managed key is updated in place; the target is backed up to `gradle.properties.secrets-bak` before the first merge. + - `secrets which` reads back the parsed manifest; `secrets list` shows `[external]` entries; `secrets rekey` re-encrypts external blobs alongside dotenv ones. + - Backward compatible: no `.secrets-files` → identical behavior to before. + +### Security + +- **The merge is pure bash with exact-string key matching — no `sed`/regex.** This is deliberate: a `sed`-based substitution would corrupt values containing `&`, `\`, or `/` (common in API keys) and would false-match substring keys (`beaconClerkPk` vs `beaconClerkPkTest`). Values are treated as opaque literals and round-trip byte-exact. +- **External write targets are validated against an attacker-controlled path.** Because the target path comes from a *committed* manifest, the writable target is locked down: basename must be `gradle.properties`, the path must resolve inside `$HOME`, `..` traversal is rejected, and symlinked targets (file or parent dir) are refused — blocking a malicious manifest from appending decrypted keys to `~/.gitconfig`, `~/.bashrc`, etc. Manifest parsing rejects shell metacharacters and control characters in paths and keys, mirroring the `.secrets-store` posture. Writes are atomic (temp-in-same-dir + rename), mode-preserving, and default to `600` on create. +- **Storage isolation.** External blobs live in a `/external/` subdir so the existing broad `*.age` globs (pull, list, rekey) structurally never decrypt them into the working directory or orphan them. +- **Note on plaintext.** Merged Gradle keys are written as permanent plaintext into the target file (`secrets clear` does not remove them) — appropriate for publishable/low-secrecy values like Clerk publishable keys, by design. + +### Fixed + +- **`secrets rekey` was broken and never completed.** Two latent bugs, exposed by the new rekey test: (1) `age-keygen -o key.txt` aborts because age-keygen refuses to overwrite an existing file — the new key is now generated into a temp dir and moved into place only on success, so the old key survives a failed rotation; (2) the `EXIT` trap referenced the function-local `$tmpdir` after the function returned, erroring under `set -u` and leaking the plaintext temp dir — the temp dir is now removed explicitly and the trap cleared on normal completion. + +### Tests + +- 80 → 104 (+24). New coverage: manifest parse/read-back, key extraction across `=`/`:`/space separators, merge (preserve unrelated/comments/order, substring-key isolation, sed-metachar value round-trip, duplicate-key collapse, continuation-line safety, idempotency), path validation (wrong basename, outside `$HOME`, symlink), first-create mode `600`, manifest injection/symlink rejection, rekey round-trip of external blobs, glob isolation (blob not leaked to cwd), `list` surfacing, pre-commit blocking plaintext `gradle.properties`, and backward compatibility. + ## [0.1.1.0] - 2026-05-09 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 6f9d9ea..8ada649 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,16 +26,18 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek - Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable) - Storage: Private git repo at `~/.secrets/` - Convention: Tracks `.env`, `.env.*`, and `.dev.vars` (not `.envrc`, `.environment-*`) +- External files: `.secrets-files` manifest tracks designated keys from files outside the project (e.g. `~/.gradle/gradle.properties`) — merged, not overwritten (EGB-531, see below) - Workspaces: `--workspaces` flag reads `package.json` workspaces, requires `jq` -- Safety: Pre-commit hook rejects plaintext secret files +- Safety: Pre-commit hook rejects plaintext secret files (`.env`, `.dev.vars`, `gradle.properties`) +- Portability: must run on system bash 3.2 (macOS) — no associative arrays or bash-4 features ## Project Structure ``` -secrets # CLI script (~300 lines bash) +secrets # CLI script (~600 lines bash) hooks/pre-commit # Pre-commit hook template test/ - secrets.bats # bats-core test suite (25 tests) + secrets.bats # bats-core test suite (104 tests) test_helper.bash # Shared setup/teardown README.md # User-facing documentation CLAUDE.md # This file @@ -58,6 +60,19 @@ The active store directory is picked by `resolve_store()` using these rules, hig `.secrets-store` parsing is deliberately conservative: first non-empty non-comment line wins, no shell expansion (no `$VAR`, `$()`, backticks). Bare names map via `_expand_store_path`: `work` → `$HOME/.secrets-work`, `default` → `$HOME/.secrets`. An optional remote URL after the spec on the same line is captured as `_REMOTE_URL` and passed through to `check_initialized`, which uses it to fill in a runnable `git clone ` in the missing-store error (EGB-282). The URL is parsed via `read -r spec rest` (no `set -- $line`, no glob expansion) and then **sanitized**: any URL containing shell metacharacters (`;&|<>$\`(){}*?!"'\\`), control characters (incl. ANSI escapes), or whitespace is dropped with a stderr warning. The directed error then falls back to the `` placeholder. This matters because the rendered `git clone` line is meant to be copy-pasted by a teammate — without sanitization, `work evil.git;rm -rf ~` would render verbatim and execute the payload on paste. Internal flow: `_parse_secrets_store_file` returns `\t`; `_find_secrets_store_file` returns `\t\t`; `resolve_store` splits the 3-tuple via `IFS=$'\t' read -r ...`. +## External files (.secrets-files) — EGB-531 + +`.secrets-files` is a committed, project-root manifest declaring keys to sync from files **outside** the project (motivating case: `~/.gradle/gradle.properties`, which Android Studio GUI builds read but terminal env vars can't reach). One entry per line: ` ...`. Only type `gradle-properties` is supported; the type token leaves room for future types **without** a plugin-dispatch framework (build the concrete case — a deliberate scope cut). + +Key design decisions (all driven by /autoplan review): + +- **Wire-in is at command scope** (`cmd_push`/`cmd_pull`), via `push_external_files` / `pull_external_files`, **not** inside `push_dir_to_project` / `pull_project_to_dir` (those loop per-workspace and `pull_project_to_dir` uses stdout as a data channel). +- **Storage:** blobs live in `$SECRETS_DIR//external/.gradle-properties.age`. The `external/` subdir keeps them out of the existing non-recursive `*.age` / `.*.age` globs (pull, list, rekey), so the old dotenv path can never decrypt a blob into cwd. `cmd_rekey` and `cmd_list` recurse into `external/` explicitly (rekey MUST, or the blob is orphaned after rotation = data loss). `` = manifest path token with non-`[A-Za-z0-9._-]` chars → `_`, so it's machine-independent. +- **Merge is pure bash, no `sed`/regex** (`merge_gradle_keys`): exact-string key comparison (avoids `beaconClerkPk` vs `beaconClerkPkTest` substring bug), value treated as opaque literal (survives `& \ /` in values). Updates a managed key in place at its first occurrence, collapses duplicates, appends new keys, preserves unrelated lines/comments/order. Continuation lines (trailing odd backslashes, tracked by `_trailing_bs_odd`) are never matched as keys. Atomic write: temp in the same dir → `chmod` to match (or `600` on create) → `mv`. Backs up to `.secrets-bak` before the first merge. +- **Properties separator parsing** (`_props_get`): key ends at the first `=`, `:`, or whitespace (after lstrip); handles `key=value`, `key = value`, `key:value`, `key value`; last definition wins. +- **Security:** the write target comes from a committed file, so `_validate_external_target_path` locks it down — basename must be `gradle.properties`, must resolve inside `$HOME` (deepest-existing-ancestor resolved, symlink target/parent refused, `..` rejected). This blocks a malicious manifest from appending decrypted keys to `~/.gitconfig`/`~/.bashrc`. `_parse_secrets_files_manifest` rejects shell metacharacters/control chars in path and keys (path allows `[A-Za-z0-9/._~-]` only; keys allow `[A-Za-z0-9._-]` + space), mirrors the `.secrets-store` posture (no shell expansion, symlinked manifest skipped). +- **Plaintext tradeoff (accepted, documented):** merged keys are permanent plaintext in the target; `secrets clear` does not remove them. Fine for the Clerk *publishable* keys this was built for; not for high-value secrets (use `secrets run` + `.env`). + ## Deploy Configuration - Platform: NONE (distributed via `git clone` from GitHub) diff --git a/README.md b/README.md index c9eed3f..026d375 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ flowchart TD Files like `.envrc` (direnv) and `.environment-*` are intentionally **not** tracked. +Beyond project files, `secrets` can also sync designated keys from files that live *outside* the project — like `~/.gradle/gradle.properties` — merging them in without clobbering unrelated keys. See [External files (Gradle properties)](#external-files-gradle-properties). + ## Prerequisites - **macOS** (uses Homebrew for installation) @@ -346,6 +348,47 @@ Inside `~/.secrets/`, workspace secrets are organized by path: Requires `jq` (`brew install jq`). +### External files (Gradle properties) + +Some credentials don't live in your project at all. Android builds, for example, read keys from `~/.gradle/gradle.properties` — a global file, outside any project, shared by every Gradle project on your machine (the project's own `gradle.properties` is git-tracked, so it's the wrong home for secrets). `secrets` can sync specific keys from such a file without touching the unrelated keys around them. + +You declare what to sync in a committed `.secrets-files` manifest at your project root, one entry per line: + +``` +# +gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive +``` + +- **type** — `gradle-properties` (the only supported type today). +- **path** — absolute or `~/`-relative. The basename must be `gradle.properties` and must resolve inside `$HOME`. +- **keys** — the property names to sync. Only these keys are read on push and merged on pull; everything else in the file is left alone. + +#### Syncing to a second machine + +On the machine that already has the keys set: + +```bash +cd ~/myapp +echo "gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive" > .secrets-files +git add .secrets-files && git commit -m "sync gradle Clerk keys" +secrets push +# ==> Extracted 2 key(s) from ~/.gradle/gradle.properties +``` + +`secrets push` extracts just those keys, encrypts them, and stores them under `/external/` in your secrets repo. Run `secrets which` from the project to confirm the manifest parsed. + +On the other machine (after the usual key + repo setup): + +```bash +cd ~/myapp +secrets pull +# ==> Merged 2 key(s) into ~/.gradle/gradle.properties (beaconClerkPkTest, beaconClerkPkLive) +``` + +`secrets pull` merges those keys into the local `~/.gradle/gradle.properties`, leaving every other key untouched. If a managed key already exists, its value is updated in place; comments, ordering, and unrelated entries are preserved. The file is backed up to `gradle.properties.secrets-bak` before the first merge. + +> **Note:** unlike `.env` files, merged Gradle keys are written as **permanent plaintext** into the target file — `secrets clear` does **not** remove them. This is appropriate for publishable / low-secrecy values (like Clerk publishable keys, `pk_*`). For high-value secrets that should never sit on disk, use `secrets run` with a `.env` instead. + ## Safety features - **`secrets run` auto-clears** — plaintext files are deleted when the command exits, errors, or is interrupted with Ctrl-C @@ -389,7 +432,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/` ## Development ```bash -# Run the test suite (37 tests) +# Run the test suite (104 tests) brew install bats-core bats test/secrets.bats ``` diff --git a/VERSION b/VERSION index 5cff5f1..e396b40 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.1.0 +0.2.0.0 diff --git a/hooks/pre-commit b/hooks/pre-commit index dc6570a..bb741f5 100755 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -1,9 +1,10 @@ #!/usr/bin/env bash # Pre-commit hook for the secrets repo. -# Rejects staged files matching .env patterns without .age extension. +# Rejects staged plaintext secret files (.env, .dev.vars, gradle.properties) +# without a .age extension. # This is a safety net, not a security boundary (--no-verify bypasses it). -BLOCKED=$(git diff --cached --name-only | grep -E '\.(env|dev\.vars)' | grep -v '\.age$' || true) +BLOCKED=$(git diff --cached --name-only | grep -E '(\.env|\.dev\.vars|gradle\.properties)' | grep -v '\.age$' || true) if [ -n "$BLOCKED" ]; then echo "ERROR: Plaintext secret files staged for commit:" echo "$BLOCKED" diff --git a/secrets b/secrets index f33309a..d47ba01 100755 --- a/secrets +++ b/secrets @@ -319,6 +319,325 @@ collect_env_files() { [ ${#COLLECTED_FILES[@]} -gt 0 ] } +# ─── External files (.secrets-files) — EGB-531 ───────────────────────── +# +# A committed, project-local `.secrets-files` manifest declares files that +# live OUTSIDE the project root (e.g. global Gradle properties) whose +# *designated keys* should sync. One entry per non-empty non-comment line: +# +# gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive +# [key...] +# +# push extracts only the named keys → encrypts a subset blob under +# /external/. pull decrypts it and MERGES those keys into , +# preserving every unrelated line. Only type `gradle-properties` is +# supported (the type token leaves room for future types without a plugin +# framework). Parsing mirrors `.secrets-store`: no shell expansion, no +# symlink following, conservative sanitization. The merge is pure bash +# (no sed/regex) with exact-string key matching, so it is safe against +# values containing `& \ /` and against substring keys +# (beaconClerkPk vs beaconClerkPkTest). + +SECRETS_FILES_NAME=".secrets-files" +SUPPORTED_EXTERNAL_TYPE="gradle-properties" + +# True if the string ends in an odd number of backslashes (a Java/Gradle +# properties line-continuation). Used to skip continuation lines when +# matching keys so an unrelated continued value is never clobbered. +_trailing_bs_odd() { + local s="$1" n=0 + while [ "${s%\\}" != "$s" ]; do s="${s%\\}"; n=$((n + 1)); done + [ $((n % 2)) -eq 1 ] +} + +# Parse .secrets-files. Emits "\t\t" per valid entry on +# stdout; warnings (skipped/unsafe lines) to stderr. Conservative: rejects +# shell/control chars and `..` in the path, restricts keys to a safe +# charset. No shell expansion is applied to file content. +_parse_secrets_files_manifest() { + local file="$1" + local line lineno=0 mtype mpath mkeys + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno + 1)) + line="${line%$'\r'}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [ -z "$line" ] && continue + case "$line" in '#'*) continue ;; esac + # `read -r` does not glob-expand and keeps the key list intact in $mkeys. + read -r mtype mpath mkeys <<< "$line" + if [ "$mtype" != "$SUPPORTED_EXTERNAL_TYPE" ]; then + echo "WARNING: $file line $lineno: unknown type '$mtype' (supported: $SUPPORTED_EXTERNAL_TYPE). Skipping." >&2 + continue + fi + if [ -z "$mpath" ] || [ -z "$mkeys" ]; then + echo "WARNING: $file line $lineno: expected ' [key...]'. Skipping." >&2 + continue + fi + # Path: allow alnum and / . _ ~ - only; reject everything else (blocks + # \$ \` ; & | ( ) * ? whitespace etc.) and reject `..` traversal. + case "$mpath" in + *[!A-Za-z0-9/._~-]* | *..*) + echo "WARNING: $file line $lineno: unsafe characters in path. Paths may contain letters, digits, / . _ - ~ only. Skipping." >&2 + continue + ;; + esac + # Keys: space-separated; each may contain alnum and . _ - (covers the + # systemProp. prefix). Reject anything else (=, *, control chars, ...). + case "$mkeys" in + *[!A-Za-z0-9._\ -]*) + echo "WARNING: $file line $lineno: unsafe characters in key list. Skipping." >&2 + continue + ;; + esac + printf '%s\t%s\t%s\n' "$mtype" "$mpath" "$mkeys" + done < "$file" +} + +# Validate a writable external target path. Refuses anything that isn't a +# `gradle.properties` resolving inside $HOME, blocks `..`, and refuses +# symlinks (target file or its parent dir) — the path comes from a +# committed file, so it is attacker-controllable. Returns 0 if safe. +_validate_external_target_path() { + local p="$1" + local base; base=$(basename "$p") + if [ "$base" != "gradle.properties" ]; then + echo "ERROR: $SECRETS_FILES_NAME: target basename must be 'gradle.properties' (got '$base'). Refusing." >&2 + return 1 + fi + 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 dir; dir=$(dirname "$p") + # Resolve the deepest existing ancestor and require it inside $HOME. + local probe="$dir" + while [ ! -e "$probe" ] && [ "$probe" != "/" ] && [ -n "$probe" ]; do + probe=$(dirname "$probe") + done + local probe_real + probe_real=$(cd -P "$probe" 2>/dev/null && pwd -P) || { + echo "ERROR: $SECRETS_FILES_NAME: cannot resolve target ancestor '$probe'. Refusing." >&2 + return 1 + } + case "$probe_real/" in + "$home_real"/*) ;; + *) echo "ERROR: $SECRETS_FILES_NAME: target '$p' must resolve inside \$HOME. Refusing." >&2; return 1 ;; + esac + if [ -L "$p" ]; then + echo "ERROR: $SECRETS_FILES_NAME: target '$p' is a symlink. Refusing to follow." >&2 + return 1 + fi + if [ -e "$dir" ] && [ -L "$dir" ]; then + echo "ERROR: $SECRETS_FILES_NAME: target dir '$dir' is a symlink. Refusing." >&2 + return 1 + fi + return 0 +} + +# Print the value of an exact key from a properties file (last definition +# wins, matching Gradle). Returns 1 if the key is absent. Pure bash; the +# key is compared by string equality (never as a regex), and the value is +# read verbatim. Skips comments and continuation lines. +_props_get() { + local file="$1" want="$2" + [ -f "$file" ] || return 1 + local line t key after val="" found=0 cont=0 + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + if [ "$cont" -eq 1 ]; then + _trailing_bs_odd "$line" && cont=1 || cont=0 + continue + fi + _trailing_bs_odd "$line" && cont=1 || cont=0 + t="${line#"${line%%[![:space:]]*}"}" + case "$t" in ''|'#'*|'!'*) continue ;; esac + key="${t%%[=:[:space:]]*}" + [ "$key" = "$want" ] || continue + after="${t#"$key"}" + after="${after#"${after%%[![:space:]]*}"}" + case "$after" in [=:]*) after="${after#?}" ;; esac + after="${after#"${after%%[![:space:]]*}"}" + val="$after" + found=1 + done < "$file" + [ "$found" -eq 1 ] || return 1 + printf '%s' "$val" +} + +# Turn a manifest path token into a machine-independent blob slug. +_secrets_files_slug() { + local p="$1" + printf '%s' "${p//[!A-Za-z0-9._-]/_}" +} + +# 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. +merge_gradle_keys() { + local target="$1" kvfile="$2" + local -a mkey=() mval=() seen=() + local line key val i + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + key="${line%%=*}" + val="${line#*=}" + mkey+=("$key"); mval+=("$val"); seen+=(0) + done < "$kvfile" + local n=${#mkey[@]} + [ "$n" -gt 0 ] || return 0 + + local dir; dir=$(dirname "$target") + mkdir -p "$dir" + local tmp; tmp=$(mktemp "$dir/.gradle-merge.XXXXXX") || return 1 + + local cont=0 t tkey matched + if [ -f "$target" ]; then + while IFS= read -r line || [ -n "$line" ]; do + local stripped="${line%$'\r'}" + if [ "$cont" -eq 1 ]; then + printf '%s\n' "$line" >> "$tmp" + _trailing_bs_odd "$stripped" && cont=1 || cont=0 + continue + fi + _trailing_bs_odd "$stripped" && cont=1 || cont=0 + t="${stripped#"${stripped%%[![:space:]]*}"}" + case "$t" in ''|'#'*|'!'*) printf '%s\n' "$line" >> "$tmp"; continue ;; esac + tkey="${t%%[=:[:space:]]*}" + matched=-1 + for ((i = 0; i < n; i++)); do + if [ "$tkey" = "${mkey[$i]}" ]; then matched=$i; break; fi + done + if [ "$matched" -ge 0 ]; then + if [ "${seen[$matched]}" -eq 0 ]; then + printf '%s=%s\n' "${mkey[$matched]}" "${mval[$matched]}" >> "$tmp" + seen[$matched]=1 + fi + else + printf '%s\n' "$line" >> "$tmp" + fi + done < "$target" + fi + for ((i = 0; i < n; i++)); do + if [ "${seen[$i]}" -eq 0 ]; then + printf '%s=%s\n' "${mkey[$i]}" "${mval[$i]}" >> "$tmp" + fi + done + + # Back up the existing target before overwriting (seatbelt for the merge + # engine touching a hand-maintained file), preserve mode, atomic rename. + if [ -f "$target" ]; then + cp "$target" "$target.secrets-bak" 2>/dev/null || true + local mode + mode=$(stat -f '%Lp' "$target" 2>/dev/null || stat -c '%a' "$target" 2>/dev/null || echo 600) + chmod "$mode" "$tmp" 2>/dev/null || true + else + chmod 600 "$tmp" 2>/dev/null || true + fi + # TOCTOU recheck before the write into $HOME. + if [ -L "$target" ]; then + rm -f "$tmp" + echo "ERROR: $target became a symlink; aborting merge." >&2 + return 1 + fi + mv "$tmp" "$target" +} + +# Encrypt the managed keys declared in /.secrets-files into +# /external/. Returns 0 if at least one entry was pushed, 1 if +# there is no usable manifest. Dies on unsafe targets or all-missing keys. +push_external_files() { + local root="$1" project="$2" pubkey="$3" + local manifest="$root/$SECRETS_FILES_NAME" + [ -e "$manifest" ] || return 1 + if [ -L "$manifest" ]; then + echo "WARNING: $manifest is a symlink; ignoring." >&2 + return 1 + fi + [ -f "$manifest" ] || return 1 + + local pushed=0 mtype mpath mkeys + while IFS=$'\t' read -r mtype mpath mkeys; do + [ -n "$mtype" ] || continue + local expanded; expanded=$(_expand_store_path "$mpath") + if ! _validate_external_target_path "$expanded"; then + die "Refusing unsafe external target in $SECRETS_FILES_NAME: $mpath" + fi + if [ ! -f "$expanded" ]; then + echo "WARNING: source '$mpath' not found on this machine; skipping." >&2 + continue + fi + local tmp; tmp=$(mktemp) + local found=0 k v + for k in $mkeys; do + if v=$(_props_get "$expanded" "$k"); then + printf '%s=%s\n' "$k" "$v" >> "$tmp" + found=$((found + 1)) + else + echo "WARNING: key '$k' not found in $mpath — not synced. Set it locally first, or remove it from $SECRETS_FILES_NAME." >&2 + fi + done + if [ "$found" -eq 0 ]; then + rm -f "$tmp" + die "No managed keys found in $mpath (looked for: $mkeys)." + fi + mkdir -p "$SECRETS_DIR/$project/external" + local slug; slug=$(_secrets_files_slug "$mpath") + age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$slug.$mtype.age" "$tmp" + rm -f "$tmp" + info "Extracted $found key(s) from $mpath" + pushed=$((pushed + 1)) + done < <(_parse_secrets_files_manifest "$manifest") + + [ "$pushed" -gt 0 ] +} + +# Decrypt the external blobs named by /.secrets-files and merge their +# keys into the declared targets. Skips (with a warning) unsafe targets or +# missing blobs rather than aborting the whole pull. +pull_external_files() { + local root="$1" project="$2" + local manifest="$root/$SECRETS_FILES_NAME" + [ -e "$manifest" ] || return 0 + if [ -L "$manifest" ]; then + echo "WARNING: $manifest is a symlink; ignoring." >&2 + return 0 + fi + [ -f "$manifest" ] || return 0 + + local mtype mpath mkeys + while IFS=$'\t' read -r mtype mpath mkeys; do + [ -n "$mtype" ] || continue + local expanded; expanded=$(_expand_store_path "$mpath") + if ! _validate_external_target_path "$expanded"; then + echo "WARNING: skipping unsafe external target: $mpath" >&2 + continue + fi + local slug; slug=$(_secrets_files_slug "$mpath") + local blob="$SECRETS_DIR/$project/external/$slug.$mtype.age" + if [ ! -f "$blob" ]; then + echo "WARNING: $SECRETS_FILES_NAME names '$mpath' but no encrypted data exists in the store yet. Run 'secrets push' on a machine that has these keys. Skipping." >&2 + continue + fi + local tmp; tmp=$(mktemp) + if ! age -d -i "$KEY_FILE" -o "$tmp" "$blob"; then + rm -f "$tmp" + die "Decryption failed for external target $mpath." + fi + local k + for k in $mkeys; do + grep -q "^$k=" "$tmp" 2>/dev/null || echo "WARNING: '$k' listed in $SECRETS_FILES_NAME but absent from synced data for $mpath." >&2 + done + if [ ! -d "$(dirname "$expanded")" ]; then + echo "WARNING: creating $(dirname "$expanded")" >&2 + fi + local count; count=$(grep -c '=' "$tmp" 2>/dev/null || echo 0) + if merge_gradle_keys "$expanded" "$tmp"; then + local klist; klist=$(printf '%s' "$mkeys" | tr ' ' ',' | sed 's/,/, /g') + info "Merged $count key(s) into $expanded ($klist)" + fi + rm -f "$tmp" + done < <(_parse_secrets_files_manifest "$manifest") +} + # Read package.json workspaces and expand globs to actual directories. # Prints one workspace path per line (relative to the monorepo root). get_workspaces() { @@ -353,7 +672,7 @@ install_hook() { # Inline hook if template not found (e.g. secrets installed standalone) cat > "$hook_dst" << 'HOOKEOF' #!/usr/bin/env bash -BLOCKED=$(git diff --cached --name-only | grep -E '\.(env|dev\.vars)' | grep -v '\.age$' || true) +BLOCKED=$(git diff --cached --name-only | grep -E '(\.env|\.dev\.vars|gradle\.properties)' | grep -v '\.age$' || true) if [ -n "$BLOCKED" ]; then echo "ERROR: Plaintext secret files staged for commit:" echo "$BLOCKED" @@ -480,8 +799,11 @@ cmd_push() { local pubkey pubkey=$(get_pubkey) - if ! push_dir_to_project "$PWD" "$project" "$pubkey"; then - die "No secret files (.env, .env.*, .dev.vars) found in $PWD" + local did=0 + if push_dir_to_project "$PWD" "$project" "$pubkey"; then did=1; fi + if push_external_files "$PWD" "$project" "$pubkey"; then did=1; fi + if [ "$did" -eq 0 ]; then + die "No secret files (.env, .env.*, .dev.vars) or $SECRETS_FILES_NAME entries found in $PWD" fi commit_and_push_secrets "update $project" @@ -522,6 +844,12 @@ cmd_push_workspaces() { fi done <<< "$workspaces" + # External files (.secrets-files) are monorepo-root-scoped, like + # .secrets-store — handle once, not per-workspace. + if push_external_files "$root" "$monorepo_name" "$pubkey"; then + total=$((total + 1)) + fi + if [ "$total" -eq 0 ]; then die "No secret files found in any workspace" fi @@ -569,6 +897,9 @@ cmd_pull() { info "Decrypted $count file(s) into $target_dir" + # Merge any external files (.secrets-files) declared in this project. + pull_external_files "$PWD" "$project" + # Reinstall hook if missing if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then install_hook @@ -644,6 +975,12 @@ cmd_pull_workspaces() { fi done <<< "$workspaces" + # External files (.secrets-files) are monorepo-root-scoped — once. + if [ -f "$root/$SECRETS_FILES_NAME" ] && [ ! -L "$root/$SECRETS_FILES_NAME" ]; then + pull_external_files "$root" "$monorepo_name" + total=$((total + 1)) + fi + if [ "$total" -eq 0 ]; then die "No secrets found for any workspace in $monorepo_name" fi @@ -674,6 +1011,14 @@ cmd_list() { echo " $(basename "$f" .age)" found=1 done + # External files live in a subdir, invisible to the globs above. + if [ -d "${dir}external" ]; then + for f in "${dir}external"/*.age; do + [ -f "$f" ] || continue + echo " [external] $(basename "$f" .age)" + found=1 + done + fi done if [ "$found" -eq 0 ]; then @@ -720,7 +1065,10 @@ cmd_rekey() { # Create temp dir with cleanup trap local tmpdir tmpdir=$(mktemp -d) - trap 'rm -rf "$tmpdir"' EXIT INT TERM + # `${tmpdir:-}` guards against `set -u` if the trap somehow fires after the + # function returns (the local would be out of scope). Normal completion + # cleans up explicitly and clears the trap at the end of this function. + trap 'rm -rf "${tmpdir:-}"' EXIT INT TERM info "Decrypting all files with current key..." @@ -741,6 +1089,20 @@ cmd_rekey() { fi file_count=$((file_count + 1)) done + # External files live in a subdir; rekey them too or they become + # undecryptable after rotation. + if [ -d "${dir}external" ]; then + mkdir -p "$tmpdir/$project/external" + for f in "${dir}external"/*.age; do + [ -f "$f" ] || continue + local ename + ename=$(basename "$f" .age) + if ! age -d -i "$KEY_FILE" -o "$tmpdir/$project/external/$ename" "$f"; then + die "Decryption failed for $project/external/$ename. Rekey aborted. Old key preserved." + fi + file_count=$((file_count + 1)) + done + fi done if [ "$file_count" -eq 0 ]; then @@ -749,8 +1111,12 @@ cmd_rekey() { info "Decrypted $file_count file(s). Generating new key pair..." - # Generate new key (overwrites old) - age-keygen -o "$KEY_FILE" 2>&1 + # Generate the new key. age-keygen refuses to overwrite an existing + # file, so generate into the temp dir and move it into place only on + # success — the old key stays intact if generation fails. + local newkey="$tmpdir/key.txt.new" + age-keygen -o "$newkey" 2>&1 + mv "$newkey" "$KEY_FILE" local pubkey pubkey=$(get_pubkey) @@ -768,6 +1134,15 @@ cmd_rekey() { name=$(basename "$f") age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f" done + if [ -d "${dir}external" ]; then + mkdir -p "$SECRETS_DIR/$project/external" + for f in "${dir}external"/*; do + [ -f "$f" ] || continue + local ename + ename=$(basename "$f") + age -r "$pubkey" -o "$SECRETS_DIR/$project/external/${ename}.age" "$f" + done + fi done # Commit and push @@ -787,6 +1162,11 @@ cmd_rekey() { echo "" echo "WARNING: Old ciphertext remains in git history." echo "For full rotation, create a fresh repo." + + # Clean up the plaintext temp dir while $tmpdir is still in scope, then + # drop the trap so it can't fire (and error under set -u) post-return. + rm -rf "$tmpdir" + trap - EXIT INT TERM } cmd_clear() { @@ -893,6 +1273,22 @@ cmd_which() { resolve_store echo "store: $SECRETS_DIR" echo "source: $STORE_SOURCE" + + # Read back any external-file manifest in cwd (validates the format and + # gives the user a way to confirm it parsed, since there's no add-file + # command). Skips symlinked manifests. + local manifest="$PWD/$SECRETS_FILES_NAME" + if [ -f "$manifest" ] && [ ! -L "$manifest" ]; then + local mtype mpath mkeys printed=0 + while IFS=$'\t' read -r mtype mpath mkeys; do + [ -n "$mtype" ] || continue + if [ "$printed" -eq 0 ]; then + echo "external files ($SECRETS_FILES_NAME at $manifest):" + printed=1 + fi + echo " $mtype $mpath $mkeys" + done < <(_parse_secrets_files_manifest "$manifest") + fi } cmd_help() { @@ -946,6 +1342,25 @@ Workspaces: // in the secrets repo. Root secret files are stored under / directly. Requires jq. +External files (.secrets-files): + Sync specific keys from files OUTSIDE the project root (e.g. global + Gradle properties). Create a committed .secrets-files in the project + root, one entry per line: + + # + gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive + + On push, the named keys are extracted from and encrypted under + /external/ in the store. On pull, they are MERGED back into + , leaving all unrelated keys untouched. Only type + 'gradle-properties' is supported; the target basename must be + 'gradle.properties' and must resolve inside $HOME. Run 'secrets which' + from the project to confirm the manifest parsed. + + Note: merged keys are written as permanent plaintext into the target + file (suitable for publishable/low-secrecy values). 'secrets clear' + does NOT remove them. + Environment: SECRETS_DIR Path to secrets repo (default: ~/.secrets). See also the .secrets-store file and --store flag above. diff --git a/test/secrets.bats b/test/secrets.bats index 72f0b88..9bd5f0b 100644 --- a/test/secrets.bats +++ b/test/secrets.bats @@ -1045,3 +1045,307 @@ PKG [ "$status" -eq 1 ] [[ "$output" == *"git+ssh://user@host:2222/path/to-repo_v2.git"* ]] } + +# ─── EGB-531: gradle.properties external file support ────────────────── + +# Helper: write a fake global gradle.properties under the sandboxed HOME. +gradle_src() { + mkdir -p "$HOME/.gradle" + printf '%s' "$1" > "$HOME/.gradle/gradle.properties" +} + +# Helper: bind a project dir to a gradle entry via .secrets-files, cd into it. +gradle_project() { + local name="${1:-gproj}" + local keys="${2:-beaconClerkPkTest beaconClerkPkLive}" + local dir="$WORK_DIR/$name" + mkdir -p "$dir" + printf 'gradle-properties ~/.gradle/gradle.properties %s\n' "$keys" > "$dir/.secrets-files" + cd "$dir" +} + +@test "EGB-531: which shows parsed .secrets-files entries" { + init_with_remote + gradle_project gproj + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"gradle-properties"* ]] + [[ "$output" == *"~/.gradle/gradle.properties"* ]] + [[ "$output" == *"beaconClerkPkTest"* ]] +} + +@test "EGB-531: push extracts managed keys into external/ blob (no .env needed)" { + init_with_remote + gradle_src $'beaconClerkPkTest=pk_test_abc\nbeaconClerkPkLive=pk_live_xyz\nunrelated=keep\n' + gradle_project gproj + run "$SECRETS_BIN" push gproj + [ "$status" -eq 0 ] + [[ "$output" == *"Extracted 2 key"* ]] + run bash -c "ls $SECRETS_DIR/gproj/external/*.gradle-properties.age" + [ "$status" -eq 0 ] +} + +@test "EGB-531: push dies if all managed keys missing from source" { + init_with_remote + gradle_src $'somethingelse=1\n' + gradle_project gproj + run "$SECRETS_BIN" push gproj + [ "$status" -eq 1 ] + [[ "$output" == *"not found"* ]] +} + +@test "EGB-531: pull merges managed keys, preserves unrelated entries" { + init_with_remote + gradle_src $'beaconClerkPkTest=pk_test_abc\nbeaconClerkPkLive=pk_live_xyz\n' + gradle_project gproj + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + # Simulate a second machine: target holds only unrelated keys + gradle_src $'unrelated.key=keepme\norg.gradle.jvmargs=-Xmx2g\n' + run "$SECRETS_BIN" pull gproj + [ "$status" -eq 0 ] + [[ "$output" == *"Merged 2 key"* ]] + grep -q 'beaconClerkPkTest=pk_test_abc' "$HOME/.gradle/gradle.properties" + grep -q 'beaconClerkPkLive=pk_live_xyz' "$HOME/.gradle/gradle.properties" + grep -q 'unrelated.key=keepme' "$HOME/.gradle/gradle.properties" + grep -q 'org.gradle.jvmargs=-Xmx2g' "$HOME/.gradle/gradle.properties" +} + +@test "EGB-531: merge does NOT touch a substring key" { + init_with_remote + gradle_src $'beaconClerkPkTest=secretval\n' + gradle_project gproj beaconClerkPkTest + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + # Target has the shorter key AND a stale managed key + gradle_src $'beaconClerkPk=DONOTCHANGE\nbeaconClerkPkTest=old\n' + run "$SECRETS_BIN" pull gproj + [ "$status" -eq 0 ] + grep -q '^beaconClerkPk=DONOTCHANGE$' "$HOME/.gradle/gradle.properties" + grep -q '^beaconClerkPkTest=secretval$' "$HOME/.gradle/gradle.properties" +} + +@test "EGB-531: value with sed/regex metacharacters round-trips byte-exact" { + init_with_remote + local val='a/b&c\d.e|f$g' + gradle_src "$(printf 'beaconClerkPkTest=%s\n' "$val")" + gradle_project gproj beaconClerkPkTest + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + gradle_src $'other=1\n' + run "$SECRETS_BIN" pull gproj + [ "$status" -eq 0 ] + run grep -F "beaconClerkPkTest=$val" "$HOME/.gradle/gradle.properties" + [ "$status" -eq 0 ] +} + +@test "EGB-531: colon and space separators are parsed" { + init_with_remote + gradle_src $'beaconClerkPkTest : pk_colon\nbeaconClerkPkLive pk_space\n' + gradle_project gproj + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + gradle_src $'x=1\n' + run "$SECRETS_BIN" pull gproj + [ "$status" -eq 0 ] + grep -q '^beaconClerkPkTest=pk_colon$' "$HOME/.gradle/gradle.properties" + grep -q '^beaconClerkPkLive=pk_space$' "$HOME/.gradle/gradle.properties" +} + +@test "EGB-531: pull is idempotent (second pull leaves file byte-identical)" { + init_with_remote + gradle_src $'beaconClerkPkTest=pk_test_abc\nbeaconClerkPkLive=pk_live_xyz\n' + gradle_project gproj + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + gradle_src $'unrelated=x\n# a comment\n' + "$SECRETS_BIN" pull gproj >/dev/null 2>&1 + cp "$HOME/.gradle/gradle.properties" "$TEST_TMPDIR/snap1" + "$SECRETS_BIN" pull gproj >/dev/null 2>&1 + run diff "$TEST_TMPDIR/snap1" "$HOME/.gradle/gradle.properties" + [ "$status" -eq 0 ] +} + +@test "EGB-531: merge preserves comments and blank lines" { + init_with_remote + gradle_src $'beaconClerkPkTest=pk_test_abc\n' + gradle_project gproj beaconClerkPkTest + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + gradle_src $'# header comment\n\nunrelated=x\n' + run "$SECRETS_BIN" pull gproj + [ "$status" -eq 0 ] + grep -q '^# header comment$' "$HOME/.gradle/gradle.properties" + grep -q '^unrelated=x$' "$HOME/.gradle/gradle.properties" + grep -q '^beaconClerkPkTest=pk_test_abc$' "$HOME/.gradle/gradle.properties" +} + +@test "EGB-531: duplicate managed key in target collapses to one canonical line" { + init_with_remote + gradle_src $'beaconClerkPkTest=newval\n' + gradle_project gproj beaconClerkPkTest + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + gradle_src $'beaconClerkPkTest=old1\nx=1\nbeaconClerkPkTest=old2\n' + run "$SECRETS_BIN" pull gproj + [ "$status" -eq 0 ] + run grep -c '^beaconClerkPkTest=' "$HOME/.gradle/gradle.properties" + [ "$output" -eq 1 ] + grep -q '^beaconClerkPkTest=newval$' "$HOME/.gradle/gradle.properties" + grep -q '^x=1$' "$HOME/.gradle/gradle.properties" +} + +@test "EGB-531: continuation-line-adjacent managed key is not clobbered" { + init_with_remote + gradle_src $'beaconClerkPkTest=realval\n' + gradle_project gproj beaconClerkPkTest + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + # 'beaconClerkPkTest=...' here is a CONTINUATION of unrelated's value, not a definition + printf 'unrelated=foo\\\nbeaconClerkPkTest=continuation\n' > "$HOME/.gradle/gradle.properties" + run "$SECRETS_BIN" pull gproj + [ "$status" -eq 0 ] + # continuation line preserved verbatim + grep -q '^beaconClerkPkTest=continuation$' "$HOME/.gradle/gradle.properties" + # and the real managed key appended + grep -q '^beaconClerkPkTest=realval$' "$HOME/.gradle/gradle.properties" +} + +@test "EGB-531: first-create target gets mode 600" { + init_with_remote + gradle_src $'beaconClerkPkTest=pk_test_abc\n' + gradle_project gproj beaconClerkPkTest + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + rm -f "$HOME/.gradle/gradle.properties" + run "$SECRETS_BIN" pull gproj + [ "$status" -eq 0 ] + [ -f "$HOME/.gradle/gradle.properties" ] + local mode + mode=$(stat -f '%Lp' "$HOME/.gradle/gradle.properties" 2>/dev/null || stat -c '%a' "$HOME/.gradle/gradle.properties") + [ "$mode" = "600" ] +} + +@test "EGB-531: target with wrong basename is refused" { + init_with_remote + mkdir -p "$HOME/.gradle" + printf 'beaconClerkPkTest=x\n' > "$HOME/.gradle/custom.properties" + mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" + printf 'gradle-properties ~/.gradle/custom.properties beaconClerkPkTest\n' > .secrets-files + run "$SECRETS_BIN" push gproj + [ "$status" -eq 1 ] + [[ "$output" == *"gradle.properties"* ]] +} + +@test "EGB-531: target outside HOME is refused" { + init_with_remote + local outside + outside=$(mktemp -d) + mkdir -p "$outside/.gradle" + printf 'beaconClerkPkTest=x\n' > "$outside/.gradle/gradle.properties" + mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" + printf 'gradle-properties %s/.gradle/gradle.properties beaconClerkPkTest\n' "$outside" > .secrets-files + run "$SECRETS_BIN" push gproj + rm -rf "$outside" + [ "$status" -eq 1 ] + [[ "$output" == *"HOME"* ]] +} + +@test "EGB-531: symlinked target is refused" { + init_with_remote + mkdir -p "$HOME/.gradle" + printf 'beaconClerkPkTest=x\n' > "$HOME/realgradle" + ln -s "$HOME/realgradle" "$HOME/.gradle/gradle.properties" + gradle_project gproj beaconClerkPkTest + run "$SECRETS_BIN" push gproj + [ "$status" -eq 1 ] + [[ "$output" == *"symlink"* ]] +} + +@test "EGB-531: unknown type in manifest warns and skips" { + init_with_remote + mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" + printf 'gradle-props ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files + echo "X=1" > .env + run "$SECRETS_BIN" push gproj + [[ "$output" == *"unknown type"* ]] + [ ! -d "$SECRETS_DIR/gproj/external" ] +} + +@test "EGB-531: malformed manifest line (no path/keys) is skipped with warning" { + init_with_remote + mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" + printf 'gradle-properties\n' > .secrets-files + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"WARNING"* ]] +} + +@test "EGB-531: manifest path with command-substitution chars is rejected" { + init_with_remote + mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" + local pwn="$TEST_TMPDIR/pwn-$$" + rm -f "$pwn" + printf 'gradle-properties ~/.gradle/gradle.properties$(touch %s) beaconClerkPkTest\n' "$pwn" > .secrets-files + run "$SECRETS_BIN" which + [ ! -f "$pwn" ] + [[ "$output" == *"WARNING"* ]] +} + +@test "EGB-531: symlinked .secrets-files is ignored" { + init_with_remote + printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$HOME/realmanifest" + mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" + ln -s "$HOME/realmanifest" .secrets-files + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" != *"beaconClerkPkTest"* ]] +} + +@test "EGB-531: rekey re-encrypts the external blob (still decryptable after)" { + init_with_remote + gradle_src $'beaconClerkPkTest=pk_test_abc\n' + gradle_project gproj beaconClerkPkTest + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + "$SECRETS_BIN" rekey >/dev/null 2>&1 + gradle_src $'other=1\n' + run "$SECRETS_BIN" pull gproj + [ "$status" -eq 0 ] + grep -q '^beaconClerkPkTest=pk_test_abc$' "$HOME/.gradle/gradle.properties" +} + +@test "EGB-531: gradle blob is NOT decrypted into cwd by dotenv pull" { + init_with_remote + gradle_src $'beaconClerkPkTest=pk_test_abc\n' + mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" + printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > .secrets-files + echo "DOTENV=1" > .env + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + local pulldir="$WORK_DIR/pull-gproj" + mkdir -p "$pulldir" + cp "$WORK_DIR/gproj/.secrets-files" "$pulldir/.secrets-files" + cd "$pulldir" + run "$SECRETS_BIN" pull gproj + [ "$status" -eq 0 ] + [ -f "$pulldir/.env" ] + [ ! -f "$pulldir/gradle.properties" ] +} + +@test "EGB-531: list shows external entry" { + init_with_remote + gradle_src $'beaconClerkPkTest=pk_test_abc\n' + gradle_project gproj beaconClerkPkTest + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + run "$SECRETS_BIN" list + [ "$status" -eq 0 ] + [[ "$output" == *"external"* ]] +} + +@test "EGB-531: no .secrets-files behaves exactly as before (backward compat)" { + init_with_remote + create_project_dir plainproj + run "$SECRETS_BIN" push plainproj + [ "$status" -eq 0 ] + [ ! -d "$SECRETS_DIR/plainproj/external" ] +} + +@test "EGB-531: pre-commit blocks plaintext gradle.properties in store" { + init_with_remote + cd "$SECRETS_DIR" + echo "beaconClerkPkTest=leak" > gradle.properties + git add -f gradle.properties + run git commit -m "should fail" + [ "$status" -eq 1 ] + [[ "$output" == *"Plaintext"* ]] +} From 31f2741c44ca73805646178d8c9e497db66d9486 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Tue, 26 May 2026 14:26:41 -0700 Subject: [PATCH 2/3] test: coverage for external-file workspace/multi-entry/warning paths (EGB-531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the high-value gaps surfaced by the ship coverage audit (71% → ~92%): push -w / pull -w external sync, multi-entry manifests, partial-key push warnings, missing-blob pull warnings, unsafe-key-charset rejection, symlinked parent dir refusal, and source-side comment/continuation skipping. Tests: 104 -> 112. Docs updated to match. --- CHANGELOG.md | 2 +- CLAUDE.md | 2 +- README.md | 2 +- test/secrets.bats | 115 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 557254b..b96af01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme. ### Tests -- 80 → 104 (+24). New coverage: manifest parse/read-back, key extraction across `=`/`:`/space separators, merge (preserve unrelated/comments/order, substring-key isolation, sed-metachar value round-trip, duplicate-key collapse, continuation-line safety, idempotency), path validation (wrong basename, outside `$HOME`, symlink), first-create mode `600`, manifest injection/symlink rejection, rekey round-trip of external blobs, glob isolation (blob not leaked to cwd), `list` surfacing, pre-commit blocking plaintext `gradle.properties`, and backward compatibility. +- 80 → 112 (+32). New coverage: manifest parse/read-back, key extraction across `=`/`:`/space separators, merge (preserve unrelated/comments/order, substring-key isolation, sed-metachar value round-trip, duplicate-key collapse, continuation-line safety, idempotency), path validation (wrong basename, outside `$HOME`, symlinked target, symlinked parent dir), first-create mode `600`, manifest injection/symlink/unsafe-key rejection, rekey round-trip of external blobs, glob isolation (blob not leaked to cwd), `list` surfacing, pre-commit blocking plaintext `gradle.properties`, workspace (`push -w`/`pull -w`) external sync, multi-entry manifests, partial-key push warnings, missing-blob pull warnings, source-side comment/continuation skipping, and backward compatibility. ## [0.1.1.0] - 2026-05-09 diff --git a/CLAUDE.md b/CLAUDE.md index 8ada649..c3e001c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek secrets # CLI script (~600 lines bash) hooks/pre-commit # Pre-commit hook template test/ - secrets.bats # bats-core test suite (104 tests) + secrets.bats # bats-core test suite (112 tests) test_helper.bash # Shared setup/teardown README.md # User-facing documentation CLAUDE.md # This file diff --git a/README.md b/README.md index 026d375..ded1889 100644 --- a/README.md +++ b/README.md @@ -432,7 +432,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/` ## Development ```bash -# Run the test suite (104 tests) +# Run the test suite (112 tests) brew install bats-core bats test/secrets.bats ``` diff --git a/test/secrets.bats b/test/secrets.bats index 9bd5f0b..bd95488 100644 --- a/test/secrets.bats +++ b/test/secrets.bats @@ -1349,3 +1349,118 @@ gradle_project() { [ "$status" -eq 1 ] [[ "$output" == *"Plaintext"* ]] } + +# ── EGB-531: coverage for warning/error branches, workspaces, multi-entry ── + +@test "EGB-531: push -w pushes external blob once at monorepo root" { + init_with_remote + gradle_src $'beaconClerkPkTest=pk_w\n' + local mono="$WORK_DIR/mono" + mkdir -p "$mono/apps/web" + printf '{"workspaces":["apps/*"]}\n' > "$mono/package.json" + echo "ROOT=1" > "$mono/.env" + echo "WEB=1" > "$mono/apps/web/.env" + printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$mono/.secrets-files" + git init "$mono" >/dev/null 2>&1 + cd "$mono" + run "$SECRETS_BIN" push -w + [ "$status" -eq 0 ] + # External blob pushed exactly once (not once per workspace) + run bash -c "ls $SECRETS_DIR/mono/external/*.gradle-properties.age 2>/dev/null | wc -l | tr -d ' '" + [ "$output" = "1" ] +} + +@test "EGB-531: pull -w merges external keys at monorepo root" { + init_with_remote + gradle_src $'beaconClerkPkTest=pk_w\n' + local mono="$WORK_DIR/mono" + mkdir -p "$mono/apps/web" + printf '{"workspaces":["apps/*"]}\n' > "$mono/package.json" + echo "ROOT=1" > "$mono/.env" + echo "WEB=1" > "$mono/apps/web/.env" + printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$mono/.secrets-files" + git init "$mono" >/dev/null 2>&1 + cd "$mono" + "$SECRETS_BIN" push -w >/dev/null 2>&1 + gradle_src $'unrelated=keep\n' + run "$SECRETS_BIN" pull -w + [ "$status" -eq 0 ] + grep -q '^beaconClerkPkTest=pk_w$' "$HOME/.gradle/gradle.properties" + grep -q '^unrelated=keep$' "$HOME/.gradle/gradle.properties" +} + +@test "EGB-531: push warns for missing key but still syncs present ones" { + init_with_remote + gradle_src $'beaconClerkPkTest=present\n' + gradle_project gproj + run "$SECRETS_BIN" push gproj + [ "$status" -eq 0 ] + [[ "$output" == *"beaconClerkPkLive"* ]] + [[ "$output" == *"not found"* ]] + [[ "$output" == *"Extracted 1 key"* ]] +} + +@test "EGB-531: pull warns when manifest entry has no blob in store" { + init_with_remote + create_project_dir gproj + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\n' > "$WORK_DIR/gproj/.secrets-files" + cd "$WORK_DIR/gproj" + run "$SECRETS_BIN" pull gproj + [ "$status" -eq 0 ] + [[ "$output" == *"no encrypted data exists"* ]] +} + +@test "EGB-531: multi-entry manifest syncs each target" { + init_with_remote + mkdir -p "$HOME/.gradle" "$HOME/.gradle-b" + printf 'beaconClerkPkTest=a\n' > "$HOME/.gradle/gradle.properties" + printf 'beaconClerkPkLive=b\n' > "$HOME/.gradle-b/gradle.properties" + mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" + printf 'gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest\ngradle-properties ~/.gradle-b/gradle.properties beaconClerkPkLive\n' > .secrets-files + run "$SECRETS_BIN" push gproj + [ "$status" -eq 0 ] + run bash -c "ls $SECRETS_DIR/gproj/external/*.age 2>/dev/null | wc -l | tr -d ' '" + [ "$output" = "2" ] + printf 'x=1\n' > "$HOME/.gradle/gradle.properties" + printf 'y=1\n' > "$HOME/.gradle-b/gradle.properties" + run "$SECRETS_BIN" pull gproj + [ "$status" -eq 0 ] + grep -q '^beaconClerkPkTest=a$' "$HOME/.gradle/gradle.properties" + grep -q '^beaconClerkPkLive=b$' "$HOME/.gradle-b/gradle.properties" +} + +@test "EGB-531: manifest with unsafe key chars is skipped with warning" { + init_with_remote + mkdir -p "$WORK_DIR/gproj"; cd "$WORK_DIR/gproj" + printf 'gradle-properties ~/.gradle/gradle.properties bad=key\n' > .secrets-files + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"WARNING"* ]] + [[ "$output" != *"bad=key"* ]] +} + +@test "EGB-531: symlinked parent dir of target is refused" { + init_with_remote + mkdir -p "$HOME/realdir" + printf 'beaconClerkPkTest=x\n' > "$HOME/realdir/gradle.properties" + ln -s "$HOME/realdir" "$HOME/.gradle" + gradle_project gproj beaconClerkPkTest + run "$SECRETS_BIN" push gproj + [ "$status" -eq 1 ] + [[ "$output" == *"symlink"* ]] +} + +@test "EGB-531: push skips comment and continuation lines in source" { + init_with_remote + mkdir -p "$HOME/.gradle" + printf '%s' $'! bang comment\nunrelated=foo\\\nbeaconClerkPkTest=is_a_continuation\nbeaconClerkPkTest=realkey\n' > "$HOME/.gradle/gradle.properties" + gradle_project gproj beaconClerkPkTest + "$SECRETS_BIN" push gproj >/dev/null 2>&1 + gradle_src $'z=1\n' + run "$SECRETS_BIN" pull gproj + [ "$status" -eq 0 ] + # The continuation line that looks like the key must NOT win; the real + # definition must, and the '!' comment must be ignored. + grep -q '^beaconClerkPkTest=realkey$' "$HOME/.gradle/gradle.properties" +} From ffdff4dafbddd60f990ea7f5f525a66212d34154 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Tue, 26 May 2026 14:40:11 -0700 Subject: [PATCH 3/3] fix: pre-landing review fixes for external-file sync (EGB-531) Adversarial review of the diff surfaced 6 non-critical findings, all fixed: - pull now warns + reports when a merge fails (read-only $HOME, full disk) instead of silently exiting 0 as if it synced - push skips a multi-line (continuation) managed value with a warning rather than writing a dangling backslash that would corrupt the target - absent-key pull check escapes regex '.' so systemProp.foo can't false-match - fix garbled 'Merged 0\n0 key(s)' count when a blob has no '=' lines - slug gets a cksum suffix so paths that clean to the same string (a/b vs a_b) don't overwrite each other's blob - docs: backup happens before each merge, not just the first Tests: 112 -> 113 (continuation-value skip). --- CHANGELOG.md | 4 ++-- CLAUDE.md | 6 +++--- README.md | 4 ++-- secrets | 29 +++++++++++++++++++++++------ test/secrets.bats | 12 ++++++++++++ 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b96af01..214b95c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme. - **Sync designated keys from external files (Gradle properties).** A new committed `.secrets-files` manifest lets `secrets` track specific keys from files that live *outside* the project root — the motivating case being `~/.gradle/gradle.properties`, where Android builds read Clerk publishable keys (`beaconClerkPkTest`, `beaconClerkPkLive`) that Android Studio's GUI builds can only get from that persistent global file, not from terminal env vars. One entry per line: `gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive`. (EGB-531) - **push** extracts only the named keys and encrypts them under `/external/` in the store. - - **pull** *merges* those keys into the target file, preserving every unrelated key, comment, and line order. An existing managed key is updated in place; the target is backed up to `gradle.properties.secrets-bak` before the first merge. + - **pull** *merges* those keys into the target file, preserving every unrelated key, comment, and line order. An existing managed key is updated in place; the target is backed up to `gradle.properties.secrets-bak` before each merge. - `secrets which` reads back the parsed manifest; `secrets list` shows `[external]` entries; `secrets rekey` re-encrypts external blobs alongside dotenv ones. - Backward compatible: no `.secrets-files` → identical behavior to before. @@ -28,7 +28,7 @@ and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme. ### Tests -- 80 → 112 (+32). New coverage: manifest parse/read-back, key extraction across `=`/`:`/space separators, merge (preserve unrelated/comments/order, substring-key isolation, sed-metachar value round-trip, duplicate-key collapse, continuation-line safety, idempotency), path validation (wrong basename, outside `$HOME`, symlinked target, symlinked parent dir), first-create mode `600`, manifest injection/symlink/unsafe-key rejection, rekey round-trip of external blobs, glob isolation (blob not leaked to cwd), `list` surfacing, pre-commit blocking plaintext `gradle.properties`, workspace (`push -w`/`pull -w`) external sync, multi-entry manifests, partial-key push warnings, missing-blob pull warnings, source-side comment/continuation skipping, and backward compatibility. +- 80 → 113 (+33). New coverage: manifest parse/read-back, key extraction across `=`/`:`/space separators, merge (preserve unrelated/comments/order, substring-key isolation, sed-metachar value round-trip, duplicate-key collapse, continuation-line safety, idempotency), path validation (wrong basename, outside `$HOME`, symlinked target, symlinked parent dir), first-create mode `600`, manifest injection/symlink/unsafe-key rejection, rekey round-trip of external blobs, glob isolation (blob not leaked to cwd), `list` surfacing, pre-commit blocking plaintext `gradle.properties`, workspace (`push -w`/`pull -w`) external sync, multi-entry manifests, partial-key push warnings, missing-blob pull warnings, source-side comment/continuation skipping, and backward compatibility. ## [0.1.1.0] - 2026-05-09 diff --git a/CLAUDE.md b/CLAUDE.md index c3e001c..e31f76a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Single bash script (`secrets`) with subcommands: init, push, pull, list, rm, rek secrets # CLI script (~600 lines bash) hooks/pre-commit # Pre-commit hook template test/ - secrets.bats # bats-core test suite (112 tests) + secrets.bats # bats-core test suite (113 tests) test_helper.bash # Shared setup/teardown README.md # User-facing documentation CLAUDE.md # This file @@ -67,8 +67,8 @@ The active store directory is picked by `resolve_store()` using these rules, hig Key design decisions (all driven by /autoplan review): - **Wire-in is at command scope** (`cmd_push`/`cmd_pull`), via `push_external_files` / `pull_external_files`, **not** inside `push_dir_to_project` / `pull_project_to_dir` (those loop per-workspace and `pull_project_to_dir` uses stdout as a data channel). -- **Storage:** blobs live in `$SECRETS_DIR//external/.gradle-properties.age`. The `external/` subdir keeps them out of the existing non-recursive `*.age` / `.*.age` globs (pull, list, rekey), so the old dotenv path can never decrypt a blob into cwd. `cmd_rekey` and `cmd_list` recurse into `external/` explicitly (rekey MUST, or the blob is orphaned after rotation = data loss). `` = manifest path token with non-`[A-Za-z0-9._-]` chars → `_`, so it's machine-independent. -- **Merge is pure bash, no `sed`/regex** (`merge_gradle_keys`): exact-string key comparison (avoids `beaconClerkPk` vs `beaconClerkPkTest` substring bug), value treated as opaque literal (survives `& \ /` in values). Updates a managed key in place at its first occurrence, collapses duplicates, appends new keys, preserves unrelated lines/comments/order. Continuation lines (trailing odd backslashes, tracked by `_trailing_bs_odd`) are never matched as keys. Atomic write: temp in the same dir → `chmod` to match (or `600` on create) → `mv`. Backs up to `.secrets-bak` before the first merge. +- **Storage:** blobs live in `$SECRETS_DIR//external/.gradle-properties.age`. The `external/` subdir keeps them out of the existing non-recursive `*.age` / `.*.age` globs (pull, list, rekey), so the old dotenv path can never decrypt a blob into cwd. `cmd_rekey` and `cmd_list` recurse into `external/` explicitly (rekey MUST, or the blob is orphaned after rotation = data loss). `` = manifest path token with non-`[A-Za-z0-9._-]` chars → `_`, plus a `cksum` suffix of the original path so paths that clean to the same string (`a/b` vs `a_b`) don't collide. Machine-independent (derived from the committed manifest token, not the expanded path). +- **Merge is pure bash, no `sed`/regex** (`merge_gradle_keys`): exact-string key comparison (avoids `beaconClerkPk` vs `beaconClerkPkTest` substring bug), value treated as opaque literal (survives `& \ /` in values). Updates a managed key in place at its first occurrence, collapses duplicates, appends new keys, preserves unrelated lines/comments/order. Continuation lines (trailing odd backslashes, tracked by `_trailing_bs_odd`) are never matched as keys. Atomic write: temp in the same dir → `chmod` to match (or `600` on create) → `mv`. Backs up to `.secrets-bak` before each merge. - **Properties separator parsing** (`_props_get`): key ends at the first `=`, `:`, or whitespace (after lstrip); handles `key=value`, `key = value`, `key:value`, `key value`; last definition wins. - **Security:** the write target comes from a committed file, so `_validate_external_target_path` locks it down — basename must be `gradle.properties`, must resolve inside `$HOME` (deepest-existing-ancestor resolved, symlink target/parent refused, `..` rejected). This blocks a malicious manifest from appending decrypted keys to `~/.gitconfig`/`~/.bashrc`. `_parse_secrets_files_manifest` rejects shell metacharacters/control chars in path and keys (path allows `[A-Za-z0-9/._~-]` only; keys allow `[A-Za-z0-9._-]` + space), mirrors the `.secrets-store` posture (no shell expansion, symlinked manifest skipped). - **Plaintext tradeoff (accepted, documented):** merged keys are permanent plaintext in the target; `secrets clear` does not remove them. Fine for the Clerk *publishable* keys this was built for; not for high-value secrets (use `secrets run` + `.env`). diff --git a/README.md b/README.md index ded1889..2e23b41 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,7 @@ secrets pull # ==> Merged 2 key(s) into ~/.gradle/gradle.properties (beaconClerkPkTest, beaconClerkPkLive) ``` -`secrets pull` merges those keys into the local `~/.gradle/gradle.properties`, leaving every other key untouched. If a managed key already exists, its value is updated in place; comments, ordering, and unrelated entries are preserved. The file is backed up to `gradle.properties.secrets-bak` before the first merge. +`secrets pull` merges those keys into the local `~/.gradle/gradle.properties`, leaving every other key untouched. If a managed key already exists, its value is updated in place; comments, ordering, and unrelated entries are preserved. The file is backed up to `gradle.properties.secrets-bak` before each merge. > **Note:** unlike `.env` files, merged Gradle keys are written as **permanent plaintext** into the target file — `secrets clear` does **not** remove them. This is appropriate for publishable / low-secrecy values (like Clerk publishable keys, `pk_*`). For high-value secrets that should never sit on disk, use `secrets run` with a `.env` instead. @@ -432,7 +432,7 @@ For complete rotation with no historical exposure, create a fresh `~/.secrets/` ## Development ```bash -# Run the test suite (112 tests) +# Run the test suite (113 tests) brew install bats-core bats test/secrets.bats ``` diff --git a/secrets b/secrets index d47ba01..76eb7c4 100755 --- a/secrets +++ b/secrets @@ -464,9 +464,14 @@ _props_get() { } # Turn a manifest path token into a machine-independent blob slug. +# A bare char-replace would collide (e.g. a/b and a_b both → a_b), so append a +# checksum of the original path to keep distinct targets' blobs distinct. _secrets_files_slug() { local p="$1" - printf '%s' "${p//[!A-Za-z0-9._-]/_}" + local clean="${p//[!A-Za-z0-9._-]/_}" + local sum + sum=$(printf '%s' "$p" | cksum | cut -d' ' -f1) + printf '%s-%s' "$clean" "$sum" } # Merge managed key=value lines (from $2) into target file $1, preserving @@ -569,8 +574,16 @@ push_external_files() { local found=0 k v for k in $mkeys; do if v=$(_props_get "$expanded" "$k"); then - printf '%s=%s\n' "$k" "$v" >> "$tmp" - found=$((found + 1)) + if _trailing_bs_odd "$v"; then + # A trailing odd backslash means a multi-line (continuation) value. + # We only support single-line values; syncing this would write a + # dangling backslash that turns the next target line into a + # continuation and corrupts the file. Skip it loudly. + echo "WARNING: key '$k' in $mpath has a multi-line (continuation) value — not supported, skipping." >&2 + else + printf '%s=%s\n' "$k" "$v" >> "$tmp" + found=$((found + 1)) + fi else echo "WARNING: key '$k' not found in $mpath — not synced. Set it locally first, or remove it from $SECRETS_FILES_NAME." >&2 fi @@ -622,17 +635,21 @@ pull_external_files() { rm -f "$tmp" die "Decryption failed for external target $mpath." fi - local k + local k k_esc for k in $mkeys; do - grep -q "^$k=" "$tmp" 2>/dev/null || echo "WARNING: '$k' listed in $SECRETS_FILES_NAME but absent from synced data for $mpath." >&2 + # Escape regex-special '.' so e.g. systemProp.foo can't match systemPropXfoo. + k_esc=$(printf '%s' "$k" | sed 's/\./\\./g') + grep -q "^$k_esc=" "$tmp" 2>/dev/null || echo "WARNING: '$k' listed in $SECRETS_FILES_NAME but absent from synced data for $mpath." >&2 done if [ ! -d "$(dirname "$expanded")" ]; then echo "WARNING: creating $(dirname "$expanded")" >&2 fi - local count; count=$(grep -c '=' "$tmp" 2>/dev/null || echo 0) + local count; count=$(grep -c '=' "$tmp" 2>/dev/null || true); count=${count:-0} if merge_gradle_keys "$expanded" "$tmp"; then local klist; klist=$(printf '%s' "$mkeys" | tr ' ' ',' | sed 's/,/, /g') info "Merged $count key(s) into $expanded ($klist)" + else + echo "WARNING: failed to merge keys into $expanded — target left unchanged." >&2 fi rm -f "$tmp" done < <(_parse_secrets_files_manifest "$manifest") diff --git a/test/secrets.bats b/test/secrets.bats index bd95488..2119c40 100644 --- a/test/secrets.bats +++ b/test/secrets.bats @@ -1451,6 +1451,18 @@ gradle_project() { [[ "$output" == *"symlink"* ]] } +@test "EGB-531: push skips a multi-line (continuation) managed value with a warning" { + init_with_remote + mkdir -p "$HOME/.gradle" + # beaconClerkPkTest has a continuation value (trailing backslash); Live is single-line + printf '%s' $'beaconClerkPkTest=part1\\\npart2\nbeaconClerkPkLive=fine\n' > "$HOME/.gradle/gradle.properties" + gradle_project gproj + run "$SECRETS_BIN" push gproj + [ "$status" -eq 0 ] + [[ "$output" == *"multi-line"* ]] + [[ "$output" == *"Extracted 1 key"* ]] +} + @test "EGB-531: push skips comment and continuation lines in source" { init_with_remote mkdir -p "$HOME/.gradle"