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:
63
scripts/gen-secret
Executable file
63
scripts/gen-secret
Executable 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
82
specs/gen-secret.spec.md
Normal 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
120
tests/test-gen-secret.sh
Executable 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
|
||||
Reference in New Issue
Block a user