From 7c3a76e8c1e12988b567a7c946e0bead9eeff6b9 Mon Sep 17 00:00:00 2001 From: Brian Majewski Date: Sat, 9 May 2026 14:54:45 -0700 Subject: [PATCH] v0.1.1.0 feat: optional remote URL in .secrets-store (EGB-282) (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: ignore .gstack/ (per-project local state) * feat: optional remote URL in .secrets-store (EGB-282) A second whitespace-separated token after the store name in .secrets-store is treated as the store's git remote URL. When a teammate clones a project bound to a store they don't have locally yet, the directed missing-store error now fills in `git clone ` so they can copy-paste instead of asking the original setter for the URL. Backward compatible: single-token .secrets-store files (the v0.1.0.x format) continue to work and produce the existing `` placeholder. Security hardening (caught by adversarial review during /ship): - 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 `rm -rf ~` on paste. The parser now rejects URLs containing shell metacharacters (;&|<>$`(){}*?!"'\\), control characters (incl. ANSI escape sequences that could spoof terminal output), and embedded whitespace. Rejected URLs are dropped with a stderr warning; the error falls back to the safe placeholder. - Switched from `set -- $line` to `read -r spec rest` so the URL field isn't glob-expanded or word-split — important so `work *` from a populated directory doesn't leak filenames into the URL field. Tests 72 → 80. New: backward compat, SSH+HTTPS+~/-prefix URL forms, comment-and-URL form, four named injection vectors (shell metachar, backtick, $(), ANSI escape), multi-token URL, glob char, and a positive test asserting standard git URL chars round-trip unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: bump version and changelog (v0.1.1.0) Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .gitignore | 3 + CHANGELOG.md | 20 ++++++ CLAUDE.md | 2 +- README.md | 18 +++-- VERSION | 2 +- secrets | 100 +++++++++++++++++++++++---- test/secrets.bats | 171 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 296 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 3dcc88e..9b0662b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ # Editor *.swp *~ + +# Per-project gstack state (deploy reports, brain caches, etc.) +.gstack/ diff --git a/CHANGELOG.md b/CHANGELOG.md index dc0f181..32d40f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ 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.1.1.0] - 2026-05-09 + +### Added + +- **Optional git remote URL in `.secrets-store`.** Add a second whitespace-separated token after the store name to give teammates a copy-paste-ready clone command: + ``` + work git@github.com:acme/work-secrets.git + ``` + When a teammate clones a project bound to a store they don't have on their machine yet, the directed error now fills in the actual `git clone ` line — they no longer have to ask the original setter for the URL. The URL is optional; existing single-token `.secrets-store` files continue to work and show the `` placeholder as before. (EGB-282) + +### Security + +- **Hardened `.secrets-store` URL parser against copy-paste shell injection.** The URL is rendered into a `git clone` line that a teammate is likely to copy-paste from the directed error. Without sanitization, a malicious `.secrets-store` containing `work evil.git;rm -rf ~` would render verbatim and execute `rm -rf ~` on paste. The parser now rejects URLs containing shell metacharacters (`;&|<>$\`(){}*?!"'\\`), control characters (including ANSI escape sequences that could spoof terminal output), and embedded whitespace. Rejected URLs are dropped with a stderr warning; the directed error falls back to the safe placeholder. Found by adversarial review during /ship; verified with regression tests for every named attack vector. +- **Switched URL parsing from `set -- $line` to `read -r spec rest`.** The previous form word-split *and* glob-expanded — `work *` from a populated directory would have leaked filenames into the URL field. The new form preserves the rest of the line verbatim into a single variable, so glob characters and internal whitespace are noticed by the sanitizer instead of silently expanded. + +### Tests + +- 72 → 80 (+8). New coverage: backward-compat single-token form, two-token URL form (SSH, HTTPS, `~/`-prefixed), comment-and-URL form, copy-paste injection (rm -rf payload), backtick injection, `$()` injection, ANSI escape injection, multi-token URL, glob-character URL, and a positive test asserting standard git URL chars (`-`, `+`, `_`, `:`, `/`, `@`, `.`) round-trip unchanged. + ## [0.1.0.1] - 2026-05-09 ### Fixed @@ -42,5 +61,6 @@ and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme. - 37 → 66 tests. New coverage: store resolution rules and precedence, walk-up boundaries, command-injection prevention, key-file re-derivation across stores, teammate-onboarding error path, monorepo workspace binding, F1–F5 adversarial regressions. +[0.1.1.0]: https://github.com/bmajewski/secrets/releases/tag/v0.1.1.0 [0.1.0.1]: https://github.com/bmajewski/secrets/releases/tag/v0.1.0.1 [0.1.0.0]: https://github.com/bmajewski/secrets/releases/tag/v0.1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 20fb973..6f9d9ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ The active store directory is picked by `resolve_store()` using these rules, hig `resolve_store` mutates BOTH `SECRETS_DIR` and `KEY_FILE` so the existing single-store code paths just work. `STORE_SOURCE` reports which rule won. `_LAST_FOUND_AT` (when rule 2 fires) holds the path of the file that was read. -`.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`. +`.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 ...`. ## Deploy Configuration diff --git a/README.md b/README.md index ef3a226..060b45d 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,14 @@ git add .secrets-store git commit -m "use work secrets store" ``` +For teammates who haven't set up the store yet, you can include the store's git remote URL on the same line so they don't have to ask you for it: + +```bash +echo "work git@github.com:acme/work-secrets.git" > .secrets-store +``` + +That second token (whitespace-separated) is optional, ignored when the store already exists locally, and used as a copy-paste-ready hint in the missing-store error message when it doesn't. See "Joining a teammate's bound project" below. + After that, every `secrets push` / `secrets pull` from this project (or any subdirectory) automatically uses `~/.secrets-work`. Teammates who clone the project get the same binding for free — the file is in the repo. When you push or pull from a non-default store, `secrets` echoes which one is active so you can spot mistakes immediately: @@ -278,7 +286,9 @@ When you push or pull from a non-default store, `secrets` echoes which one is ac #### Joining a teammate's bound project -If you clone a project that has a committed `.secrets-store: work` file but you don't have `~/.secrets-work` set up locally, `secrets pull` will tell you exactly what to do: +If you clone a project that has a committed `.secrets-store: work` file but you don't have `~/.secrets-work` set up locally, `secrets pull` will tell you exactly what to do. + +When the original setter included the remote URL in `.secrets-store` (recommended), the error fills in the actual `git clone` command for you to copy-paste: ``` ERROR: Store not initialized: /Users/you/.secrets-work @@ -286,16 +296,16 @@ ERROR: Store not initialized: /Users/you/.secrets-work This path doesn't exist on this machine yet. If you're joining a teammate's existing store: - git clone /Users/you/.secrets-work + git clone git@github.com:acme/work-secrets.git /Users/you/.secrets-work # then copy their key.txt to /Users/you/.secrets-work/key.txt If you want a fresh new store at this path: secrets --store /Users/you/.secrets-work init ``` -You'll need two things from the teammate who set it up: +If the URL wasn't in the file, the error shows `` as a placeholder — you'll need to ask the teammate who set it up. Either way, you need two things to finish onboarding: -1. **The git remote URL** of the work-secrets repo — clone it to `~/.secrets-work` (or wherever the `.secrets-store` file resolves to on your machine). +1. **The git remote URL** of the work-secrets repo — clone it to `~/.secrets-work` (or wherever the `.secrets-store` file resolves to on your machine). The `.secrets-store` file may already include this for you. 2. **The age key file** (`key.txt`) — same as standing up any new machine. AirDrop, scp, or USB. Once both are in place, `secrets pull` works. diff --git a/VERSION b/VERSION index 4180912..5cff5f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0.1 +0.1.1.0 diff --git a/secrets b/secrets index 942eeef..f33309a 100755 --- a/secrets +++ b/secrets @@ -24,6 +24,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" STORE_SOURCE="default" # Path to the .secrets-store file that won resolution, if any. _LAST_FOUND_AT="" +# Remote URL parsed from the active .secrets-store file (optional 2nd token). +# Used by check_initialized to fill in a runnable `git clone ` +# in the missing-store error so teammates don't have to ask. +_REMOTE_URL="" # --store flag value, captured by the pre-pass. STORE_OVERRIDE="" @@ -41,12 +45,21 @@ check_initialized() { return fi if [ "$STORE_SOURCE" != "default" ]; then + # Fill in the remote URL when .secrets-store provided one, so the + # teammate can copy-paste the clone command without asking the original + # setter for the URL. Falls back to a placeholder otherwise. + local clone_url + if [ -n "${_REMOTE_URL:-}" ]; then + clone_url="$_REMOTE_URL" + else + clone_url="" + fi die "Store not initialized: $SECRETS_DIR Resolved from: $STORE_SOURCE This path doesn't exist on this machine yet. If you're joining a teammate's existing store: - git clone $SECRETS_DIR + git clone $clone_url $SECRETS_DIR # then copy their key.txt to $SECRETS_DIR/key.txt If you want a fresh new store at this path: @@ -100,10 +113,20 @@ derive_project_name() { # 4. $HOME/.secrets default # # .secrets-store file format: first non-empty non-comment line is the store -# spec. Spec is one of: +# spec, optionally followed by a remote URL on the same line. Spec is one of: # - absolute path (/Users/you/.secrets-work) # - ~/path (expanded to $HOME/path) # - bare name (e.g. "work" → $HOME/.secrets-work; "default" → $HOME/.secrets) +# +# The optional second token (whitespace-separated) is a git remote URL. It +# is only used as a hint when the store directory does not yet exist on +# the current machine — the missing-store error includes a runnable +# `git clone ` for the teammate to copy. Examples: +# +# work +# work git@github.com:acme/work-secrets.git +# ~/.secrets-work https://github.com/acme/work-secrets.git +# # Comments (#) and CRLF line endings are tolerated. NO shell expansion is # applied to the file content — `$VAR`, `$(...)`, and backticks are read as # literal characters to prevent code injection from a committed file. @@ -136,8 +159,21 @@ _expand_store_path() { esac } -# Read a .secrets-store file. Print the first non-empty non-comment line, -# trimmed. Return 1 if no usable line is found. +# Read a .secrets-store file. On success, print "\t" +# (URL empty if not provided or rejected as unsafe). The first non-empty +# non-comment line is the active line; the first whitespace splits it into +# the spec and an optional URL. +# +# Security: the URL is later substituted into a copy-paste-ready `git clone` +# command in the missing-store error. An attacker who slips a malicious line +# into a committed .secrets-store could weaponize that copy-paste — e.g. +# `work evil.git;rm -rf ~` would render as `git clone evil.git;rm -rf ~ ...` +# and a teammate following the directed error would execute the payload. +# We reject URLs containing shell metacharacters, control characters, ANSI +# escapes, and embedded whitespace. Rejected URLs are dropped silently from +# the parser's perspective (a warning is printed to stderr); the resolver +# falls back to the `` placeholder so the directed +# error stays useful without rendering the attacker-controlled string. _parse_secrets_store_file() { local file="$1" local line @@ -150,7 +186,25 @@ _parse_secrets_store_file() { line="${line%"${line##*[![:space:]]}"}" [ -z "$line" ] && continue case "$line" in '#'*) continue ;; esac - printf '%s\n' "$line" + # Split into spec (first whitespace-separated token) and url (rest of + # the line, verbatim). `read -r` does NOT glob-expand and preserves the + # tail in $rest as a single string — important so `work *` doesn't + # silently expand to `work file1 file2 ...`. + local spec="" rest="" + read -r spec rest <<< "$line" + local url="$rest" + if [ -n "$url" ]; then + # Reject URLs containing characters that could weaponize a + # copy-paste shell command, terminal escapes, or be ambiguous. + # Match order: control chars (incl. ESC \x1b), shell meta, whitespace. + case "$url" in + *[[:cntrl:]]*|*[\;\&\|\<\>\$\`\(\)\{\}\*\?\!\"\'\\]*|*' '*|*" "*) + echo "WARNING: $file: dropping unsafe characters in remote URL hint" >&2 + url="" + ;; + esac + fi + printf '%s\t%s\n' "$spec" "$url" return 0 done < "$file" return 1 @@ -159,8 +213,9 @@ _parse_secrets_store_file() { # Walk up from cwd looking for .secrets-store. Bounded by $HOME — never # walks INTO or PAST $HOME. If cwd is outside $HOME entirely (e.g. /tmp), # the walk does not run. Symlinks are resolved with `cd -P`. -# On success: prints "\t" and returns 0. -# The caller (resolve_store) splits the tab-separated tuple. We can't set a +# On success: prints "\t\t" +# (URL empty if .secrets-store didn't include one) and returns 0. The caller +# (resolve_store) splits the tab-separated 3-tuple. We can't set a # parent-shell variable from here because we're typically called inside # `$(...)` command substitution, which runs in a subshell. _find_secrets_store_file() { @@ -182,11 +237,14 @@ _find_secrets_store_file() { if [ -L "$dir/.secrets-store" ]; then : elif [ -f "$dir/.secrets-store" ]; then - local content - if content=$(_parse_secrets_store_file "$dir/.secrets-store"); then + local parsed + if parsed=$(_parse_secrets_store_file "$dir/.secrets-store"); then + # parsed is "\t" (URL may be empty) + local spec="${parsed%%$'\t'*}" + local url="${parsed#*$'\t'}" local expanded - expanded=$(_expand_store_path "$content") - printf '%s\t%s\n' "$expanded" "$dir/.secrets-store" + expanded=$(_expand_store_path "$spec") + printf '%s\t%s\t%s\n' "$expanded" "$dir/.secrets-store" "$url" return 0 fi fi @@ -198,18 +256,25 @@ _find_secrets_store_file() { # Resolve the active store directory and update SECRETS_DIR + KEY_FILE. # Sets STORE_SOURCE to one of: # "--store flag" | ".secrets-store file ()" | "SECRETS_DIR env var" | "default" +# Also sets _REMOTE_URL to the optional remote URL parsed from .secrets-store +# (empty when not present). check_initialized uses _REMOTE_URL to fill in a +# copy-paste-ready `git clone` command for teammates whose store doesn't +# exist yet. resolve_store() { local resolved="" local source="" + _REMOTE_URL="" if [ -n "${STORE_OVERRIDE:-}" ]; then resolved=$(_expand_store_path "$STORE_OVERRIDE") source="--store flag" _LAST_FOUND_AT="" elif _find_result=$(_find_secrets_store_file); then - # _find_secrets_store_file returns "\t" - resolved="${_find_result%%$'\t'*}" - _LAST_FOUND_AT="${_find_result#*$'\t'}" + # _find_secrets_store_file returns "\t\t" + # IFS=$'\t' prefix is scoped to this single `read` builtin — no manual + # save/restore needed. URL field is empty when .secrets-store didn't + # include a URL or when it was rejected as unsafe. + IFS=$'\t' read -r resolved _LAST_FOUND_AT _REMOTE_URL <<< "$_find_result" source=".secrets-store file ($_LAST_FOUND_AT)" elif [ -n "${_USER_SECRETS_DIR:-}" ]; then resolved="$_USER_SECRETS_DIR" @@ -868,6 +933,13 @@ Stores: Bare names ("work") expand to ~/.secrets-work. The name "default" resolves to ~/.secrets. Run `secrets which` to inspect the active store. + Optional .secrets-store URL hint: + A second whitespace-separated token on the line is treated as the + store's git remote URL. It is used to fill in a runnable `git clone + ` in the missing-store error so teammates joining the + project don't have to ask for the URL. Example: + work git@github.com:acme/work-secrets.git + Workspaces: With -w/--workspaces, reads package.json "workspaces" field to find workspace directories. Each workspace's secret files are stored under diff --git a/test/secrets.bats b/test/secrets.bats index 9039fa1..72f0b88 100644 --- a/test/secrets.bats +++ b/test/secrets.bats @@ -874,3 +874,174 @@ PKG [ "$status" -ne 0 ] [[ "$output" == *"HOME"* ]] } + +# ─── EGB-282: optional remote URL in .secrets-store ────────────────── + +@test "EGB-282: .secrets-store without URL still works (backward compat)" { + unset SECRETS_DIR + mkdir -p "$HOME/.secrets-work" + mkdir -p "$WORK_DIR/proj" + # Single token only — same as v0.1.0.0 format + echo "work" > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + [[ "$output" == *"$HOME/.secrets-work"* ]] +} + +@test "EGB-282: .secrets-store with URL parses both tokens" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/proj" + echo "work git@github.com:acme/work-secrets.git" > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + # Store doesn't exist yet — pull should fail with the directed error + # that includes the actual URL (not the placeholder). + run "$SECRETS_BIN" pull + [ "$status" -eq 1 ] + [[ "$output" == *"git clone git@github.com:acme/work-secrets.git $HOME/.secrets-work"* ]] + # Placeholder must NOT appear when a real URL was supplied + [[ "$output" != *""* ]] +} + +@test "EGB-282: missing-store error still works without URL (placeholder)" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/proj" + echo "missing-only" > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" pull + [ "$status" -eq 1 ] + # No URL given — placeholder is the right behavior. + [[ "$output" == *""* ]] +} + +@test "EGB-282: https URL is preserved literally" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/proj" + echo "work https://github.com/acme/work-secrets.git" > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" pull + [ "$status" -eq 1 ] + [[ "$output" == *"https://github.com/acme/work-secrets.git"* ]] +} + +@test "EGB-282: ~/-prefixed path with URL works" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/proj" + printf '~/.secrets-x git@github.com:acme/x.git\n' > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" pull + [ "$status" -eq 1 ] + [[ "$output" == *"git clone git@github.com:acme/x.git $HOME/.secrets-x"* ]] +} + +@test "EGB-282: comments before URL line are still skipped" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/proj" + printf '# this binding was set by alice\n# please do not delete\nwork git@github.com:acme/work-secrets.git\n' > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" pull + [ "$status" -eq 1 ] + [[ "$output" == *"git clone git@github.com:acme/work-secrets.git"* ]] +} + +# ─── EGB-282 adversarial regressions: URL injection prevention ──────── + +@test "EGB-282 SECURITY: URL with shell metachars is dropped (rm -rf payload)" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/proj" + # The classic copy-paste shell injection: a `;` after the "URL" splits + # the rendered git clone into two commands, the second of which is the + # attacker payload. The teammate copy-pasting the directed-error one-liner + # would execute `rm -rf ~`. The parser must reject this. + printf 'work evil.git;rm -rf ~\n' > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" pull + [ "$status" -eq 1 ] + # Must use the placeholder, NOT the attacker URL + [[ "$output" == *""* ]] + [[ "$output" != *"rm -rf"* ]] + # And must have warned the user that something was dropped + [[ "$output" == *"WARNING"* ]] + [[ "$output" == *"unsafe"* ]] +} + +@test "EGB-282 SECURITY: URL with backticks is dropped" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/proj" + printf 'work evil.git`whoami`\n' > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" pull + [ "$status" -eq 1 ] + [[ "$output" == *""* ]] +} + +@test "EGB-282 SECURITY: URL with command substitution \$() is dropped" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/proj" + printf 'work evil.git$(whoami)\n' > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" pull + [ "$status" -eq 1 ] + [[ "$output" == *""* ]] +} + +@test "EGB-282 SECURITY: URL with ANSI escape is dropped (terminal-spoof prevention)" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/proj" + # ESC ([ \x1b) lets a malicious URL render differently from what gets pasted + printf 'work evil.git\x1b[2K\r\n' > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" pull + [ "$status" -eq 1 ] + [[ "$output" == *""* ]] +} + +@test "EGB-282 SECURITY: multi-token URL ('work url1 url2') is dropped" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/proj" + # `git clone url1 url2 /path` would clone url1 into directory `url2` — wrong + # behavior either way. Treat any whitespace inside the URL token as unsafe. + printf 'work url1 url2\n' > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" pull + [ "$status" -eq 1 ] + [[ "$output" == *""* ]] +} + +@test "EGB-282 SECURITY: glob char in URL is dropped (no expansion either way)" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/proj" + # In v0.1.1.0-alpha (set -- $line), this used to expand to filenames. + # The fixed parser uses `read -r`, so it doesn't glob — but glob chars + # are still rejected as suspicious. + printf 'work /tmp/*\n' > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" pull + [ "$status" -eq 1 ] + [[ "$output" == *""* ]] +} + +@test "EGB-282: spec parsing is glob-safe (work * does NOT expand)" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/proj" + # Create files in cwd that would expand if `set -- $line` were used. + touch "$WORK_DIR/proj/file1" "$WORK_DIR/proj/file2" + printf 'work *\n' > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" which + [ "$status" -eq 0 ] + # Spec is the literal "work" (resolves to ~/.secrets-work). The "*" gets + # rejected as unsafe URL and dropped. Resolution works; no globbing. + [[ "$output" == *"$HOME/.secrets-work"* ]] +} + +@test "EGB-282: URL with - + _ : / @ . is preserved (positive test)" { + unset SECRETS_DIR + mkdir -p "$WORK_DIR/proj" + # Standard git URL chars must NOT be rejected + printf 'work git+ssh://user@host:2222/path/to-repo_v2.git\n' > "$WORK_DIR/proj/.secrets-store" + cd "$WORK_DIR/proj" + run "$SECRETS_BIN" pull + [ "$status" -eq 1 ] + [[ "$output" == *"git+ssh://user@host:2222/path/to-repo_v2.git"* ]] +}