Add gen-secret: generate bash/YAML/JSON-safe random strings

Outputs cryptographically random strings using only characters safe
for unquoted use in bash, YAML, and JSON: [A-Za-z0-9._+\-:@^~]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul O'Reilly
2026-03-27 11:32:55 +13:00
parent 161c633b42
commit 8bd4c7253b
3 changed files with 265 additions and 0 deletions

63
scripts/gen-secret Executable file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -uo pipefail
CHARSET='A-Za-z0-9._+\-:@^~'
DEFAULT_LENGTH=32
usage() {
cat <<'EOF'
Usage: gen-secret [OPTIONS] [LENGTH]
Generate a cryptographically random string safe for bash, YAML, and JSON.
Arguments:
LENGTH Number of characters (default: 32)
Options:
-n, --dryrun Preview what would happen
-h, --help Show this help
EOF
}
dryrun=false
length=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-n|--dryrun)
dryrun=true
shift
;;
-*)
echo "Error: Unknown option: $1" >&2
exit 1
;;
*)
if [[ -n "$length" ]]; then
echo "Error: Too many arguments" >&2
exit 1
fi
length="$1"
shift
;;
esac
done
length="${length:-$DEFAULT_LENGTH}"
if ! [[ "$length" =~ ^[0-9]+$ ]] || [[ "$length" -le 0 ]]; then
echo "Error: Length must be a positive integer" >&2
exit 1
fi
if $dryrun; then
echo "[dryrun] Would generate a ${length}-character secret from charset: [${CHARSET}]"
exit 0
fi
tr -dc "$CHARSET" < /dev/urandom | head -c "$length"
echo

82
specs/gen-secret.spec.md Normal file
View File

@@ -0,0 +1,82 @@
# gen-secret
## Purpose
Generate a cryptographically random string that is safe to embed unquoted in bash, YAML, and JSON without escaping.
## Usage
```
gen-secret [OPTIONS] [LENGTH]
```
### Arguments
| Argument | Default | Description |
|----------|---------|-------------|
| `LENGTH` | 32 | Number of characters in the generated secret |
### Flags
| Flag | Description |
|------|-------------|
| `--help`, `-h` | Show usage information |
| `--dryrun`, `-n` | Print what would happen without generating a secret |
## Character Set
The output uses only characters that need no escaping in bash (unquoted assignment), YAML (plain scalar), and JSON (string value):
```
A-Z a-z 0-9 . _ + - : @ ^ ~
```
**Excluded** (unsafe in at least one context): `"`, `'`, `\`, `` ` ``, `$`, `!`, `{`, `}`, `(`, `)`, `[`, `]`, `#`, `%`, `&`, `|`, `<`, `>`, `*`, `?`, `;`, `,`, `=`, space, tab, newline, `/`
Note: `/` is excluded because YAML plain scalars starting with `//` or containing `#` after a space can cause issues, and removing `/` keeps the set simpler without meaningful entropy loss.
## Behaviour
1. Validate that LENGTH is a positive integer.
2. Read random bytes from `/dev/urandom`.
3. Filter to the allowed character set.
4. Output exactly LENGTH characters followed by a newline.
5. Exit 0 on success.
## Dryrun Behaviour
When `--dryrun` or `-n` is passed:
```
[dryrun] Would generate a 32-character secret from charset: [A-Za-z0-9._+\-:@^~]
```
(Substituting the actual length if provided.)
No random output is produced.
## Edge Cases
| Case | Handling |
|------|----------|
| LENGTH is 0 | Error: "Length must be a positive integer", exit 1 |
| LENGTH is negative | Error: "Length must be a positive integer", exit 1 |
| LENGTH is not a number | Error: "Length must be a positive integer", exit 1 |
| No arguments | Default to 32 |
| Multiple arguments | Error: "Too many arguments", exit 1 |
## Examples
```bash
# Default 32-character secret
$ gen-secret
xQ9.kT3+mR7:nW2@pF5^bY8~cH4_dL6a
# Custom length
$ gen-secret 64
xQ9.kT3+mR7:nW2@pF5^bY8~cH4_dL6axQ9.kT3+mR7:nW2@pF5^bY8~cH4_dL6a
# Dryrun
$ gen-secret -n 16
[dryrun] Would generate a 16-character secret from charset: [A-Za-z0-9._+\-:@^~]
```

120
tests/test-gen-secret.sh Executable file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env bash
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
GEN_SECRET="$SCRIPT_DIR/scripts/gen-secret"
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
pass=0
fail=0
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" == "$actual" ]]; then
echo -e "${GREEN}PASS${NC}: $desc"
((pass++))
else
echo -e "${RED}FAIL${NC}: $desc"
echo " expected: $expected"
echo " actual: $actual"
((fail++))
fi
}
assert_match() {
local desc="$1" pattern="$2" actual="$3"
if [[ "$actual" =~ $pattern ]]; then
echo -e "${GREEN}PASS${NC}: $desc"
((pass++))
else
echo -e "${RED}FAIL${NC}: $desc"
echo " pattern: $pattern"
echo " actual: $actual"
((fail++))
fi
}
assert_exit() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" -eq "$actual" ]]; then
echo -e "${GREEN}PASS${NC}: $desc"
((pass++))
else
echo -e "${RED}FAIL${NC}: $desc"
echo " expected exit: $expected"
echo " actual exit: $actual"
((fail++))
fi
}
echo "=== gen-secret tests ==="
echo
# --- Dryrun tests ---
out=$("$GEN_SECRET" --dryrun 2>&1)
assert_eq "dryrun default length" \
'[dryrun] Would generate a 32-character secret from charset: [A-Za-z0-9._+\-:@^~]' \
"$out"
out=$("$GEN_SECRET" -n 16 2>&1)
assert_eq "dryrun custom length" \
'[dryrun] Would generate a 16-character secret from charset: [A-Za-z0-9._+\-:@^~]' \
"$out"
# --- Help ---
out=$("$GEN_SECRET" --help 2>&1)
rc=$?
assert_exit "help exits 0" 0 "$rc"
assert_match "help mentions LENGTH" "LENGTH" "$out"
# --- Default generation ---
out=$("$GEN_SECRET" 2>&1)
rc=$?
assert_exit "default exits 0" 0 "$rc"
assert_eq "default length is 32" 32 "${#out}"
assert_match "default uses safe charset" '^[A-Za-z0-9._+:@^~-]+$' "$out"
# --- Custom length ---
out=$("$GEN_SECRET" 64 2>&1)
assert_eq "custom length 64" 64 "${#out}"
out=$("$GEN_SECRET" 1 2>&1)
assert_eq "minimum length 1" 1 "${#out}"
# --- Error cases ---
out=$("$GEN_SECRET" 0 2>&1)
rc=$?
assert_exit "length 0 exits 1" 1 "$rc"
assert_match "length 0 error message" "positive integer" "$out"
out=$("$GEN_SECRET" -5 2>&1)
rc=$?
assert_exit "negative length exits 1" 1 "$rc"
out=$("$GEN_SECRET" abc 2>&1)
rc=$?
assert_exit "non-numeric exits 1" 1 "$rc"
assert_match "non-numeric error message" "positive integer" "$out"
out=$("$GEN_SECRET" 10 20 2>&1)
rc=$?
assert_exit "too many args exits 1" 1 "$rc"
assert_match "too many args error message" "Too many arguments" "$out"
# --- Summary ---
echo
total=$((pass + fail))
echo -e "Results: ${GREEN}${pass}${NC}/${total} passed"
if [[ $fail -gt 0 ]]; then
echo -e "${RED}${fail} test(s) failed${NC}"
exit 1
fi