feat(a): improve encryption script with better error handling

- Add dependency check for age and curl with install instructions
- Add --delete flag to remove originals after encryption
- Add -f/--force flag to control overwrite behavior
- Skip already-encrypted .age files during encryption
- Include hidden files (dotglob) when encrypting directories
- Handle empty directories gracefully with nullglob
- Allow flags in any position (proper option parsing)
- Add set -euo pipefail for better error handling
- Update documentation with all features and examples
- Bump version to 1.1.0
This commit is contained in:
2026-02-06 01:51:01 +02:00
parent cfde007494
commit 13dd701eb7
2 changed files with 264 additions and 72 deletions

View File

@@ -1,7 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# A script for encrypting and decrypting files or directories with age and SSH keys # A script for encrypting and decrypting files or directories with age and SSH keys
VERSION="1.0.0" set -euo pipefail
VERSION="1.1.0"
# Default ENV values # Default ENV values
KEYS_FILE="${AGE_KEYSFILE:-$HOME/.ssh/keys.txt}" KEYS_FILE="${AGE_KEYSFILE:-$HOME/.ssh/keys.txt}"
@@ -9,14 +11,49 @@ KEYS_SOURCE="${AGE_KEYSSOURCE:-https://github.com/ivuorinen.keys}"
LOG_FILE="${AGE_LOGFILE:-$HOME/.cache/a.log}" LOG_FILE="${AGE_LOGFILE:-$HOME/.cache/a.log}"
VERBOSE=false VERBOSE=false
DELETE_ORIGINAL=false
FORCE=false
# Parse flags for verbosity # Check for required dependencies
for arg in "$@"; do check_dependencies()
if [[ "$arg" == "-v" || "$arg" == "--verbose" ]]; then {
VERBOSE=true if ! command -v age &> /dev/null; then
break echo "Error: 'age' is not installed. Please install it first." >&2
echo " brew install age # macOS" >&2
echo " apt install age # Debian/Ubuntu" >&2
echo " dnf install age # Fedora" >&2
exit 1
fi fi
done
if ! command -v curl &> /dev/null; then
echo "Error: 'curl' is not installed." >&2
exit 1
fi
}
# Parse flags
parse_flags()
{
local args=()
for arg in "$@"; do
case "$arg" in
-v | --verbose)
VERBOSE=true
;;
--delete)
DELETE_ORIGINAL=true
;;
-f | --force)
FORCE=true
;;
*)
args+=("$arg")
;;
esac
done
# Return remaining arguments
printf '%s\n' "${args[@]}"
}
# Ensure log directory and file exist with correct permissions # Ensure log directory and file exist with correct permissions
prepare_log_file() prepare_log_file()
@@ -38,8 +75,6 @@ prepare_log_file()
chmod 0600 "$LOG_FILE" chmod 0600 "$LOG_FILE"
} }
prepare_log_file
# Logging function # Logging function
log_message() log_message()
{ {
@@ -56,7 +91,7 @@ log_message()
print_help() print_help()
{ {
cat << EOF cat << EOF
Usage: a [command] [file_or_directory] [options] Usage: a [options] [command] [file_or_directory]
Commands: Commands:
e, enc, encrypt Encrypt the specified file or directory e, enc, encrypt Encrypt the specified file or directory
@@ -65,12 +100,14 @@ Commands:
version, --version Show version information version, --version Show version information
Options: Options:
-v, --verbose Print log messages to console in addition to writing to log file -v, --verbose Print log messages to console
--delete Delete original files after successful encryption
-f, --force Overwrite existing output files without prompting
Environment Variables: Environment Variables:
AGE_KEYSFILE Path to the SSH keys file (default: $HOME/.ssh/keys.txt) AGE_KEYSFILE Path to the SSH keys file (default: \$HOME/.ssh/keys.txt)
AGE_KEYSSOURCE URL to fetch SSH keys if keys file does not exist AGE_KEYSSOURCE URL to fetch SSH keys if keys file does not exist
AGE_LOGFILE Path to the log file (default: $HOME/.cache/a.log) AGE_LOGFILE Path to the log file (default: \$HOME/.cache/a.log)
Examples: Examples:
Encrypt a file: Encrypt a file:
@@ -79,14 +116,21 @@ Examples:
Encrypt a directory: Encrypt a directory:
a e /path/to/directory a e /path/to/directory
Encrypt and delete originals:
a --delete e file.txt
Decrypt a file: Decrypt a file:
a d file.txt.age a d file.txt.age
Force overwrite existing files:
a -f e file.txt
Specify a custom keys file: Specify a custom keys file:
AGE_KEYSFILE=/path/to/keys.txt a e file.txt AGE_KEYSFILE=/path/to/keys.txt a e file.txt
Specify a custom keys source and log file: Requirements:
AGE_KEYSSOURCE=https://example.com/keys.txt AGE_LOGFILE=/tmp/a.log a d file.txt.age - age (encryption tool): https://github.com/FiloSottile/age
- curl (for fetching keys)
EOF EOF
} }
@@ -115,26 +159,104 @@ fetch_keys_if_missing()
fi fi
} }
# Function to encrypt a single file
encrypt_single_file()
{
local file="$1"
# Skip already encrypted files
if [[ "$file" == *.age ]]; then
log_message "Skipping already encrypted file: $file"
return 0
fi
local output_file="${file}.age"
# Check if output file exists
if [[ -f "$output_file" && "$FORCE" != true ]]; then
log_message "Error: Output file '$output_file' already exists. Use --force to overwrite."
return 1
fi
fetch_keys_if_missing
local temp_file
temp_file="$(mktemp -p "$(dirname "$file")")"
if age -R "$KEYS_FILE" "$file" > "$temp_file" && mv "$temp_file" "$output_file"; then
log_message "File encrypted successfully: $output_file"
if [[ "$DELETE_ORIGINAL" == true ]]; then
rm -f "$file"
log_message "Original file deleted: $file"
fi
else
rm -f "$temp_file"
log_message "Error: Failed to encrypt file '$file'."
return 1
fi
}
# Function to encrypt files or directories # Function to encrypt files or directories
encrypt_file_or_directory() encrypt_file_or_directory()
{ {
local file="$1" local file="$1"
if [[ -d "$file" ]]; then if [[ -d "$file" ]]; then
for f in "$file"/*; do # Enable dotglob to include hidden files
shopt -s dotglob nullglob
local files=("$file"/*)
shopt -u dotglob nullglob
if [[ ${#files[@]} -eq 0 ]]; then
log_message "Warning: Directory '$file' is empty."
return 0
fi
for f in "${files[@]}"; do
encrypt_file_or_directory "$f" encrypt_file_or_directory "$f"
done done
elif [[ -f "$file" ]]; then elif [[ -f "$file" ]]; then
fetch_keys_if_missing encrypt_single_file "$file"
local output_file="${file}.age" else
local temp_file log_message "Warning: '$file' is not a file or directory, skipping."
temp_file="$(mktemp -p "$(dirname "$file")")" fi
if age -R "$KEYS_FILE" "$file" > "$temp_file" && mv "$temp_file" "$output_file"; then }
log_message "File encrypted successfully: $output_file"
else # Function to decrypt a single file
rm -f "$temp_file" decrypt_single_file()
log_message "Error: Failed to encrypt file '$file'." {
exit 1 local file="$1"
if [[ ! "$file" == *.age ]]; then
log_message "Skipping non-.age file: $file"
return 0
fi
local output_file="${file%.age}"
# Check if output file exists
if [[ -f "$output_file" && "$FORCE" != true ]]; then
log_message "Error: Output file '$output_file' already exists. Use --force to overwrite."
return 1
fi
fetch_keys_if_missing
local temp_file
temp_file="$(mktemp -p "$(dirname "$file")")"
if age -d -i "$KEYS_FILE" "$file" > "$temp_file" && mv "$temp_file" "$output_file"; then
log_message "File decrypted successfully: $output_file"
if [[ "$DELETE_ORIGINAL" == true ]]; then
rm -f "$file"
log_message "Encrypted file deleted: $file"
fi fi
else
rm -f "$temp_file"
log_message "Error: Failed to decrypt file '$file'."
return 1
fi fi
} }
@@ -142,54 +264,76 @@ encrypt_file_or_directory()
decrypt_file_or_directory() decrypt_file_or_directory()
{ {
local file="$1" local file="$1"
if [[ -d "$file" ]]; then if [[ -d "$file" ]]; then
for f in "$file"/*.age; do # Enable nullglob to handle no matches gracefully
decrypt_file_or_directory "$f" shopt -s nullglob
local files=("$file"/*.age)
shopt -u nullglob
if [[ ${#files[@]} -eq 0 ]]; then
log_message "Warning: No .age files found in directory '$file'."
return 0
fi
for f in "${files[@]}"; do
decrypt_single_file "$f"
done done
elif [[ -f "$file" ]]; then elif [[ -f "$file" ]]; then
fetch_keys_if_missing decrypt_single_file "$file"
local output_file="${file%.age}" else
local temp_file log_message "Warning: '$file' is not a file or directory, skipping."
temp_file="$(mktemp -p "$(dirname "$file")")"
if age -d -i "$KEYS_FILE" "$file" > "$temp_file" && mv "$temp_file" "$output_file"; then
log_message "File decrypted successfully: $output_file"
else
rm -f "$temp_file"
log_message "Error: Failed to decrypt file '$file'."
exit 1
fi
fi fi
} }
# Main logic # Main entry point
case "$1" in main()
e | enc | encrypt) {
if [[ $# -lt 2 ]]; then check_dependencies
log_message "Error: No file or directory specified for encryption."
# Parse flags and get remaining arguments
mapfile -t ARGS < <(parse_flags "$@")
prepare_log_file
local command="${ARGS[0]:-}"
local target="${ARGS[1]:-}"
case "$command" in
e | enc | encrypt)
if [[ -z "$target" ]]; then
log_message "Error: No file or directory specified for encryption."
print_help
exit 1
fi
encrypt_file_or_directory "$target"
;;
d | dec | decrypt)
if [[ -z "$target" ]]; then
log_message "Error: No file or directory specified for decryption."
print_help
exit 1
fi
decrypt_file_or_directory "$target"
;;
help | --help | -h)
print_help
;;
version | --version)
print_version
;;
"")
print_help print_help
exit 1 exit 1
fi ;;
encrypt_file_or_directory "$2" *)
;; log_message "Error: Unknown command '$command'"
d | dec | decrypt)
if [[ $# -lt 2 ]]; then
log_message "Error: No file or directory specified for decryption."
print_help print_help
exit 1 exit 1
fi ;;
decrypt_file_or_directory "$2" esac
;; }
help | --help)
print_help main "$@"
;;
version | --version)
print_version
;;
*)
log_message "Error: Unknown command '$1'"
print_help
exit 1
;;
esac
# vim: ft=bash:syn=sh:ts=2:sw=2:et:ai:nowrap # vim: ft=bash:syn=sh:ts=2:sw=2:et:ai:nowrap

View File

@@ -2,28 +2,76 @@
Encrypt or decrypt files and directories using `age` and your GitHub SSH keys. Encrypt or decrypt files and directories using `age` and your GitHub SSH keys.
## Requirements
- [age](https://github.com/FiloSottile/age) - encryption tool
- curl - for fetching SSH keys
Install age:
```bash
brew install age # macOS
apt install age # Debian/Ubuntu
dnf install age # Fedora
```
## Usage ## Usage
```bash ```bash
a encrypt <file|dir> a [options] <command> <file|directory>
a decrypt <file.age|dir>
``` ```
Commands:
- `e`, `enc`, `encrypt` - encrypt files
- `d`, `dec`, `decrypt` - decrypt files
- `help`, `--help`, `-h` - show help
- `version`, `--version` - show version
Options: Options:
- `-v`, `--verbose` show log output - `-v`, `--verbose` - show log output
- `--delete` - delete original files after successful operation
- `-f`, `--force` - overwrite existing output files
Environment variables: Environment variables:
- `AGE_KEYSFILE` location of the keys file - `AGE_KEYSFILE` - location of the keys file (default: `~/.ssh/keys.txt`)
- `AGE_KEYSSOURCE` URL to fetch keys if missing - `AGE_KEYSSOURCE` - URL to fetch keys if missing (default: GitHub keys)
- `AGE_LOGFILE` log file path - `AGE_LOGFILE` - log file path (default: `~/.cache/a.log`)
## Example ## Examples
```bash ```bash
# Encrypt a file
a encrypt secret.txt a encrypt secret.txt
# Encrypt with short command
a e secret.txt
# Decrypt a file
a decrypt secret.txt.age a decrypt secret.txt.age
a d secret.txt.age
# Encrypt a directory (includes hidden files)
a e /path/to/secrets/
# Encrypt and delete originals
a --delete e secret.txt
# Force overwrite existing .age file
a -f e secret.txt
# Verbose output
a -v e secret.txt
``` ```
## Behavior
- Encrypting a directory processes all files recursively, including hidden files
- Already encrypted files (`.age`) are skipped during encryption
- Only `.age` files are processed during directory decryption
- Original files are preserved by default (use `--delete` to remove them)
- Output files are not overwritten by default (use `--force` to overwrite)
<!-- vim: set ft=markdown spell spelllang=en_us cc=80 : --> <!-- vim: set ft=markdown spell spelllang=en_us cc=80 : -->