diff options
| author | Igor Soarez <igor@soarez.org> | 2026-09-17 19:45:23 +0100 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-09-17 19:45:23 +0100 |
| commit | 13cc065ec014b86369dfe4ad60d1a43fa06fd251 (patch) | |
| tree | 684690c61b7b59758c2aac132c65b81c86de0608 /secrets | |
dotfiles
Shell, editor and terminal configuration, the tools that go with them, and
szecret, an age-based secret manager whose store lives elsewhere.
History restarts here. What came before was not written to be read by
anyone else, and carried machine names, addresses and an encrypted secret
store; it is kept privately instead. What is left is meant to be safe on
any machine, including one whose owner is not me: see CLAUDE.md for the
rule it is written to.
Diffstat (limited to 'secrets')
| -rw-r--r-- | secrets/.gitattributes | 1 | ||||
| -rw-r--r-- | secrets/.gitignore | 2 | ||||
| -rw-r--r-- | secrets/README.md | 163 | ||||
| -rw-r--r-- | secrets/common.sh | 307 | ||||
| -rwxr-xr-x | secrets/create-local.sh | 75 | ||||
| -rwxr-xr-x | secrets/ensure.sh | 32 | ||||
| -rwxr-xr-x | secrets/get-local.sh | 96 | ||||
| -rwxr-xr-x | secrets/get.sh | 46 | ||||
| -rwxr-xr-x | secrets/list.sh | 36 | ||||
| -rwxr-xr-x | secrets/main.sh | 69 | ||||
| -rwxr-xr-x | secrets/put-local.sh | 71 | ||||
| -rwxr-xr-x | secrets/put.sh | 40 | ||||
| -rwxr-xr-x | secrets/ready.sh | 31 | ||||
| -rwxr-xr-x | secrets/rotate.sh | 119 | ||||
| -rw-r--r-- | secrets/test/README.md | 148 | ||||
| -rwxr-xr-x | secrets/test/common.sh | 108 | ||||
| -rwxr-xr-x | secrets/test/create-local.sh | 122 | ||||
| -rwxr-xr-x | secrets/test/edge-cases.sh | 100 | ||||
| -rwxr-xr-x | secrets/test/ensure.sh | 29 | ||||
| -rwxr-xr-x | secrets/test/get.sh | 44 | ||||
| -rwxr-xr-x | secrets/test/list.sh | 55 | ||||
| -rwxr-xr-x | secrets/test/local.sh | 132 | ||||
| -rwxr-xr-x | secrets/test/main.sh | 57 | ||||
| -rwxr-xr-x | secrets/test/paths.sh | 119 | ||||
| -rwxr-xr-x | secrets/test/put.sh | 34 | ||||
| -rwxr-xr-x | secrets/test/ready.sh | 27 | ||||
| -rwxr-xr-x | secrets/test/rotate.sh | 69 | ||||
| -rwxr-xr-x | secrets/test/run.sh | 82 |
28 files changed, 2214 insertions, 0 deletions
diff --git a/secrets/.gitattributes b/secrets/.gitattributes new file mode 100644 index 0000000..ad48a77 --- /dev/null +++ b/secrets/.gitattributes @@ -0,0 +1 @@ +*.age binary diff --git a/secrets/.gitignore b/secrets/.gitignore new file mode 100644 index 0000000..b47e1b5 --- /dev/null +++ b/secrets/.gitignore @@ -0,0 +1,2 @@ +.id +.bin diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 0000000..293b747 --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,163 @@ +# szecret + +Secret management using [age](https://github.com/FiloSottile/age) encryption. + +## Quick Start + +```bash +# Store a secret +echo "my-api-key" | szecret put api-key + +# Retrieve a secret +szecret get api-key + +# List all secrets +szecret list +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `get <name>` | Retrieve and decrypt a secret | +| `put <name>` | Store a secret (reads from stdin) | +| `list` | List all secrets in the store | +| `ready` | Check if private key is configured | +| `ensure` | Ensure private key is configured (interactive) | +| `rotate` | Generate a new key and re-encrypt the global store | +| `create-local <name> <dir>` | Create a localized secret store | +| `put-local <file>` | Encrypt to a local store | +| `get-local <file>` | Decrypt from a local store | + +## Local Stores + +Local stores allow you to keep encrypted secrets outside the global store, useful for project-specific secrets that live alongside the code. + +### Creating a Local Store + +```bash +# Initialize a local store in a directory +szecret create-local myproject /path/to/project/secrets/ + +# This creates: +# - A key in the global store (local-key-myproject) +# - A metadata file at /path/to/project/secrets/.szecret containing: +# store=myproject +# pubkey=age1... +``` + +The metadata includes the public key for verification, preventing accidental use of the wrong store. + +### Using a Local Store + +```bash +# Encrypt a secret +echo "db-password" | szecret put-local /path/to/project/secrets/db.age + +# Decrypt a secret +szecret get-local /path/to/project/secrets/db.age +``` + +The commands automatically read the store name from the `.szecret` metadata file in the directory. + +## Setup + +### Where the store lives + +These scripts carry no secrets. The global store is a directory of `.age` +files plus `id.pub`, the public key they are encrypted to, kept in a checkout +of its own. szecret looks for it in this order: + +1. `$SZECRET_STORE` and `$SZECRET_PUBLIC_KEY`, if set. +2. `~/.local/share/szecret/store` and `~/.local/share/szecret/id.pub`, beside + the private keys. Normally symlinks into the checkout that holds the store. + +With neither, every command stops with setup instructions, and `ready` +fails silently. + +### Initial Setup + +```bash +# Generate a new keypair (file must be named after its public key) +age-keygen -o private +pubkey="$(age-keygen -y private)" +mkdir -p ~/.local/share/szecret/store +echo "${pubkey}" > ~/.local/share/szecret/id.pub +mv private "${HOME}/.local/share/szecret/${pubkey}" +``` + +### On a New Machine + +```bash +# The ensure command will prompt for your private key +szecret ensure +``` + +### Rotating the Global Key + +```bash +# Re-encrypt all secrets with a new age key +szecret rotate +``` + +This updates `id.pub` and every file in the store. When complete, commit +those changes in the checkout that holds the store, and update the private key +backup in 1Password. + +## Migration Guide + +### Migrating Local Stores (pre-2026) + +If you created local stores before the metadata system was introduced, run +`create-local` again with the directory - it will detect the existing key +and just add the metadata: + +```bash +szecret create-local myapp /path/to/secrets/ +# Output: Added metadata for existing store 'myapp' +``` + +**Before (old syntax):** +```bash +szecret create-local myapp +echo "secret" | szecret put-local myapp /path/to/secrets/password.age +szecret get-local myapp /path/to/secrets/password.age +``` + +**After (new syntax):** +```bash +# One-time migration +szecret create-local myapp /path/to/secrets/ + +# Now use simplified commands +echo "secret" | szecret put-local /path/to/secrets/password.age +szecret get-local /path/to/secrets/password.age +``` + +**For automated scripts:** The old `get-local <store-name> <file>` syntax still works but emits a deprecation warning suggesting the migration command. + +## Environment Variables + +For testing or custom setups, these environment variables override default paths: + +| Variable | Description | Default | +|----------|-------------|---------| +| `SZECRET_STORE` | Global store directory | `~/.local/share/szecret/store` | +| `SZECRET_PUBLIC_KEY` | Public key path | `~/.local/share/szecret/id.pub` | +| `SZECRET_PRIVATE_KEY` | Single private key file (full override) | (none) | +| `SZECRET_PRIVATE_KEY_DIR` | Directory containing private keys (named by their public key) | `~/.local/share/szecret` | + +* Private keys are stored in `~/.local/share/szecret/` (or `$SZECRET_PRIVATE_KEY_DIR`), with each file named after its public key (e.g. `age1...`). +* `id.pub` indicates which public key is current. + +## Testing + +```bash +# Run all tests +./test/run.sh + +# Run specific tests +./test/run.sh local create-local +``` + +See [test/README.md](test/README.md) for more details. diff --git a/secrets/common.sh b/secrets/common.sh new file mode 100644 index 0000000..ef1b7b5 --- /dev/null +++ b/secrets/common.sh @@ -0,0 +1,307 @@ +this_dir="$(cd "$(dirname "$0")" && pwd)" + +exists() { command -v "$1" >/dev/null 2>&1; } # Check if command exists (is in path). + +if [[ "$(whoami)" == "root" ]]; then + # find out who the normal user is based on $this_dir, + # since $this_dir will be somewhere within /Users/<user>/... or /home/<user>/... + # and then switch to that user, using exec to avoid forking + + # Cross-platform user detection + user="" + + # Use uname to detect OS and choose appropriate stat command + if command -v stat >/dev/null 2>&1; then + if [[ "$(uname)" == "Darwin" ]]; then + user="$(stat -f '%Su' "${this_dir}" 2>/dev/null || true)" + elif [[ "$(uname)" == "Linux" ]]; then + user="$(stat -c '%U' "${this_dir}" 2>/dev/null || true)" + else + >&2 echo "ERROR: Unsupported OS: $(uname)" + exit 1 + fi + fi + + # Fallback method: Parse user from directory path + if [[ -z "${user}" ]]; then + # Extract username from path like /Users/username/... or /home/username/... + if [[ "$(uname)" == "Darwin" ]] && [[ "${this_dir}" =~ ^/Users/([^/]+) ]]; then + user="${BASH_REMATCH[1]}" + elif [[ "$(uname)" == "Linux" ]] && [[ "${this_dir}" =~ ^/home/([^/]+) ]]; then + user="${BASH_REMATCH[1]}" + fi + fi + + if [[ -n "${user}" ]]; then + if id "${user}" >/dev/null 2>&1; then + # exec to avoid forking, + # and make sure to use a login shell + # propagate all arguments using "$@" + # The command structure: su - user -c "command" -- args + # - su - user: switch to user with login shell + # - -c "command": execute the quoted command + # - -- args: separator, everything after -- goes to the command + # - "$@": all original script arguments + # - \"\$@\": escaped version for the inner command string + exec su - "${user}" -c "exec bash -l $0 \"\$@\"" -- "$@" + >&2 echo "ERROR: Failed to switch to user ${user}" + else + >&2 echo "ERROR: Detected user ${user} does not exist" + fi + else + >&2 echo "WARN: Failed to determine user from szecret dir: ${this_dir}" + fi + >&2 echo "ERROR: Refusing to run szecret as root" + exit 1 +fi + +if ! exists age ; then + >&2 cat <<EOF +ERROR: age tool not found + +Install from: https://github.com/FiloSottile/age +EOF + exit 1 +fi + +XDG_DATA_HOME=${XDG_DATA_HOME:-"$HOME/.local/share"} + +# Private key directory. Files must be named after their public key (age1...). +# SZECRET_PRIVATE_KEY can still be used as a full single-file override (mainly for tests). +private_key_dir="${SZECRET_PRIVATE_KEY_DIR:-${XDG_DATA_HOME}/szecret}" + +# The global store and the public key its secrets are encrypted to. This code +# carries neither: a machine with only these scripts holds no secrets. Looked +# for in order: +# +# 1. SZECRET_STORE / SZECRET_PUBLIC_KEY, if set (tests, or a separate store). +# 2. store/ and id.pub in ~/.local/share/szecret, beside the private keys. +# Normally links into whichever checkout holds the store. +# +# With none of these, every command fails with the instructions below. +szecret_data_dir="${XDG_DATA_HOME}/szecret" + +if [[ -n "${SZECRET_STORE:-}" ]]; then + store="${SZECRET_STORE}" + mkdir -p "${store}" +elif [[ -d "${szecret_data_dir}/store" ]]; then + store="${szecret_data_dir}/store" +else + store="" +fi + +if [[ -n "${SZECRET_PUBLIC_KEY:-}" ]]; then + public_key="${SZECRET_PUBLIC_KEY}" +elif [[ -r "${szecret_data_dir}/id.pub" ]]; then + public_key="${szecret_data_dir}/id.pub" +else + public_key="${szecret_data_dir}/id.pub" +fi + +if [[ -z "${store}" || ! -r "${public_key}" ]]; then + # `ready` is asked by every interactive shell; a machine with no store is + # simply not ready, which needs no explanation each time. + if [[ -n "${szecret_quiet:-}" ]]; then + exit 1 + fi + >&2 cat <<EOF +ERROR: no szecret store on this machine. + +Looked for ${szecret_data_dir}/store and ${szecret_data_dir}/id.pub. +Link them to an existing store, point SZECRET_STORE and SZECRET_PUBLIC_KEY at +one, or start a new one: + + age-keygen -o private + pubkey="\$(age-keygen -y private)" + mkdir -p ${szecret_data_dir}/store + echo "\${pubkey}" > ${szecret_data_dir}/id.pub + mv private "${private_key_dir}/\${pubkey}" + +EOF + exit 1 +fi + +key_id="$(cat "${public_key}" 2>/dev/null || true)" +if [[ -z "$key_id" ]]; then + >&2 echo "WARN: Invalid public key: ${public_key}" + exit 1 +fi + +private_key="${SZECRET_PRIVATE_KEY:-${private_key_dir}/${key_id}}" + +file_for() { + local name="$1" + echo "${store}/${name}.age" +} + +local_key_name() { + local local_store_name="$1" + echo "local-key-${local_store_name}" +} + +ensure_local_store() { + local local_store_name="$1" + local local_key_name + local local_key_file + local_key_name="$(local_key_name "${local_store_name}")" + local_key_file="$(file_for "${local_key_name}")" + + if ! [[ -r "${local_key_file}" ]]; then + >&2 echo "ERROR: Local store '${local_store_name}' does not exist (key: ${local_key_name})" + exit 1 + fi +} + +get_local_private_key() { + local local_store_name="$1" + local local_key_name + local_key_name="$(local_key_name "${local_store_name}")" + "${this_dir}/get.sh" "${local_key_name}" +} + +exists() { command -v "$1" >/dev/null 2>&1; } # Check if command exists (is in path). +pathadd() { [[ -d "$1" ]] && PATH="$1"$(echo ":$PATH:" | sed "s|:$1:|:|" | sed 's|:$||'); } + +ensure_age() { + if exists age ; then + return + fi + local bin="${this_dir}/.bin" + pathadd "${bin}" + if [[ -x "${bin}/age" ]]; then + return + fi + local os + local arch + os="$(uname)" + os=${os,,} + arch="$(uname -m)" + if [[ "$arch" == "aarch64" ]]; then + arch="arm64" + elif [[ "$arch" == "x86_64" ]]; then + arch="amd64" + fi + tar="$bin/age.tar.gz" + curl -LSsfo "$tar" "https://dl.filippo.io/age/latest?for=${os}/${arch}" + tar xf "$tar" -C "$bin" --strip-components=1 +} + +private_key_is_setup() { + check_legacy_private_key_migration + if ! [[ -n "${private_key}" && -r "${private_key}" ]]; then + >&2 echo "WARN: Missing age key: ${private_key}" + fi + [[ -r "${private_key}" ]] +} + +ensure_private_key() { + mkdir -p "${private_key_dir}" + if private_key_is_setup; then + return + fi + + if [ ! -t 0 ]; then + >&2 echo "ERROR: szecret private key not installed: ${public_key}" + exit 1 + fi + + ensure_age + echo -n "Enter age private key ${public_key}: " + read -s private_key_value + echo + + local derived + derived="$(age-keygen -y <<<"${private_key_value}" 2>/dev/null || true)" + if [[ "$derived" != "$(cat "${public_key}")" ]]; then + >&2 echo "ERROR: Invalid private key (does not match ${public_key})" + exit 1 + fi + + mkdir -p "${private_key_dir}" + local target="${private_key_dir}/${derived}" + echo "${private_key_value}" > "${target}" + chmod 600 "${target}" 2>/dev/null || true +} + +check_legacy_private_key_migration() { + local f="${private_key_dir}/.id" + if [[ ! -r "${f}" ]]; then + return + fi + + local pub + pub="$(age-keygen -y "$f" 2>/dev/null || true)" + if [[ -z "$pub" || "$pub" != age1* ]]; then + >&2 echo "WARN: stray file in ${private_key_dir} that does not belong there: $f" + return + fi + + local target="${private_key_dir}/${pub}" + mv "$f" "$target" +} + +age_encrypt() { + local pubkeyfile="$1" + local outputfile="$2" + cat | age --encrypt --recipients-file "${pubkeyfile}" --output "${outputfile}" +} + +age_decrypt() { + local privatekeyfile="$1" + local inputfile="$2" + age --decrypt --identity "${privatekeyfile}" "${inputfile}" +} + +# Local store metadata helpers + +write_local_metadata() { + local metadata_file="$1" + local store_name="$2" + local pubkey="$3" + cat > "${metadata_file}" <<EOF +store=${store_name} +pubkey=${pubkey} +EOF +} + +# Read local metadata file +# Sets global variables: _metadata_store and _metadata_pubkey +read_local_metadata() { + local metadata_file="$1" + _metadata_store="" + _metadata_pubkey="" + + if [[ ! -r "${metadata_file}" ]]; then + return 1 + fi + + _metadata_store="$(grep '^store=' "${metadata_file}" | cut -d= -f2)" + _metadata_pubkey="$(grep '^pubkey=' "${metadata_file}" | cut -d= -f2)" + + if [[ -z "${_metadata_store}" || -z "${_metadata_pubkey}" ]]; then + >&2 echo "ERROR: Invalid metadata format in ${metadata_file}" + >&2 echo "Run 'szecret create-local <store-name> <directory>' to recreate" + return 1 + fi +} + +get_local_public_key() { + local local_store_name="$1" + local local_private_key + local_private_key="$(get_local_private_key "${local_store_name}")" + age-keygen -y <<<"${local_private_key}" +} + +verify_local_pubkey() { + local expected_pubkey="$1" + local actual_pubkey="$2" + local store_name="$3" + + if [[ "${expected_pubkey}" != "${actual_pubkey}" ]]; then + >&2 echo "ERROR: Public key mismatch for store '${store_name}'" + >&2 echo " Expected: ${expected_pubkey}" + >&2 echo " Actual: ${actual_pubkey}" + return 1 + fi +} + diff --git a/secrets/create-local.sh b/secrets/create-local.sh new file mode 100755 index 0000000..ffebd9c --- /dev/null +++ b/secrets/create-local.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +create-local: Create a new localized secret store + +Creates a new age private key for a localized secret store and initializes +a directory with metadata for automatic store discovery. + +If the store key already exists but the directory lacks metadata, this command +will add the metadata file (useful for migrating existing stores). + +Usage: + create-local <store-name> <directory> + create-local -h|--help + +Arguments: + store-name Name of the local store to create + directory Directory to initialize with store metadata + +The command will: +1. Check if the directory already has a .szecret metadata file +2. If the store key doesn't exist, generate a new age private key +3. Write the store name to <directory>/.szecret + +Note: This command will refuse to overwrite existing metadata. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +if [[ "$#" -lt 2 ]]; then + >&2 echo "ERROR: Need local store name and directory" + exit 1 +fi + +local_store_name="$1" +target_dir="$2" +metadata_file="${target_dir}/.szecret" +local_key_name="$(local_key_name "${local_store_name}")" +local_key_file="$(file_for "${local_key_name}")" + +key_exists=false +metadata_exists=false + +[[ -r "${local_key_file}" ]] && key_exists=true +[[ -e "${metadata_file}" ]] && metadata_exists=true + +# Check if metadata already exists +if [[ "${metadata_exists}" == "true" ]]; then + >&2 echo "ERROR: Directory already has .szecret metadata" + exit 1 +fi + +# Ensure target directory exists +mkdir -p "${target_dir}" + +ensure_age + +if [[ "${key_exists}" == "false" ]]; then + # New store: create key first + age-keygen 2>/dev/null | "${this_dir}/put.sh" "${local_key_name}" +fi + +# Get public key and write metadata +local_pubkey="$(get_local_public_key "${local_store_name}")" +write_local_metadata "${metadata_file}" "${local_store_name}" "${local_pubkey}" + +if [[ "${key_exists}" == "true" ]]; then + >&2 echo "Added metadata for existing store '${local_store_name}'" +fi diff --git a/secrets/ensure.sh b/secrets/ensure.sh new file mode 100755 index 0000000..e11212f --- /dev/null +++ b/secrets/ensure.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +ensure: Ensure the private key is configured and available + +Verifies that the age private key is properly set up for secret operations. +If not configured, prompts the user to enter it. + +Usage: + ensure + ensure -h|--help + +The command will: +1. Check if the private key exists and is readable +2. If not found, prompt for manual entry +3. Validate the entered key against the public key +4. Store the private key in the appropriate location + +Note: Interactive command that may prompt for user input. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +root_dir="$(cd "${this_dir}" && cd .. && pwd)" + +source "${this_dir}/common.sh" + +ensure_private_key diff --git a/secrets/get-local.sh b/secrets/get-local.sh new file mode 100755 index 0000000..719a914 --- /dev/null +++ b/secrets/get-local.sh @@ -0,0 +1,96 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +get-local: Retrieve and decrypt a secret from a localized store + +Decrypts and outputs a secret value from a localized store. The store name +is read from the .szecret metadata file in the file's directory. + +Usage: + get-local <file-path> + get-local -h|--help + +Arguments: + file-path Path to the encrypted file to decrypt + +The command will: +1. Read the store name from <directory>/.szecret +2. Retrieve the local store's private key from global store +3. Check if the encrypted file exists and is readable +4. Decrypt the file using age with the local store's private key +5. Output the decrypted content to stdout + +Legacy usage (deprecated): + get-local <local-store-name> <file-path> + +Note: Exits with error if file doesn't exist or decryption fails. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +if [[ "$#" -lt 1 ]]; then + >&2 echo "ERROR: Need file path" + exit 1 +fi + +# Determine if using legacy or new syntax +if [[ "$#" -ge 2 ]]; then + # Legacy syntax: get-local <store-name> <file-path> + local_store_name="$1" + file_path="$2" + target_dir="$(dirname "${file_path}")" + metadata_file="${target_dir}/.szecret" + + >&2 echo "WARN: Using deprecated syntax. Use: szecret get-local ${file_path}" + if [[ ! -r "${metadata_file}" ]]; then + >&2 echo " To migrate, run: szecret create-local ${local_store_name} ${target_dir}" + fi + + ensure_local_store "${local_store_name}" + ensure_age + # No pubkey verification for legacy syntax +else + # New syntax: get-local <file-path> + file_path="$1" + target_dir="$(dirname "${file_path}")" + metadata_file="${target_dir}/.szecret" + + if ! read_local_metadata "${metadata_file}"; then + >&2 echo "ERROR: No .szecret metadata in ${target_dir}" + >&2 echo "Run 'szecret create-local <store-name> ${target_dir}' first" + exit 1 + fi + + local_store_name="${_metadata_store}" + expected_pubkey="${_metadata_pubkey}" + + ensure_local_store "${local_store_name}" + ensure_age + + # Verify pubkey matches + actual_pubkey="$(get_local_public_key "${local_store_name}")" + verify_local_pubkey "${expected_pubkey}" "${actual_pubkey}" "${local_store_name}" +fi + +# Check if the encrypted file exists +if ! [[ -r "${file_path}" ]]; then + >&2 echo "ERROR: File '${file_path}' not found or not readable" + exit 1 +fi + +# Get the local private key from the global store +local_private_key="$(get_local_private_key "${local_store_name}")" + +# Create a temporary file for the private key +temp_private_key="$(mktemp)" +trap 'rm -f "${temp_private_key}"' EXIT +echo "${local_private_key}" > "${temp_private_key}" + +# Decrypt the file using the local private key +age_decrypt "${temp_private_key}" "${file_path}" diff --git a/secrets/get.sh b/secrets/get.sh new file mode 100755 index 0000000..d88b696 --- /dev/null +++ b/secrets/get.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +get: Retrieve and decrypt a secret from the store + +Decrypts and outputs a secret value from the global secret store. + +Usage: + get <secret-name> + get -h|--help + +Arguments: + secret-name Name of the secret to retrieve + +The command will: +1. Check if the secret exists in the store +2. Ensure the private key is available +3. Decrypt the secret using age and output to stdout + +Note: Exits with error if secret doesn't exist or decryption fails. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +if [[ "$#" -lt 1 ]]; then + >&2 echo "ERROR: Need secret name" + exit 1 +fi +name="$1" + +file="$(file_for "${name}")" +if ! [[ -r "${file}" ]]; then + >&2 echo "ERROR: Secret '$name' not found" + exit 1 +fi + +ensure_private_key + +age_decrypt "${private_key}" "${file}" + diff --git a/secrets/list.sh b/secrets/list.sh new file mode 100755 index 0000000..bf087e3 --- /dev/null +++ b/secrets/list.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +list: List all available secrets in the store + +Displays all secret names in the global store in a columnar format. + +Usage: + list + list -h|--help + +The command will: +1. List all .age files in the store directory +2. Remove the .age extension from filenames +3. Display the secret names in columns + +Note: Only shows secret names, not their values. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$(realpath "$0")")" && pwd)" +source "${this_dir}/common.sh" + +maybe_column() { + if [ -t 1 ]; then + column + else + cat + fi +} + +ls "${store}" | sed 's/\.age$//' | sort | maybe_column diff --git a/secrets/main.sh b/secrets/main.sh new file mode 100755 index 0000000..89bbea3 --- /dev/null +++ b/secrets/main.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +set -euo pipefail + +this_dir="$(cd "$(dirname "$(realpath "$0")")" && pwd)" + +# list all commands, name only +cmds() { + find "${this_dir}" -maxdepth 1 -type f -name '*.sh' -print0 \ + | xargs -0 -n 1 basename \ + | sed 's/\.sh$//' \ + | grep -Ev '^(main|common)$' \ + | sort +} + +# list all commands, name and description +cmd_descriptions() { + for cmd in $(cmds); do + local cmd_sh="${this_dir}/${cmd}.sh" + if [[ -x "${cmd_sh}" ]]; then + local description + description="$(bash "${cmd_sh}" -h 2>/dev/null | head -1 | sed 's/^[^:]*: *//')" + if [[ -n "${description}" ]]; then + printf "%-15s %s\n" "${cmd}" "${description}" + else + printf "%-15s\n" "${cmd}" + fi + fi + done +} + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +szecret: Secret management using age encryption + +Usage: + szecret -h|--help + szecret <command> -h|--help + szecret <command> [args...] + +Available commands: + +$(cmd_descriptions | sed 's/^/ /') +EOF + exit 0 +fi + +# check number of arguments > 1, error and exit if not +# extract first argument, call script with same name using remaining arguments +if [ $# -lt 1 ]; then + >&2 cat <<EOF +ERROR: no command specified. Available: + +$(cmd_descriptions | sed 's/^/ /') +EOF + exit 1 +fi + +cmd="$1" +cmd_sh="${this_dir}/${cmd}.sh" +shift + +if [[ ! -e "${cmd_sh}" ]]; then + >&2 echo "ERROR: no command named ${cmd}. Available: $(cmds | paste -sd ',' -)" + exit 1 +fi + +exec bash "${cmd_sh}" "$@" + diff --git a/secrets/put-local.sh b/secrets/put-local.sh new file mode 100755 index 0000000..cba71c6 --- /dev/null +++ b/secrets/put-local.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +put-local: Store a secret in a localized store + +Encrypts data from stdin and stores it in a localized store. The store name +is read from the .szecret metadata file in the file's directory. + +Usage: + put-local <file-path> + echo "secret-value" | put-local <file-path> + put-local -h|--help + +Arguments: + file-path Path where to store the encrypted file + +The command will: +1. Read the store name from <directory>/.szecret +2. Retrieve the local store's private key from global store +3. Generate the corresponding public key from the private key +4. Read data from stdin +5. Encrypt the data using age with the local store's public key +6. Store the encrypted data at the specified file path + +Note: Will overwrite existing files at the target path. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +if [[ "$#" -lt 1 ]]; then + >&2 echo "ERROR: Need file path" + exit 1 +fi + +file_path="$1" +target_dir="$(dirname "${file_path}")" +metadata_file="${target_dir}/.szecret" + +# Read store name and pubkey from metadata +if ! read_local_metadata "${metadata_file}"; then + >&2 echo "ERROR: No .szecret metadata in ${target_dir}" + >&2 echo "Run 'szecret create-local <store-name> ${target_dir}' first" + exit 1 +fi + +local_store_name="${_metadata_store}" +expected_pubkey="${_metadata_pubkey}" + +ensure_local_store "${local_store_name}" +ensure_age + +# Verify pubkey matches +actual_pubkey="$(get_local_public_key "${local_store_name}")" +verify_local_pubkey "${expected_pubkey}" "${actual_pubkey}" "${local_store_name}" + +# Get the local private key from the global store +local_private_key="$(get_local_private_key "${local_store_name}")" + +# Create a temporary file for the public key (needed because age_encrypt uses pipes) +temp_public_key="$(mktemp)" +trap 'rm -f "${temp_public_key}"' EXIT +age-keygen -y <<<"${local_private_key}" > "${temp_public_key}" + +# Encrypt data from stdin to the specified file path using the local store's public key +age_encrypt "${temp_public_key}" "${file_path}" diff --git a/secrets/put.sh b/secrets/put.sh new file mode 100755 index 0000000..2675066 --- /dev/null +++ b/secrets/put.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +put: Store a secret in the encrypted store + +Encrypts data from stdin and stores it as a named secret in the global store. + +Usage: + put <secret-name> + echo "secret-value" | put <secret-name> + put -h|--help + +Arguments: + secret-name Name to store the secret under + +The command will: +1. Read data from stdin +2. Encrypt the data using age with the public key +3. Store the encrypted data in the store as <secret-name>.age + +Note: Will overwrite existing secrets with the same name. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +if [[ "$#" -lt 1 ]]; then + >&2 echo "ERROR: Need secret name" + exit 1 +fi +name="$1" + +ensure_age +age_encrypt "${public_key}" "$(file_for "${name}")" + diff --git a/secrets/ready.sh b/secrets/ready.sh new file mode 100755 index 0000000..dee2f86 --- /dev/null +++ b/secrets/ready.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +ready: Check if the secret management system is ready for use + +Tests whether the age private key is properly configured and available +for secret operations. Exits with status 0 if ready, non-zero if not. + +Usage: + ready + ready -h|--help + +The command will: +1. Check if the private key exists and is readable +2. Perform any necessary legacy key migration +3. Exit with appropriate status code + +Note: Silent command, check exit status for result. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$(realpath "$0")")" && pwd)" +szecret_quiet=1 +source "${this_dir}/common.sh" + +private_key_is_setup + diff --git a/secrets/rotate.sh b/secrets/rotate.sh new file mode 100755 index 0000000..4286f4c --- /dev/null +++ b/secrets/rotate.sh @@ -0,0 +1,119 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then + cat <<EOF +rotate: Generate a new age key and re-encrypt the global secret store + +Creates a new age keypair, re-encrypts every secret in the global store with +the new public key, updates the public key file, and stores the new +private key in the key directory. + +Usage: + rotate + rotate --yes + rotate -h|--help + +Options: + --yes Skip interactive confirmation + +The command will: +1. Confirm the operation (unless --yes) +2. Decrypt each secret in the store using the current private key +3. Generate a new age keypair +4. Re-encrypt all secrets with the new public key +5. Update the public key file and install the new private key + +Note: Interactive command. Commit the updated store files and public key + wherever the store is tracked, + and update the private key backup in 1Password when complete. +EOF + exit 0 +fi + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +assume_yes=false +if [[ "${1:-}" == "--yes" ]]; then + assume_yes=true +elif [[ -n "${1:-}" ]]; then + >&2 echo "ERROR: Unknown argument: $1" + exit 1 +fi + +ensure_age +ensure_private_key + +old_private_key="${private_key}" +old_public_key="${key_id}" + +shopt -s nullglob +store_files=("${store}"/*.age) +shopt -u nullglob +secret_count="${#store_files[@]}" + +if [[ "${assume_yes}" != "true" ]]; then + if [[ ! -t 1 ]]; then + >&2 echo "ERROR: rotate requires interactive confirmation (use --yes to skip)" + exit 1 + fi + >&2 echo "This will generate a new age key and re-encrypt ${secret_count} secret(s)." + >&2 echo "Current public key: ${old_public_key}" + >&2 printf "Continue? [y/N] " + local_confirm="" + read -r local_confirm </dev/tty + if [[ "${local_confirm}" != [yY] ]]; then + >&2 echo "Aborted." + exit 1 + fi +fi + +staging="$(mktemp -d "${TMPDIR:-/tmp}/szecret-rotate.XXXXXX")" +temp_private_key="$(mktemp -u "${TMPDIR:-/tmp}/szecret-rotate-key.XXXXXX")" +temp_public_key="$(mktemp "${TMPDIR:-/tmp}/szecret-rotate-pub.XXXXXX")" + +cleanup() { + rm -rf "${staging}" + rm -f "${temp_private_key}" "${temp_public_key}" +} +trap cleanup EXIT + +age-keygen -o "${temp_private_key}" 2>/dev/null +new_public_key="$(age-keygen -y "${temp_private_key}")" +echo "${new_public_key}" > "${temp_public_key}" + +for secret_file in "${store_files[@]}"; do + secret_name="$(basename "${secret_file}" .age)" + age_decrypt "${old_private_key}" "${secret_file}" \ + | age_encrypt "${temp_public_key}" "${staging}/${secret_name}.age" +done + +for secret_file in "${store_files[@]}"; do + secret_name="$(basename "${secret_file}" .age)" + mv "${staging}/${secret_name}.age" "${secret_file}" +done + +echo "${new_public_key}" > "${public_key}" + +installed_private_key="${private_key_dir}/${new_public_key}" +mv "${temp_private_key}" "${installed_private_key}" +temp_private_key="" +chmod 600 "${installed_private_key}" 2>/dev/null || true + +cat <<EOF + +Rotation complete. + + Old public key: ${old_public_key} + New public key: ${new_public_key} + Secrets re-encrypted: ${secret_count} + New private key: ${installed_private_key} + +Next steps: + 1. Commit the updated store and $(basename "${public_key}") + 2. Update the szecret private key in 1Password + +The old private key remains at ${old_private_key} until you remove it. +EOF diff --git a/secrets/test/README.md b/secrets/test/README.md new file mode 100644 index 0000000..49d70b2 --- /dev/null +++ b/secrets/test/README.md @@ -0,0 +1,148 @@ +# szecret Test Suite + +Unit tests for the szecret secret management system. + +## Running Tests + +```bash +# Run all tests +./test/run.sh + +# Run specific test files +./test/run.sh list get + +# Run a single test file directly +./test/list.sh +``` + +## Test Structure + +``` +test/ +├── common.sh # Shared utilities, setup/teardown, assertions +├── run.sh # Test runner entrypoint +├── create-local.sh # Tests for create-local command +├── edge-cases.sh # Edge case tests (special chars, unicode, etc.) +├── ensure.sh # Tests for ensure command +├── get.sh # Tests for get command +├── list.sh # Tests for list command +├── local.sh # Tests for put-local/get-local commands +├── main.sh # Tests for main entrypoint +├── paths.sh # Tests for where the store and public key are found +├── put.sh # Tests for put command +└── ready.sh # Tests for ready command +``` + +## Writing New Tests + +### Basic test file template + +```bash +#!/bin/bash +# Tests for <command> command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_something() { + # Test code here + pass "description of what passed" +} + +test_something_else() { + # Test code here + pass "another test" +} + +run_tests() { + test_something + test_something_else +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests +``` + +### Available helpers + +From `common.sh`: + +| Function | Description | +|----------|-------------| +| `szecret <cmd> [args]` | Run a szecret command via main.sh | +| `szecret_script <name> [args]` | Run a specific script directly (e.g., `szecret_script get secret-name`) | +| `pass <msg>` | Print green PASS message | +| `fail <msg>` | Print red FAIL message and exit | +| `skip <msg>` | Print yellow SKIP message | +| `assert_success <cmd>` | Assert command exits 0 | +| `assert_failure <cmd>` | Assert command exits non-zero | +| `assert_equals <expected> <actual> [msg]` | Assert string equality | +| `assert_contains <haystack> <needle> [msg]` | Assert string contains substring | +| `assert_file_exists <path> [msg]` | Assert file exists | + +### Environment variables + +Tests run in an isolated environment with these variables set: + +| Variable | Description | +|----------|-------------| +| `TEST_DIR` | Temporary directory for test artifacts | +| `SZECRET_STORE` | Points to `$TEST_DIR/store` | +| `SZECRET_PUBLIC_KEY` | Points to test public key | +| `SZECRET_PRIVATE_KEY` | Points to test private key | + +The test environment is created automatically when `common.sh` is sourced and cleaned up on exit. + +## Design Notes + +### Isolation + +Each test file runs in its own subprocess with a fresh environment. This ensures tests don't interfere with each other. The `run.sh` runner executes each test file as a separate process. + +### Auto-setup + +The `common.sh` file automatically runs `setup` and registers the `teardown` trap when first sourced. A guard variable (`_SZECRET_TEST_INITIALIZED`) prevents duplicate initialization if common.sh is sourced multiple times. + +### Configurable paths + +The szecret scripts support these environment variables for testing: + +- `SZECRET_STORE` - Override the store directory +- `SZECRET_PUBLIC_KEY` - Override the public key path +- `SZECRET_PRIVATE_KEY` - Override the private key path + +This allows tests to use a temporary directory without affecting the real secret store. + +## Known Limitations + +### Trailing newlines + +Bash command substitution `$()` strips trailing newlines. This means: + +```bash +# This will lose the trailing newline +retrieved="$(szecret get my-secret)" +``` + +This is bash behavior, not a szecret bug. The encrypted data preserves trailing newlines correctly. + +### Interactive commands + +The `ensure` command prompts for private key input when run interactively. This flow isn't fully tested since it requires TTY input. The tests verify it fails gracefully in non-interactive mode. + +### Binary data + +The tests don't currently cover binary data. Secrets are expected to be text (the encryption handles binary, but shell pipelines may have issues with null bytes). + +## Adding Tests for New Commands + +1. Create `test/<command>.sh` following the template above +2. Make it executable: `chmod +x test/<command>.sh` +3. The runner will automatically discover and run it + +## CI Integration + +The test suite exits with code 0 on success, non-zero on failure. Example CI usage: + +```bash +./secrets/test/run.sh || exit 1 +``` diff --git a/secrets/test/common.sh b/secrets/test/common.sh new file mode 100755 index 0000000..33d7d78 --- /dev/null +++ b/secrets/test/common.sh @@ -0,0 +1,108 @@ +# Common test utilities for szecret tests +# Source this file from individual test files + +set -euo pipefail + +SECRETS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +pass() { echo -e "${GREEN}PASS${NC}: $1"; } +fail() { echo -e "${RED}FAIL${NC}: $1"; exit 1; } +skip() { echo -e "${YELLOW}SKIP${NC}: $1"; } + +# Create temporary test environment +setup() { + export TEST_DIR="$(mktemp -d)" + export SZECRET_STORE="${TEST_DIR}/store" + export SZECRET_PUBLIC_KEY="${TEST_DIR}/.id.pub" + export SZECRET_PRIVATE_KEY="${TEST_DIR}/.id" + + mkdir -p "${SZECRET_STORE}" + + # Generate test keypair + age-keygen -o "${SZECRET_PRIVATE_KEY}" 2>/dev/null + age-keygen -y "${SZECRET_PRIVATE_KEY}" > "${SZECRET_PUBLIC_KEY}" +} + +teardown() { + if [[ -n "${TEST_DIR:-}" && -d "${TEST_DIR}" ]]; then + rm -rf "${TEST_DIR}" + fi +} + +# Run a command from the secrets directory +szecret() { + "${SECRETS_DIR}/main.sh" "$@" +} + +# Direct access to individual scripts (useful for testing specific scripts) +szecret_script() { + local script="$1" + shift + "${SECRETS_DIR}/${script}.sh" "$@" +} + +# Assert that a command succeeds +assert_success() { + if "$@"; then + return 0 + else + fail "Command failed: $*" + fi +} + +# Assert that a command fails +assert_failure() { + if "$@" 2>/dev/null; then + fail "Command should have failed: $*" + else + return 0 + fi +} + +# Assert string equality +assert_equals() { + local expected="$1" + local actual="$2" + local msg="${3:-values}" + if [[ "${expected}" == "${actual}" ]]; then + return 0 + else + fail "${msg}: expected '${expected}', got '${actual}'" + fi +} + +# Assert string contains substring +assert_contains() { + local haystack="$1" + local needle="$2" + local msg="${3:-string}" + if [[ "${haystack}" == *"${needle}"* ]]; then + return 0 + else + fail "${msg}: '${haystack}' does not contain '${needle}'" + fi +} + +# Assert file exists +assert_file_exists() { + local file="$1" + local msg="${2:-file}" + if [[ -f "${file}" ]]; then + return 0 + else + fail "${msg}: file '${file}' does not exist" + fi +} + +# Auto-setup: only run once per process +if [[ -z "${_SZECRET_TEST_INITIALIZED:-}" ]]; then + export _SZECRET_TEST_INITIALIZED=1 + trap teardown EXIT + setup +fi diff --git a/secrets/test/create-local.sh b/secrets/test/create-local.sh new file mode 100755 index 0000000..310ba23 --- /dev/null +++ b/secrets/test/create-local.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# Tests for create-local command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_create_local() { + local target_dir="${TEST_DIR}/local-store-dir" + szecret create-local "test-local-store" "${target_dir}" + + assert_file_exists "${SZECRET_STORE}/local-key-test-local-store.age" "local key file" + assert_file_exists "${target_dir}/.szecret" "metadata file" + + # Check metadata format + assert_contains "$(cat "${target_dir}/.szecret")" "store=test-local-store" "store in metadata" + assert_contains "$(cat "${target_dir}/.szecret")" "pubkey=age1" "pubkey in metadata" + pass "create-local creates local key and metadata" +} + +test_create_local_creates_directory() { + local target_dir="${TEST_DIR}/nested/new/directory" + szecret create-local "nested-store" "${target_dir}" + + assert_file_exists "${target_dir}/.szecret" "metadata file in created directory" + pass "create-local creates target directory" +} + +test_create_local_migration() { + # Simulate existing store without metadata (old-style) + local dir1="${TEST_DIR}/dir1" + local dir2="${TEST_DIR}/dir2" + + # Create store with first directory + szecret create-local "migration-store" "${dir1}" + + # Now "migrate" to a second directory - should just add metadata + szecret create-local "migration-store" "${dir2}" + + assert_file_exists "${dir2}/.szecret" "metadata in second directory" + + # Check metadata format and that pubkey is same in both dirs + assert_contains "$(cat "${dir2}/.szecret")" "store=migration-store" "store in metadata" + + local pubkey1 pubkey2 + pubkey1="$(grep pubkey "${dir1}/.szecret" | cut -d= -f2)" + pubkey2="$(grep pubkey "${dir2}/.szecret" | cut -d= -f2)" + assert_equals "${pubkey1}" "${pubkey2}" "pubkey matches across directories" + pass "create-local adds metadata for existing key (migration)" +} + +test_create_local_migration_preserves_key() { + local target_dir="${TEST_DIR}/preserve-key" + szecret create-local "preserve-store" "${target_dir}" + + # Encrypt a secret + local secret_value="test-secret-value" + echo "${secret_value}" | szecret put-local "${target_dir}/secret.age" + + # Remove metadata to simulate old-style store + rm "${target_dir}/.szecret" + + # Run create-local again - should add metadata without regenerating key + szecret create-local "preserve-store" "${target_dir}" + + # Verify we can still decrypt - proves key wasn't overwritten + local retrieved + retrieved="$(szecret get-local "${target_dir}/secret.age")" + assert_equals "${secret_value}" "${retrieved}" "decrypted value after migration" + pass "create-local migration preserves existing key" +} + +test_create_local_reuses_key_across_directories() { + local dir1="${TEST_DIR}/multi-dir-1" + local dir2="${TEST_DIR}/multi-dir-2" + + # Create store with first directory and encrypt a secret + szecret create-local "shared-store" "${dir1}" + local secret_value="shared-secret" + echo "${secret_value}" | szecret put-local "${dir1}/secret.age" + + # Add same store to second directory + szecret create-local "shared-store" "${dir2}" + + # Verify secret in dir1 still decrypts - proves key was reused not overwritten + local retrieved + retrieved="$(szecret get-local "${dir1}/secret.age")" + assert_equals "${secret_value}" "${retrieved}" "secret from first directory" + + # Verify we can also encrypt/decrypt in dir2 with same key + echo "dir2-secret" | szecret put-local "${dir2}/secret.age" + retrieved="$(szecret get-local "${dir2}/secret.age")" + assert_equals "dir2-secret" "${retrieved}" "secret from second directory" + + pass "create-local reuses key across directories" +} + +test_create_local_existing_metadata_fails() { + local target_dir="${TEST_DIR}/existing-metadata" + mkdir -p "${target_dir}" + echo "other-store" > "${target_dir}/.szecret" + + assert_failure szecret create-local "new-store" "${target_dir}" + pass "create-local fails if metadata exists" +} + +test_create_local_requires_args() { + assert_failure szecret create-local + assert_failure szecret create-local "store-only" + pass "create-local requires store name and directory" +} + +run_tests() { + test_create_local + test_create_local_creates_directory + test_create_local_migration + test_create_local_migration_preserves_key + test_create_local_reuses_key_across_directories + test_create_local_existing_metadata_fails + test_create_local_requires_args +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/edge-cases.sh b/secrets/test/edge-cases.sh new file mode 100755 index 0000000..ab1a56e --- /dev/null +++ b/secrets/test/edge-cases.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Edge case tests + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_special_characters_in_value() { + local secret_value='!@#$%^&*()_+-=[]{}|;:",.<>?/`~'"'" + echo "${secret_value}" | szecret put "special-chars" + + local retrieved + retrieved="$(szecret get "special-chars")" + assert_equals "${secret_value}" "${retrieved}" "special characters" + pass "handles special characters in value" +} + +test_whitespace_in_value() { + local secret_value=$' leading and trailing spaces ' + echo "${secret_value}" | szecret put "whitespace" + + local retrieved + retrieved="$(szecret get "whitespace")" + assert_equals "${secret_value}" "${retrieved}" "whitespace" + pass "preserves whitespace in value" +} + +test_empty_value() { + echo -n "" | szecret put "empty-secret" + + local retrieved + retrieved="$(szecret get "empty-secret")" + assert_equals "" "${retrieved}" "empty value" + pass "handles empty value" +} + +test_multiline_value() { + # Note: trailing newlines are stripped by bash $() substitution + # This is bash behavior, not a szecret limitation + local secret_value=$'line1\nline2\n\nline4 with spaces' + echo "${secret_value}" | szecret put "multiline" + + local retrieved + retrieved="$(szecret get "multiline")" + assert_equals "${secret_value}" "${retrieved}" "multiline value" + pass "handles multiline value with blank lines" +} + +test_unicode_in_value() { + local secret_value='Hello 世界 🔐 émoji café' + echo "${secret_value}" | szecret put "unicode" + + local retrieved + retrieved="$(szecret get "unicode")" + assert_equals "${secret_value}" "${retrieved}" "unicode value" + pass "handles unicode in value" +} + +test_secret_name_with_dashes() { + echo "value" | szecret put "my-secret-with-dashes" + assert_success szecret get "my-secret-with-dashes" >/dev/null + pass "handles dashes in secret name" +} + +test_secret_name_with_underscores() { + echo "value" | szecret put "my_secret_with_underscores" + assert_success szecret get "my_secret_with_underscores" >/dev/null + pass "handles underscores in secret name" +} + +test_secret_name_with_dots() { + echo "value" | szecret put "my.secret.with.dots" + assert_success szecret get "my.secret.with.dots" >/dev/null + pass "handles dots in secret name" +} + +test_long_value() { + # Generate a long value (10KB) + local secret_value + secret_value="$(head -c 10240 /dev/urandom | base64)" + echo "${secret_value}" | szecret put "long-secret" + + local retrieved + retrieved="$(szecret get "long-secret")" + assert_equals "${secret_value}" "${retrieved}" "long value" + pass "handles long value (10KB)" +} + +run_tests() { + test_special_characters_in_value + test_whitespace_in_value + test_empty_value + test_multiline_value + test_unicode_in_value + test_secret_name_with_dashes + test_secret_name_with_underscores + test_secret_name_with_dots + test_long_value +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/ensure.sh b/secrets/test/ensure.sh new file mode 100755 index 0000000..720d328 --- /dev/null +++ b/secrets/test/ensure.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Tests for ensure command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_ensure_succeeds_when_key_exists() { + # Key already exists from setup, should succeed silently + assert_success szecret ensure + pass "ensure succeeds when key exists" +} + +test_ensure_fails_noninteractive_without_key() { + local saved_key="${SZECRET_PRIVATE_KEY}" + export SZECRET_PRIVATE_KEY="${TEST_DIR}/nonexistent-key" + + # Force non-interactive mode by redirecting stdin from /dev/null + assert_failure szecret ensure < /dev/null + + export SZECRET_PRIVATE_KEY="${saved_key}" + pass "ensure fails non-interactive without key" +} + +run_tests() { + test_ensure_succeeds_when_key_exists + test_ensure_fails_noninteractive_without_key +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/get.sh b/secrets/test/get.sh new file mode 100755 index 0000000..eb056d9 --- /dev/null +++ b/secrets/test/get.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Tests for get command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_get_retrieves_secret() { + local secret_value="my-secret-value-123" + echo "${secret_value}" | szecret put "test-secret" + + local retrieved + retrieved="$(szecret get "test-secret")" + assert_equals "${secret_value}" "${retrieved}" "retrieved value" + pass "get retrieves secret" +} + +test_get_nonexistent_fails() { + assert_failure szecret get "nonexistent-secret" + pass "get nonexistent returns error" +} + +test_get_requires_name() { + assert_failure szecret get + pass "get requires secret name" +} + +test_get_preserves_newlines() { + local secret_value=$'line1\nline2\nline3' + echo "${secret_value}" | szecret put "multiline-secret" + + local retrieved + retrieved="$(szecret get "multiline-secret")" + assert_equals "${secret_value}" "${retrieved}" "multiline value" + pass "get preserves newlines" +} + +run_tests() { + test_get_retrieves_secret + test_get_nonexistent_fails + test_get_requires_name + test_get_preserves_newlines +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/list.sh b/secrets/test/list.sh new file mode 100755 index 0000000..08671c8 --- /dev/null +++ b/secrets/test/list.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Tests for list command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_list_shows_secrets() { + echo "value1" | szecret put "secret-a" + echo "value2" | szecret put "secret-b" + echo "value3" | szecret put "secret-c" + + local list_output + list_output="$(szecret list)" + + assert_contains "${list_output}" "secret-a" "list output" + assert_contains "${list_output}" "secret-b" "list output" + assert_contains "${list_output}" "secret-c" "list output" + pass "list shows all secrets" +} + +test_list_empty_store() { + local list_output + list_output="$(szecret list)" + assert_equals "" "${list_output}" "empty list" + pass "list handles empty store" +} + +test_list_sorted() { + echo "v" | szecret put "zebra" + echo "v" | szecret put "apple" + echo "v" | szecret put "mango" + + local list_output + list_output="$(szecret list)" + + # Check that apple comes before mango comes before zebra + local apple_pos mango_pos zebra_pos + apple_pos=$(echo "${list_output}" | grep -n "apple" | cut -d: -f1) + mango_pos=$(echo "${list_output}" | grep -n "mango" | cut -d: -f1) + zebra_pos=$(echo "${list_output}" | grep -n "zebra" | cut -d: -f1) + + if [[ "${apple_pos}" -lt "${mango_pos}" && "${mango_pos}" -lt "${zebra_pos}" ]]; then + pass "list is sorted" + else + fail "list is not sorted: apple=${apple_pos}, mango=${mango_pos}, zebra=${zebra_pos}" + fi +} + +run_tests() { + test_list_empty_store + test_list_shows_secrets + test_list_sorted +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/local.sh b/secrets/test/local.sh new file mode 100755 index 0000000..e8d174b --- /dev/null +++ b/secrets/test/local.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# Tests for put-local and get-local commands + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_put_local_and_get_local() { + local target_dir="${TEST_DIR}/secrets" + szecret create-local "test-store" "${target_dir}" + + local secret_value="local-secret-value-456" + local encrypted_file="${target_dir}/secret.age" + + echo "${secret_value}" | szecret put-local "${encrypted_file}" + + assert_file_exists "${encrypted_file}" "encrypted file" + + local retrieved + retrieved="$(szecret get-local "${encrypted_file}")" + assert_equals "${secret_value}" "${retrieved}" "local secret value" + pass "put-local and get-local" +} + +test_put_local_no_metadata_fails() { + local target_dir="${TEST_DIR}/no-metadata" + mkdir -p "${target_dir}" + + assert_failure bash -c "echo 'value' | szecret put-local '${target_dir}/secret.age'" + pass "put-local fails without metadata" +} + +test_get_local_no_metadata_fails() { + local target_dir="${TEST_DIR}/no-metadata-get" + mkdir -p "${target_dir}" + touch "${target_dir}/fake.age" + + assert_failure szecret get-local "${target_dir}/fake.age" + pass "get-local fails without metadata" +} + +test_get_local_legacy_syntax_warns() { + local target_dir="${TEST_DIR}/legacy-test" + szecret create-local "legacy-store" "${target_dir}" + + local secret_value="legacy-value" + echo "${secret_value}" | szecret put-local "${target_dir}/secret.age" + + # Use legacy 2-arg syntax - should warn even with metadata present + local stderr_output + stderr_output="$(szecret get-local "legacy-store" "${target_dir}/secret.age" 2>&1 >/dev/null)" + + assert_contains "${stderr_output}" "WARN" "deprecation warning" + assert_contains "${stderr_output}" "deprecated" "deprecation message" + + # Should still work despite warning + local retrieved + retrieved="$(szecret get-local "legacy-store" "${target_dir}/secret.age" 2>/dev/null)" + assert_equals "${secret_value}" "${retrieved}" "legacy syntax result" + pass "get-local legacy syntax warns but works" +} + +test_get_local_nonexistent_file_fails() { + local target_dir="${TEST_DIR}/nonexistent-file" + szecret create-local "file-test-store" "${target_dir}" + + assert_failure szecret get-local "${target_dir}/nonexistent.age" + pass "get-local fails for nonexistent file" +} + +test_put_local_requires_arg() { + assert_failure szecret put-local + pass "put-local requires file path" +} + +test_get_local_requires_arg() { + assert_failure szecret get-local + pass "get-local requires file path" +} + +test_put_local_pubkey_mismatch_fails() { + local target_dir="${TEST_DIR}/pubkey-mismatch-put" + szecret create-local "mismatch-put-store" "${target_dir}" + + # Corrupt the pubkey in metadata + sed -i.bak 's/pubkey=.*/pubkey=age1invalid000000000000000000000000000000000000000000000000000/' "${target_dir}/.szecret" + + # Should fail on put-local + assert_failure bash -c "echo 'value' | szecret put-local '${target_dir}/secret.age'" + pass "put-local fails on pubkey mismatch" +} + +test_get_local_pubkey_mismatch_fails() { + local target_dir="${TEST_DIR}/pubkey-mismatch-get" + szecret create-local "mismatch-get-store" "${target_dir}" + + # Encrypt a secret first + echo "secret-value" | szecret put-local "${target_dir}/secret.age" + + # Corrupt the pubkey in metadata + sed -i.bak 's/pubkey=.*/pubkey=age1invalid000000000000000000000000000000000000000000000000000/' "${target_dir}/.szecret" + + # Should fail on get-local (new syntax) + assert_failure szecret get-local "${target_dir}/secret.age" + pass "get-local fails on pubkey mismatch" +} + +test_invalid_metadata_fails() { + local target_dir="${TEST_DIR}/invalid-metadata" + mkdir -p "${target_dir}" + + # Create invalid metadata (old format without pubkey) + echo "just-store-name" > "${target_dir}/.szecret" + touch "${target_dir}/secret.age" + + assert_failure szecret get-local "${target_dir}/secret.age" + pass "invalid metadata format fails" +} + +run_tests() { + test_put_local_and_get_local + test_put_local_no_metadata_fails + test_get_local_no_metadata_fails + test_get_local_legacy_syntax_warns + test_get_local_nonexistent_file_fails + test_put_local_requires_arg + test_get_local_requires_arg + test_put_local_pubkey_mismatch_fails + test_get_local_pubkey_mismatch_fails + test_invalid_metadata_fails +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/main.sh b/secrets/test/main.sh new file mode 100755 index 0000000..23c8dd2 --- /dev/null +++ b/secrets/test/main.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Tests for main entrypoint + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_main_help() { + local output + output="$(szecret --help)" + assert_contains "${output}" "szecret" "help output" + assert_contains "${output}" "Usage" "help output" + pass "main --help shows usage" +} + +test_main_help_short() { + local output + output="$(szecret -h)" + assert_contains "${output}" "szecret" "help output" + pass "main -h shows usage" +} + +test_main_unknown_command() { + assert_failure szecret nonexistent-command + pass "main fails on unknown command" +} + +test_main_no_command() { + assert_failure szecret + pass "main fails without command" +} + +test_main_dispatches_to_command() { + echo "dispatch-test-value" | szecret put "dispatch-test" + local retrieved + retrieved="$(szecret get "dispatch-test")" + assert_equals "dispatch-test-value" "${retrieved}" "dispatched command" + pass "main dispatches to commands" +} + +test_command_help() { + local output + output="$(szecret get --help)" + assert_contains "${output}" "get" "command help" + assert_contains "${output}" "Usage" "command help" + pass "command --help shows command usage" +} + +run_tests() { + test_main_help + test_main_help_short + test_main_unknown_command + test_main_no_command + test_main_dispatches_to_command + test_command_help +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/paths.sh b/secrets/test/paths.sh new file mode 100755 index 0000000..97c8599 --- /dev/null +++ b/secrets/test/paths.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# Tests for where szecret finds the global store and its public key + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +# A copy of the scripts with no store beside them, so the tests do not depend +# on whether this checkout still carries one. +make_code_dir() { + local dir="$1" + mkdir -p "${dir}" + cp "${SECRETS_DIR}"/*.sh "${dir}/" +} + +# Runs szecret from a code dir with only XDG_DATA_HOME to go on. +szecret_at() { + local code_dir="$1" + shift + env -u SZECRET_STORE -u SZECRET_PUBLIC_KEY -u SZECRET_PRIVATE_KEY -u SZECRET_PRIVATE_KEY_DIR \ + XDG_DATA_HOME="${TEST_DIR}/xdg" \ + "${code_dir}/main.sh" "$@" +} + +# Makes a keypair: the public key goes to $1, the private key into the private +# key directory, named after its public key. +make_key() { + local pub_file="$1" + local key_dir="${TEST_DIR}/xdg/szecret" + mkdir -p "${key_dir}" "$(dirname "${pub_file}")" + age-keygen -o "${TEST_DIR}/new-key" 2>/dev/null + age-keygen -y "${TEST_DIR}/new-key" > "${pub_file}" + mv "${TEST_DIR}/new-key" "${key_dir}/$(cat "${pub_file}")" +} + +reset_xdg() { + rm -rf "${TEST_DIR}/xdg" +} + +test_no_store_fails() { + reset_xdg + local code="${TEST_DIR}/code-bare" + make_code_dir "${code}" + + local err + err="$(szecret_at "${code}" list 2>&1 >/dev/null)" && fail "list should fail without a store" + assert_contains "${err}" "no szecret store" "error message" + pass "no store: commands fail with instructions" +} + +test_no_store_ready_is_quiet() { + reset_xdg + local code="${TEST_DIR}/code-bare" + make_code_dir "${code}" + + local err + err="$(szecret_at "${code}" ready 2>&1)" && fail "ready should fail without a store" + assert_equals "" "${err}" "ready output" + pass "no store: ready fails silently" +} + +test_data_dir_store() { + reset_xdg + local code="${TEST_DIR}/code-bare" + make_code_dir "${code}" + + # The store and public key live elsewhere and are linked in, as on a host. + local elsewhere="${TEST_DIR}/checkout/szecret" + mkdir -p "${elsewhere}/store" + make_key "${elsewhere}/id.pub" + ln -s "${elsewhere}/store" "${TEST_DIR}/xdg/szecret/store" + ln -s "${elsewhere}/id.pub" "${TEST_DIR}/xdg/szecret/id.pub" + + echo "linked-value" | szecret_at "${code}" put "linked-secret" + assert_file_exists "${elsewhere}/store/linked-secret.age" "secret written through the link" + assert_equals "linked-value" "$(szecret_at "${code}" get "linked-secret")" "round trip" + pass "store and id.pub are found in the data dir" +} + +test_data_dir_wins_over_a_stray_store() { + reset_xdg + local code="${TEST_DIR}/code-stray" + make_code_dir "${code}" + mkdir -p "${code}/store" + make_key "${code}/.id.pub" + + mkdir -p "${TEST_DIR}/xdg/szecret/store" + make_key "${TEST_DIR}/xdg/szecret/id.pub" + + echo "v" | szecret_at "${code}" put "where" + assert_file_exists "${TEST_DIR}/xdg/szecret/store/where.age" "secret in data dir store" + if [[ -e "${code}/store/where.age" ]]; then + fail "secret was written to the store beside the scripts" + fi + pass "data dir is used even with a store beside the scripts" +} + +test_env_beats_data_dir() { + reset_xdg + mkdir -p "${TEST_DIR}/xdg/szecret/store" + make_key "${TEST_DIR}/xdg/szecret/id.pub" + + # The common.sh setup exported SZECRET_STORE and friends; use them as-is. + echo "v" | XDG_DATA_HOME="${TEST_DIR}/xdg" szecret put "env-wins" + assert_file_exists "${SZECRET_STORE}/env-wins.age" "secret in SZECRET_STORE" + if [[ -e "${TEST_DIR}/xdg/szecret/store/env-wins.age" ]]; then + fail "secret was written to the data dir store" + fi + pass "SZECRET_STORE takes precedence over the data dir" +} + +run_tests() { + test_no_store_fails + test_no_store_ready_is_quiet + test_data_dir_store + test_data_dir_wins_over_a_stray_store + test_env_beats_data_dir +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/put.sh b/secrets/test/put.sh new file mode 100755 index 0000000..7cfb117 --- /dev/null +++ b/secrets/test/put.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Tests for put command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_put_creates_file() { + echo "test-value" | szecret put "test-secret" + assert_file_exists "${SZECRET_STORE}/test-secret.age" "put creates encrypted file" + pass "put creates file" +} + +test_put_overwrites() { + echo "original" | szecret put "overwrite-test" + echo "updated" | szecret put "overwrite-test" + + local retrieved + retrieved="$(szecret get "overwrite-test")" + assert_equals "updated" "${retrieved}" "overwritten value" + pass "put overwrites existing secret" +} + +test_put_requires_name() { + assert_failure szecret put + pass "put requires secret name" +} + +run_tests() { + test_put_creates_file + test_put_overwrites + test_put_requires_name +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/ready.sh b/secrets/test/ready.sh new file mode 100755 index 0000000..26a000c --- /dev/null +++ b/secrets/test/ready.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Tests for ready command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +test_ready_with_key() { + assert_success szecret ready + pass "ready succeeds with private key" +} + +test_ready_without_key() { + local saved_key="${SZECRET_PRIVATE_KEY}" + export SZECRET_PRIVATE_KEY="${TEST_DIR}/nonexistent-key" + + assert_failure szecret ready + + export SZECRET_PRIVATE_KEY="${saved_key}" + pass "ready fails without private key" +} + +run_tests() { + test_ready_with_key + test_ready_without_key +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/rotate.sh b/secrets/test/rotate.sh new file mode 100755 index 0000000..02abe39 --- /dev/null +++ b/secrets/test/rotate.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Tests for rotate command + +this_dir="$(cd "$(dirname "$0")" && pwd)" +source "${this_dir}/common.sh" + +setup_key_dir_layout() { + local keys_dir="${TEST_DIR}/keys" + mkdir -p "${keys_dir}" "${SZECRET_STORE}" + rm -f "${SZECRET_STORE}"/*.age + unset SZECRET_PRIVATE_KEY + export SZECRET_PRIVATE_KEY_DIR="${keys_dir}" + + local temp_key="${TEST_DIR}/initial-key" + age-keygen -o "${temp_key}" 2>/dev/null + local pub + pub="$(age-keygen -y "${temp_key}")" + echo "${pub}" > "${SZECRET_PUBLIC_KEY}" + mv "${temp_key}" "${keys_dir}/${pub}" + chmod 600 "${keys_dir}/${pub}" + SETUP_PUB="${pub}" +} + +test_rotate_reencrypts_secrets() { + setup_key_dir_layout + local old_pub="${SETUP_PUB}" + + echo "rotate-me" | szecret put "rotate-test" + echo "other" | szecret put "rotate-other" + + szecret rotate --yes + + local new_pub + new_pub="$(cat "${SZECRET_PUBLIC_KEY}")" + if [[ "${new_pub}" == "${old_pub}" ]]; then + fail "public key should change after rotate" + fi + + assert_equals "rotate-me" "$(szecret get "rotate-test")" "rotated secret" + assert_equals "other" "$(szecret get "rotate-other")" "other rotated secret" + assert_file_exists "${SZECRET_PRIVATE_KEY_DIR}/${new_pub}" "new private key file" + pass "rotate re-encrypts secrets and updates keys" +} + +test_rotate_empty_store() { + setup_key_dir_layout >/dev/null + + szecret rotate --yes + + local new_pub + new_pub="$(cat "${SZECRET_PUBLIC_KEY}")" + assert_file_exists "${SZECRET_PRIVATE_KEY_DIR}/${new_pub}" "new private key with empty store" + pass "rotate succeeds with empty store" +} + +test_rotate_requires_confirmation() { + setup_key_dir_layout >/dev/null + + assert_failure szecret rotate + pass "rotate fails without confirmation" +} + +run_tests() { + test_rotate_reencrypts_secrets + test_rotate_empty_store + test_rotate_requires_confirmation +} + +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && run_tests diff --git a/secrets/test/run.sh b/secrets/test/run.sh new file mode 100755 index 0000000..0969618 --- /dev/null +++ b/secrets/test/run.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Test runner for szecret tests +# Runs all test files or specific ones passed as arguments +# Each test file runs in its own isolated environment + +set -euo pipefail + +this_dir="$(cd "$(dirname "$0")" && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +# Find all test files (excluding common.sh and run.sh) +find_test_files() { + find "${this_dir}" -maxdepth 1 -name '*.sh' -type f \ + | xargs -n1 basename \ + | grep -Ev '^(common|run)\.sh$' \ + | sort +} + +run_test_file() { + local test_file="$1" + local test_path="${this_dir}/${test_file}" + + if [[ ! -x "${test_path}" ]]; then + chmod +x "${test_path}" + fi + + echo "--- ${test_file%.sh} ---" + + # Run each test file in its own subprocess with isolated environment + if "${test_path}"; then + return 0 + else + return 1 + fi +} + +main() { + echo "=== szecret test suite ===" + echo + + local test_files=() + local failed=0 + + if [[ $# -gt 0 ]]; then + # Run specific test files + for arg in "$@"; do + # Allow both "list" and "list.sh" + local file="${arg%.sh}.sh" + if [[ -f "${this_dir}/${file}" ]]; then + test_files+=("${file}") + else + echo "Unknown test file: ${arg}" >&2 + exit 1 + fi + done + else + # Run all test files + while IFS= read -r file; do + test_files+=("${file}") + done < <(find_test_files) + fi + + for test_file in "${test_files[@]}"; do + if ! run_test_file "${test_file}"; then + failed=1 + fi + echo + done + + if [[ ${failed} -eq 0 ]]; then + echo -e "${GREEN}=== All tests passed ===${NC}" + else + echo -e "${RED}=== Some tests failed ===${NC}" + exit 1 + fi +} + +main "$@" |
