Merge pull request 'brian/egb-531-secrets-support-gradleproperties-files-not-just-dotenv' (#1) from brian/egb-531-secrets-support-gradleproperties-files-not-just-dotenv into main
Reviewed-on: https://codeberg.org/egbt/secrets/pulls/1
This commit is contained in:
commit
92db25a184
7 changed files with 960 additions and 13 deletions
25
CHANGELOG.md
25
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/),
|
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.
|
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 `<project>/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 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.
|
||||||
|
|
||||||
|
### 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 `<project>/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 → 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
|
## [0.1.1.0] - 2026-05-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
21
CLAUDE.md
21
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)
|
- Encryption: `age` with key files (not passphrases — age passphrases are non-scriptable)
|
||||||
- Storage: Private git repo at `~/.secrets/`
|
- Storage: Private git repo at `~/.secrets/`
|
||||||
- Convention: Tracks `.env`, `.env.*`, and `.dev.vars` (not `.envrc`, `.environment-*`)
|
- 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`
|
- 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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
secrets # CLI script (~300 lines bash)
|
secrets # CLI script (~600 lines bash)
|
||||||
hooks/pre-commit # Pre-commit hook template
|
hooks/pre-commit # Pre-commit hook template
|
||||||
test/
|
test/
|
||||||
secrets.bats # bats-core test suite (25 tests)
|
secrets.bats # bats-core test suite (113 tests)
|
||||||
test_helper.bash # Shared setup/teardown
|
test_helper.bash # Shared setup/teardown
|
||||||
README.md # User-facing documentation
|
README.md # User-facing documentation
|
||||||
CLAUDE.md # This file
|
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 <url> <path>` 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 `<their-store-remote>` 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 `<spec>\t<url>`; `_find_secrets_store_file` returns `<dir>\t<source-path>\t<url>`; `resolve_store` splits the 3-tuple via `IFS=$'\t' read -r ...`.
|
`.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 <url> <path>` 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 `<their-store-remote>` 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 `<spec>\t<url>`; `_find_secrets_store_file` returns `<dir>\t<source-path>\t<url>`; `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: `<type> <path> <key>...`. 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/<project>/external/<slug>.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). `<slug>` = 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 `<target>.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`).
|
||||||
|
|
||||||
## Deploy Configuration
|
## Deploy Configuration
|
||||||
|
|
||||||
- Platform: NONE (distributed via `git clone` from GitHub)
|
- Platform: NONE (distributed via `git clone` from GitHub)
|
||||||
|
|
|
||||||
45
README.md
45
README.md
|
|
@ -54,6 +54,8 @@ flowchart TD
|
||||||
|
|
||||||
Files like `.envrc` (direnv) and `.environment-*` are intentionally **not** tracked.
|
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
|
## Prerequisites
|
||||||
|
|
||||||
- **macOS** (uses Homebrew for installation)
|
- **macOS** (uses Homebrew for installation)
|
||||||
|
|
@ -346,6 +348,47 @@ Inside `~/.secrets/`, workspace secrets are organized by path:
|
||||||
|
|
||||||
Requires `jq` (`brew install jq`).
|
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:
|
||||||
|
|
||||||
|
```
|
||||||
|
# <type> <path> <keys...>
|
||||||
|
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 `<project>/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 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.
|
||||||
|
|
||||||
## Safety features
|
## Safety features
|
||||||
|
|
||||||
- **`secrets run` auto-clears** — plaintext files are deleted when the command exits, errors, or is interrupted with Ctrl-C
|
- **`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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run the test suite (37 tests)
|
# Run the test suite (113 tests)
|
||||||
brew install bats-core
|
brew install bats-core
|
||||||
bats test/secrets.bats
|
bats test/secrets.bats
|
||||||
```
|
```
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.1.1.0
|
0.2.0.0
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Pre-commit hook for the secrets repo.
|
# 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).
|
# 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
|
if [ -n "$BLOCKED" ]; then
|
||||||
echo "ERROR: Plaintext secret files staged for commit:"
|
echo "ERROR: Plaintext secret files staged for commit:"
|
||||||
echo "$BLOCKED"
|
echo "$BLOCKED"
|
||||||
|
|
|
||||||
444
secrets
444
secrets
|
|
@ -319,6 +319,342 @@ collect_env_files() {
|
||||||
[ ${#COLLECTED_FILES[@]} -gt 0 ]
|
[ ${#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
|
||||||
|
# <type> <path> <key> [key...]
|
||||||
|
#
|
||||||
|
# push extracts only the named keys → encrypts a subset blob under
|
||||||
|
# <project>/external/. pull decrypts it and MERGES those keys into <path>,
|
||||||
|
# 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 "<type>\t<path>\t<keys>" 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 '<type> <path> <key> [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.
|
||||||
|
# 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"
|
||||||
|
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
|
||||||
|
# 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 <root>/.secrets-files into
|
||||||
|
# <project>/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
|
||||||
|
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
|
||||||
|
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 <root>/.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 k_esc
|
||||||
|
for k in $mkeys; do
|
||||||
|
# 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 || 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")
|
||||||
|
}
|
||||||
|
|
||||||
# Read package.json workspaces and expand globs to actual directories.
|
# Read package.json workspaces and expand globs to actual directories.
|
||||||
# Prints one workspace path per line (relative to the monorepo root).
|
# Prints one workspace path per line (relative to the monorepo root).
|
||||||
get_workspaces() {
|
get_workspaces() {
|
||||||
|
|
@ -353,7 +689,7 @@ install_hook() {
|
||||||
# Inline hook if template not found (e.g. secrets installed standalone)
|
# Inline hook if template not found (e.g. secrets installed standalone)
|
||||||
cat > "$hook_dst" << 'HOOKEOF'
|
cat > "$hook_dst" << 'HOOKEOF'
|
||||||
#!/usr/bin/env bash
|
#!/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
|
if [ -n "$BLOCKED" ]; then
|
||||||
echo "ERROR: Plaintext secret files staged for commit:"
|
echo "ERROR: Plaintext secret files staged for commit:"
|
||||||
echo "$BLOCKED"
|
echo "$BLOCKED"
|
||||||
|
|
@ -480,8 +816,11 @@ cmd_push() {
|
||||||
local pubkey
|
local pubkey
|
||||||
pubkey=$(get_pubkey)
|
pubkey=$(get_pubkey)
|
||||||
|
|
||||||
if ! push_dir_to_project "$PWD" "$project" "$pubkey"; then
|
local did=0
|
||||||
die "No secret files (.env, .env.*, .dev.vars) found in $PWD"
|
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
|
fi
|
||||||
|
|
||||||
commit_and_push_secrets "update $project"
|
commit_and_push_secrets "update $project"
|
||||||
|
|
@ -522,6 +861,12 @@ cmd_push_workspaces() {
|
||||||
fi
|
fi
|
||||||
done <<< "$workspaces"
|
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
|
if [ "$total" -eq 0 ]; then
|
||||||
die "No secret files found in any workspace"
|
die "No secret files found in any workspace"
|
||||||
fi
|
fi
|
||||||
|
|
@ -569,6 +914,9 @@ cmd_pull() {
|
||||||
|
|
||||||
info "Decrypted $count file(s) into $target_dir"
|
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
|
# Reinstall hook if missing
|
||||||
if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then
|
if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then
|
||||||
install_hook
|
install_hook
|
||||||
|
|
@ -644,6 +992,12 @@ cmd_pull_workspaces() {
|
||||||
fi
|
fi
|
||||||
done <<< "$workspaces"
|
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
|
if [ "$total" -eq 0 ]; then
|
||||||
die "No secrets found for any workspace in $monorepo_name"
|
die "No secrets found for any workspace in $monorepo_name"
|
||||||
fi
|
fi
|
||||||
|
|
@ -674,6 +1028,14 @@ cmd_list() {
|
||||||
echo " $(basename "$f" .age)"
|
echo " $(basename "$f" .age)"
|
||||||
found=1
|
found=1
|
||||||
done
|
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
|
done
|
||||||
|
|
||||||
if [ "$found" -eq 0 ]; then
|
if [ "$found" -eq 0 ]; then
|
||||||
|
|
@ -720,7 +1082,10 @@ cmd_rekey() {
|
||||||
# Create temp dir with cleanup trap
|
# Create temp dir with cleanup trap
|
||||||
local tmpdir
|
local tmpdir
|
||||||
tmpdir=$(mktemp -d)
|
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..."
|
info "Decrypting all files with current key..."
|
||||||
|
|
||||||
|
|
@ -741,6 +1106,20 @@ cmd_rekey() {
|
||||||
fi
|
fi
|
||||||
file_count=$((file_count + 1))
|
file_count=$((file_count + 1))
|
||||||
done
|
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
|
done
|
||||||
|
|
||||||
if [ "$file_count" -eq 0 ]; then
|
if [ "$file_count" -eq 0 ]; then
|
||||||
|
|
@ -749,8 +1128,12 @@ cmd_rekey() {
|
||||||
|
|
||||||
info "Decrypted $file_count file(s). Generating new key pair..."
|
info "Decrypted $file_count file(s). Generating new key pair..."
|
||||||
|
|
||||||
# Generate new key (overwrites old)
|
# Generate the new key. age-keygen refuses to overwrite an existing
|
||||||
age-keygen -o "$KEY_FILE" 2>&1
|
# 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
|
local pubkey
|
||||||
pubkey=$(get_pubkey)
|
pubkey=$(get_pubkey)
|
||||||
|
|
||||||
|
|
@ -768,6 +1151,15 @@ cmd_rekey() {
|
||||||
name=$(basename "$f")
|
name=$(basename "$f")
|
||||||
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
|
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
|
||||||
done
|
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
|
done
|
||||||
|
|
||||||
# Commit and push
|
# Commit and push
|
||||||
|
|
@ -787,6 +1179,11 @@ cmd_rekey() {
|
||||||
echo ""
|
echo ""
|
||||||
echo "WARNING: Old ciphertext remains in git history."
|
echo "WARNING: Old ciphertext remains in git history."
|
||||||
echo "For full rotation, create a fresh repo."
|
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() {
|
cmd_clear() {
|
||||||
|
|
@ -893,6 +1290,22 @@ cmd_which() {
|
||||||
resolve_store
|
resolve_store
|
||||||
echo "store: $SECRETS_DIR"
|
echo "store: $SECRETS_DIR"
|
||||||
echo "source: $STORE_SOURCE"
|
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() {
|
cmd_help() {
|
||||||
|
|
@ -946,6 +1359,25 @@ Workspaces:
|
||||||
<monorepo>/<workspace-path>/ in the secrets repo. Root secret files
|
<monorepo>/<workspace-path>/ in the secrets repo. Root secret files
|
||||||
are stored under <monorepo>/ directly. Requires jq.
|
are stored under <monorepo>/ 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:
|
||||||
|
|
||||||
|
# <type> <path> <keys...>
|
||||||
|
gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
|
||||||
|
|
||||||
|
On push, the named keys are extracted from <path> and encrypted under
|
||||||
|
<project>/external/ in the store. On pull, they are MERGED back into
|
||||||
|
<path>, 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:
|
Environment:
|
||||||
SECRETS_DIR Path to secrets repo (default: ~/.secrets). See also
|
SECRETS_DIR Path to secrets repo (default: ~/.secrets). See also
|
||||||
the .secrets-store file and --store flag above.
|
the .secrets-store file and --store flag above.
|
||||||
|
|
|
||||||
|
|
@ -1045,3 +1045,434 @@ PKG
|
||||||
[ "$status" -eq 1 ]
|
[ "$status" -eq 1 ]
|
||||||
[[ "$output" == *"git+ssh://user@host:2222/path/to-repo_v2.git"* ]]
|
[[ "$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"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 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 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"
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue