pashage

Yet Another Opinionated Re-engineering of the Unix Password Store
git clone https://git.instinctive.eu/pashage.git
Log | Files | Refs | README | LICENSE

pashage.sh (41708B)


      1 #!/bin/sh
      2 # pashage - age-backed POSIX password manager
      3 # Copyright (C) 2024-2025  Natasha Kerensikova
      4 #
      5 # This program is free software; you can redistribute it and/or
      6 # modify it under the terms of the GNU General Public License
      7 # as published by the Free Software Foundation; either version 2
      8 # of the License, or (at your option) any later version.
      9 #
     10 # This program is distributed in the hope that it will be useful,
     11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
     12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     13 # GNU General Public License for more details.
     14 #
     15 # You should have received a copy of the GNU General Public License
     16 # along with this program; if not, write to the Free Software
     17 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
     18 
     19 NL="$(printf '\nend')"
     20 NL="${NL%end}"
     21 
     22 #############################
     23 # INTERNAL HELPER FUNCTIONS #
     24 #############################
     25 
     26 # Check a path and abort if it looks suspicious
     27 #   $1: path to check
     28 check_sneaky_path() {
     29 	if [ "$1" = ".." ] \
     30 	    || [ "$1" = "../${1#../}" ] \
     31 	    || [ "$1" = "${1%/..}/.." ] \
     32 	    || ! [ "$1" = "${1##*/../}" ] \
     33 	    && [ -n "$1" ]
     34 	then
     35 		die "Encountered path considered sneaky: \"$1\""
     36 	fi
     37 }
     38 
     39 # Check paths and abort if any looks suspicious
     40 check_sneaky_paths() {
     41 	for ARG in "$@"; do
     42 		check_sneaky_path "${ARG}"
     43 	done
     44 	unset ARG
     45 }
     46 
     47 # Run the arguments as a command an die on failure
     48 checked() {
     49 	if "$@"; then
     50 		:
     51 	else
     52 		CODE="$?"
     53 		printf '%s\n' "Fatal(${CODE}): $*" >&2
     54 		exit "${CODE}"
     55 	fi
     56 }
     57 
     58 # Output an error message and quit immediately
     59 die() {
     60 	printf '%s\n' "$*" >&2
     61 	exit 1
     62 }
     63 
     64 # Checks whether a globs expands correctly
     65 # This lets the shell expand the glob as an argument list, and counts on
     66 # the glob being passed unchanged as $1 otherwise.
     67 glob_exists() {
     68 	if [ -e "$1" ]; then
     69 		ANSWER=y
     70 	else
     71 		ANSWER=n
     72 	fi
     73 }
     74 
     75 # Always-successful grep filter
     76 #   ... grep arguments
     77 grep_filter() {
     78 	grep "$@" || true
     79 }
     80 
     81 # Generate random characters
     82 #   $1: number of characters
     83 #   $2: allowed character set
     84 random_chars() {
     85 	( export LC_ALL=C; \
     86 	  tr -dc -- "$2" </dev/urandom | dd ibs=1 obs=1 count="$1" \
     87 	    2>/dev/null || true )
     88 }
     89 
     90 # Find the deepest recipient file above the given path
     91 set_LOCAL_RECIPIENT_FILE() {
     92 	LOCAL_RECIPIENT_FILE="/$1"
     93 
     94 	while [ -n "${LOCAL_RECIPIENT_FILE}" ] \
     95 	   && ! [ -f "${PREFIX}${LOCAL_RECIPIENT_FILE}/.age-recipients" ]
     96 	do
     97 		LOCAL_RECIPIENT_FILE="${LOCAL_RECIPIENT_FILE%/*}"
     98 	done
     99 
    100 	if ! [ -f "${PREFIX}${LOCAL_RECIPIENT_FILE}/.age-recipients" ]; then
    101 		LOCAL_RECIPIENT_FILE=
    102 		LOCAL_RECIPIENTS=
    103 		return 0
    104 	fi
    105 
    106 	LOCAL_RECIPIENT_FILE="${PREFIX}${LOCAL_RECIPIENT_FILE}/.age-recipients"
    107 	LOCAL_RECIPIENTS="$(cat "${LOCAL_RECIPIENT_FILE}")"
    108 }
    109 
    110 # Count how many characters are in the first argument
    111 #   $1: string to measure
    112 strlen(){
    113 	RESULT=0
    114 	STR="$1"
    115 	while [ -n "${STR}" ]; do
    116 		RESULT=$((RESULT + 1))
    117 		STR="${STR#?}"
    118 	done
    119 	printf '%s\n' "${RESULT}"
    120 	unset RESULT
    121 	unset STR
    122 }
    123 
    124 # Ask for confirmation
    125 #   $1: Prompt
    126 yesno() {
    127 	printf '%s [y/n]' "$1"
    128 
    129 	if type stty >/dev/null 2>&1 && stty >/dev/null 2>&1; then
    130 
    131 		# Enable raw input to allow for a single byte to be read from
    132 		# stdin without needing to wait for the user to press Return.
    133 		stty -icanon
    134 
    135 		ANSWER=''
    136 
    137 		while [ "${ANSWER}" = "${ANSWER#[NnYy]}" ]; do
    138 			# Read a single byte from stdin using 'dd'.
    139 			# POSIX 'read' has no support for single/'N' byte
    140 			# based input from the user.
    141 			ANSWER=$(dd ibs=1 count=1 2>/dev/null)
    142 		done
    143 
    144 		# Disable raw input, leaving the terminal how we *should*
    145 		# have found it.
    146 		stty icanon
    147 
    148 		printf '\n'
    149 	else
    150 		read -r ANSWER
    151 		ANSWER="${ANSWER%"${ANSWER#?}"}"
    152 	fi
    153 
    154 	if [ "${ANSWER}" = Y ]; then
    155 		ANSWER=y
    156 	fi
    157 }
    158 
    159 
    160 ##################
    161 # SCM MANAGEMENT #
    162 ##################
    163 
    164 # Add a file or directory to pending changes
    165 #   $1: path
    166 scm_add() {
    167 	[ -d "${PREFIX}/.git" ] || return 0
    168 	git -C "${PREFIX}" add -- "$1"
    169 }
    170 
    171 # Start a sequence of changes, asserting nothing is pending
    172 scm_begin() {
    173 	[ -d "${PREFIX}/.git" ] || return 0
    174 	if [ -n "$(git -C "${PREFIX}" status --porcelain || true)" ]; then
    175 		die "There are already pending changes."
    176 	fi
    177 }
    178 
    179 # Commit pending changes
    180 #   $1: commit message
    181 scm_commit() {
    182 	[ -d "${PREFIX}/.git" ] || return 0
    183 	if [ -n "$(git -C "${PREFIX}" status --porcelain || true)" ]; then
    184 		git -C "${PREFIX}" commit -m "$1" >/dev/null
    185 	fi
    186 }
    187 
    188 # Copy a file or directory in the filesystem and put it in pending changes
    189 #   $1: source
    190 #   $2: destination
    191 scm_cp() {
    192 	cp -rf -- "${PREFIX}/$1" "${PREFIX}/$2"
    193 	scm_add "$2"
    194 }
    195 
    196 # Add deletion of a file or directory to pending changes
    197 #   $1: path
    198 scm_del() {
    199 	[ -d "${PREFIX}/.git" ] || return 0
    200 	git -C "${PREFIX}" rm -qr -- "$1"
    201 }
    202 
    203 # Move a file or directory in the filesystem and put it in pending changes
    204 #   $1: source
    205 #   $2: destination
    206 scm_mv() {
    207 	if [ -d "${PREFIX}/.git" ]; then
    208 		git -C "${PREFIX}" mv -f -- "$1" "$2"
    209 	else
    210 		mv -f -- "${PREFIX}/$1" "${PREFIX}/$2"
    211 	fi
    212 }
    213 
    214 # Delete a file or directory from filesystem and put it in pending changes
    215 scm_rm() {
    216 	rm -rf -- "${PREFIX:?}/$1"
    217 	scm_del "$1"
    218 }
    219 
    220 
    221 ###########
    222 # ACTIONS #
    223 ###########
    224 
    225 # Copy or move (depending on ${ACTION}) a secret file or directory
    226 #   $1: source name
    227 #   $2: destination name
    228 #   ACTION: Copy or Move
    229 #   DECISION: whether to re-encrypt or copy/move
    230 #   OVERWRITE: whether to overwrite without confirmation
    231 #   SCM_ACTION: scm_cp or scm_mv
    232 do_copy_move() {
    233 	if [ "$1" = "${1%/}/" ]; then
    234 		if ! [ -d "${PREFIX}/$1" ]; then
    235 			die "Error: $1 is not in the password store."
    236 		fi
    237 		SRC="$1"
    238 	elif [ -e "${PREFIX}/$1.age" ] && ! [ -d "${PREFIX}/$1.age" ]; then
    239 		SRC="$1.age"
    240 	elif [ -n "$1" ] && [ -d "${PREFIX}/$1" ]; then
    241 		SRC="$1/"
    242 	elif [ -e "${PREFIX}/$1" ]; then
    243 		SRC="$1"
    244 	else
    245 		die "Error: $1 is not in the password store."
    246 	fi
    247 
    248 	if [ -z "${SRC}" ] || [ "${SRC}" = "${SRC%/}/" ]; then
    249 		LOCAL_ACTION=do_copy_move_dir
    250 		if [ -d "${PREFIX}/$2" ]; then
    251 			DEST="${2%/}${2:+/}$(basename "${SRC%/}")/"
    252 			if [ -e "${PREFIX}/${DEST}" ] \
    253 			    && [ "${ACTION}" = Move ]
    254 			then
    255 				die "Error: $2 already contains" \
    256 				    "$(basename "${SRC%/}")"
    257 			fi
    258 		else
    259 			DEST="${2%/}${2:+/}"
    260 			if [ -e "${PREFIX}/${DEST%/}" ]; then
    261 				die "Error: ${DEST%/} is not a directory"
    262 			fi
    263 		fi
    264 
    265 	elif [ "$2" = "${2%/}/" ] || [ -d "${PREFIX}/$2" ]; then
    266 		[ -d "${PREFIX}/$2" ] || mkdir -p -- "${PREFIX}/$2"
    267 		[ -d "${PREFIX}/$2" ] || die "Error: $2 is not a directory"
    268 
    269 		DEST="${2%/}/$(basename "${SRC}")"
    270 		if [ -d "${PREFIX}/${DEST}" ]; then
    271 			die "Error: $2 already contains $(basename "${SRC}")/"
    272 		fi
    273 		LOCAL_ACTION=do_copy_move_file
    274 
    275 	else
    276 		if [ "${SRC}" = "${SRC%.age}.age" ] \
    277 		    && ! [ "$2" = "${2%.age}.age" ]
    278 		then
    279 			DEST="$2.age"
    280 		else
    281 			DEST="$2"
    282 		fi
    283 
    284 		mkdir -p -- "$(dirname "${PREFIX}/${DEST}")"
    285 		LOCAL_ACTION=do_copy_move_file
    286 	fi
    287 
    288 	scm_begin
    289 	SCM_COMMIT_MSG="${ACTION} ${SRC} to ${DEST}"
    290 
    291 	"${LOCAL_ACTION}" "${SRC}" "${DEST}"
    292 
    293 	scm_commit "${SCM_COMMIT_MSG}"
    294 
    295 	unset LOCAL_ACTION
    296 	unset SRC
    297 	unset DEST
    298 	unset SCM_COMMIT_MSG
    299 }
    300 
    301 # Copy or move a secret directory (depending on ${ACTION})
    302 #   $1: source directory name (with a trailing slash)
    303 #   $2: destination directory name (with a trailing slash)
    304 #   DECISION: whether to re-encrypt or copy/move
    305 #   SCM_ACTION: scm_cp or scm_mv
    306 do_copy_move_dir() {
    307 	[ "$1" = "${1%/}/" ] || [ -z "$1" ] || die 'Internal error'
    308 	[ "$2" = "${2%/}/" ] || [ -z "$2" ] || die 'Internal error'
    309 	[ -d "${PREFIX}/$1" ] || die 'Internal error'
    310 
    311 	[ -d "${PREFIX}/$2" ] || mkdir -p -- "${PREFIX}/${2%/}"
    312 
    313 	for ARG in "${PREFIX}/$1".* "${PREFIX}/$1"*; do
    314 		SRC="${ARG#"${PREFIX}/"}"
    315 		DEST="$2$(basename "${ARG}")"
    316 
    317 		if [ -f "${ARG}" ]; then
    318 			do_copy_move_file "${SRC}" "${DEST}"
    319 		elif [ -d "${ARG}" ] && [ "${ARG}" = "${ARG%/.*}" ]
    320 		then
    321 			do_copy_move_dir "${SRC}/" "${DEST}/"
    322 		fi
    323 	done
    324 
    325 	unset ARG
    326 	rmdir -p -- "${PREFIX}/$1" 2>/dev/null || true
    327 }
    328 
    329 # Copy or move a secret file (depending on ${ACTION})
    330 #   $1: source file name
    331 #   $2: destination file name
    332 #   ACTION: Copy or Move
    333 #   DECISION: whether to re-encrypt or copy/move
    334 #   OVERWRITE: whether to overwrite without confirmation
    335 #   SCM_ACTION: scm_cp or scm_mv
    336 do_copy_move_file() {
    337 	if [ -e "${PREFIX}/$2" ]; then
    338 		if ! [ "${OVERWRITE}" = yes ]; then
    339 			yesno "$2 already exists. Overwrite?"
    340 			[ "${ANSWER}" = y ] || return 0
    341 			unset ANSWER
    342 		fi
    343 
    344 		rm -f -- "${PREFIX}/$2"
    345 	fi
    346 
    347 	if [ "$1" = "${1%.age}.age" ]; then
    348 		case "${DECISION}" in
    349 		    keep)
    350 			ANSWER=n
    351 			;;
    352 		    interactive)
    353 			yesno "Reencrypt ${1%.age} into ${2%.age}?"
    354 			;;
    355 		    default)
    356 			set_LOCAL_RECIPIENT_FILE "$1"
    357 			SRC_RCPT="${LOCAL_RECIPIENTS}"
    358 			set_LOCAL_RECIPIENT_FILE "$2"
    359 			DST_RCPT="${LOCAL_RECIPIENTS}"
    360 
    361 			if [ "${SRC_RCPT}" = "${DST_RCPT}" ]; then
    362 				ANSWER=n
    363 			else
    364 				ANSWER=y
    365 			fi
    366 
    367 			unset DST_RCPT
    368 			unset SRC_RCPT
    369 			;;
    370 		    force)
    371 			ANSWER=y
    372 			;;
    373 		    *)
    374 			die "Unexpected DECISION value \"${DECISION}\""
    375 			;;
    376 		esac
    377 	else
    378 		ANSWER=n
    379 	fi
    380 
    381 	if [ "${ANSWER}" = y ]; then
    382 		do_decrypt "${PREFIX}/$1" | do_encrypt "$2"
    383 		if [ "${ACTION}" = Move ]; then
    384 			scm_rm "$1"
    385 		fi
    386 		scm_add "$2"
    387 	else
    388 		"${SCM_ACTION}" "$1" "$2"
    389 	fi
    390 
    391 	unset ANSWER
    392 }
    393 
    394 # Decrypt a secret file into standard output
    395 #   $1: full path of the encrypted file
    396 #   IDENTITIES_FILE: full path of age identity
    397 do_decrypt() {
    398 	checked "${AGE}" -d -i "${IDENTITIES_FILE}" -- "$1"
    399 }
    400 
    401 # Decrypt a GPG secret file into standard output
    402 #   $1: pull path of the encrypted file
    403 #   GPG: (optional) gpg command
    404 do_decrypt_gpg() {
    405 	if [ -z "${GPG-}" ]; then
    406 		if type gpg2 >/dev/null 2>&1; then
    407 			GPG=gpg2
    408 		elif type gpg >/dev/null 2>&1; then
    409 			GPG=gpg
    410 		else
    411 			die "GPG does not seem available"
    412 		fi
    413 	fi
    414 
    415 	set -- -- "$@"
    416 	if [ -n "${GPG_AGENT_INFO-}" ] || [ "${GPG}" = "gpg2" ]; then
    417 		set -- "--batch" "--use-agent" "$@"
    418 	fi
    419 	set -- "--quiet" \
    420 	    "--yes" \
    421 	    "--compress-algo=none" \
    422 	    "--no-encrypt-to" \
    423 	    "$@"
    424 
    425 	checked "${GPG}" -d "$@"
    426 }
    427 
    428 # Remove identities from a subdirectory
    429 #   $1: relative subdirectory (may be empty)
    430 #   DECISION: whether to re-encrypt or not
    431 do_deinit() {
    432 	LOC="${1:-store root}"
    433 	TARGET="${1%/}${1:+/}.age-recipients"
    434 
    435 	if ! [ -f "${PREFIX}/${TARGET}" ]; then
    436 		die "No existing recipient to remove at ${LOC}"
    437 	fi
    438 
    439 	scm_begin
    440 	scm_rm "${TARGET}"
    441 	if ! [ "${DECISION}" = keep ]; then
    442 		do_reencrypt_dir "${PREFIX}/$1"
    443 	fi
    444 	scm_commit "Deinitialize ${LOC}"
    445 	rmdir -p -- "${PREFIX}/$1" 2>/dev/null || true
    446 
    447 	unset LOC
    448 	unset TARGET
    449 }
    450 
    451 # Delete a file or directory from the password store
    452 #   $1: file or directory name
    453 #   DECISION: whether to ask before deleting
    454 #   RECURSIVE: whether to delete directories
    455 do_delete() {
    456 	# Distinguish between file or directory
    457 	if [ "$1" = "${1%/}/" ]; then
    458 		NAME="$1"
    459 		TARGET="$1"
    460 		if ! [ -e "${PREFIX}/${NAME%/}" ]; then
    461 			die "Error: $1 is not in the password store."
    462 		fi
    463 		if ! [ -d "${PREFIX}/${NAME%/}" ]; then
    464 			die "Error: $1 is not a directory."
    465 		fi
    466 		if ! [ "${RECURSIVE}" = yes ]; then
    467 			die "Error: $1 is a directory"
    468 		fi
    469 	elif [ -f "${PREFIX}/$1.age" ]; then
    470 		NAME="$1"
    471 		TARGET="$1.age"
    472 	elif [ -d "${PREFIX}/$1" ]; then
    473 		if ! [ "${RECURSIVE}" = yes ]; then
    474 			die "Error: $1/ is a directory"
    475 		fi
    476 		NAME="$1/"
    477 		TARGET="$1/"
    478 	else
    479 		die "Error: $1 is not in the password store."
    480 	fi
    481 
    482 	if [ "${DECISION}" = force ]; then
    483 		printf '%s\n' "Removing ${NAME}"
    484 	else
    485 		yesno "Are you sure you would like to delete ${NAME}?"
    486 		[ "${ANSWER}" = y ] || return 0
    487 		unset ANSWER
    488 	fi
    489 
    490 	# Perform the deletion
    491 	scm_begin
    492 	scm_rm "${TARGET}"
    493 	scm_commit "Remove ${NAME} from store."
    494 	rmdir -p -- "$(dirname "${PREFIX}/${TARGET}")" 2>/dev/null || true
    495 }
    496 
    497 # Edit a secret interactively
    498 #   $1: pass-name
    499 #   EDIT_CMD, EDITOR, VISUAL: editor command
    500 do_edit() {
    501 	NAME="${1#/}"
    502 	TARGET="${PREFIX}/${NAME}.age"
    503 
    504 	TMPNAME="${NAME}"
    505 	while ! [ "${TMPNAME}" = "${TMPNAME#*/}" ]; do
    506 		TMPNAME="${TMPNAME%%/*}-${TMPNAME#*/}"
    507 	done
    508 
    509 	TMPFILE="$(mktemp -u "${SECURE_TMPDIR}/XXXXXX")-${TMPNAME}.txt"
    510 
    511 	if [ -f "${TARGET}" ]; then
    512 		ACTION="Edit"
    513 		do_decrypt "${TARGET}" >"${TMPFILE}"
    514 		OLD_VALUE="$(cat "${TMPFILE}")"
    515 	else
    516 		ACTION="Add"
    517 		OLD_VALUE=
    518 	fi
    519 
    520 	scm_begin
    521 
    522 	if [ -z "${EDIT_CMD-}" ]; then
    523 		if [ -n "${VISUAL-}" ] && ! [ "${TERM:-dumb}" = dumb ]; then
    524 			EDIT_CMD="${VISUAL}"
    525 		elif [ -n "${EDITOR-}" ]; then
    526 			EDIT_CMD="${EDITOR}"
    527 		else
    528 			EDIT_CMD="vi"
    529 		fi
    530 	fi
    531 
    532 	if ${EDIT_CMD} "${TMPFILE}"; then
    533 		:
    534 	else
    535 		CODE="$?"
    536 		printf 'Editor "%s" exited with code %s\n' \
    537 		    "${EDIT_CMD}" "${CODE}" >&2
    538 		exit "${CODE}"
    539 	fi
    540 
    541 	if ! [ -f "${TMPFILE}" ]; then
    542 		printf '%s\n' "New password for ${NAME} not saved."
    543 	elif [ -n "${OLD_VALUE}" ] \
    544 	    && printf '%s\n' "${OLD_VALUE}" \
    545 	        | diff -- - "${TMPFILE}" >/dev/null 2>&1
    546 	then
    547 		printf '%s\n' "Password for ${NAME} unchanged."
    548 		rm "${TMPFILE}"
    549 	else
    550 		OVERWRITE=once
    551 		do_encrypt "${NAME}.age" <"${TMPFILE}"
    552 		scm_add "${NAME}.age"
    553 		scm_commit "${ACTION} password for ${NAME} using ${EDIT_CMD}."
    554 		rm "${TMPFILE}"
    555 	fi
    556 
    557 	unset ACTION
    558 	unset OLD_VALUE
    559 	unset NAME
    560 	unset TARGET
    561 	unset TMPNAME
    562 	unset TMPFILE
    563 }
    564 
    565 # Encrypt a secret on standard input into a file
    566 #   $1: relative path of the encrypted file
    567 do_encrypt() {
    568 	TARGET="$1"
    569 	set --
    570 
    571 	if [ -n "${PASHAGE_RECIPIENTS_FILE-}" ]; then
    572 		set -- "$@" -R "${PASHAGE_RECIPIENTS_FILE}"
    573 
    574 	elif [ -n "${PASSAGE_RECIPIENTS_FILE-}" ]; then
    575 		set -- "$@" -R "${PASSAGE_RECIPIENTS_FILE}"
    576 
    577 	elif [ -n "${PASHAGE_RECIPIENTS-}" ]; then
    578 		for ARG in ${PASHAGE_RECIPIENTS}; do
    579 			set -- "$@" -r "${ARG}"
    580 		done
    581 		unset ARG
    582 
    583 	elif [ -n "${PASSAGE_RECIPIENTS-}" ]; then
    584 		for ARG in ${PASSAGE_RECIPIENTS}; do
    585 			set -- "$@" -r "${ARG}"
    586 		done
    587 		unset ARG
    588 
    589 	else
    590 		set_LOCAL_RECIPIENT_FILE "${TARGET}"
    591 
    592 		if [ -n "${LOCAL_RECIPIENT_FILE}" ]; then
    593 			set -- "$@" -R "${LOCAL_RECIPIENT_FILE}"
    594 		else
    595 			set -- "$@" -i "${IDENTITIES_FILE}"
    596 		fi
    597 	fi
    598 
    599 	unset LOCAL_RECIPIENT_FILE
    600 
    601 	if [ -e "${PREFIX}/${TARGET}" ] && ! [ "${OVERWRITE}" = yes ]; then
    602 		if [ "${OVERWRITE}" = once ]; then
    603 			OVERWRITE=no
    604 		else
    605 			die "Refusing to overwite ${TARGET}"
    606 		fi
    607 	fi
    608 	mkdir -p "$(dirname "${PREFIX}/${TARGET}")"
    609 	"${AGE}" -e "$@" -o "${PREFIX}/${TARGET}"
    610 	unset TARGET
    611 }
    612 
    613 # Generate a new secret
    614 #   $1: secret name
    615 #   $2: new password length
    616 #   $3: new password charset
    617 #   DECISION: when interactive, show-ask-commit instead of commit-show
    618 #   OVERWRITE: whether to overwrite with confirmation ("no"), without
    619 #              confirmation ("yes"), or with existing secret data ("reuse")
    620 #   SELECTED_LINE: which line to paste or diplay as qr-code
    621 #   SHOW: how to show the secret
    622 do_generate() {
    623 	NEW_PASS="$(random_chars "$2" "$3")"
    624 	NEW_PASS_LEN="$(strlen "${NEW_PASS}")"
    625 
    626 	if [ "${NEW_PASS_LEN}" -ne "$2" ]; then
    627 		die "Error while generating password:" \
    628 		    "${NEW_PASS_LEN}/$2 bytes read"
    629 	fi
    630 	unset NEW_PASS_LEN
    631 
    632 	if [ "${DECISION}" = interactive ]; then
    633 		do_generate_show "$@"
    634 		yesno "Save generated password for $1?"
    635 		[ "${ANSWER}" = y ] && do_generate_commit "$@"
    636 	else
    637 		do_generate_commit "$@"
    638 		[ "${ANSWER-y}" = y ] && do_generate_show "$@"
    639 	fi
    640 
    641 	unset NEW_PASS
    642 }
    643 
    644 # SCM-committing part of do_generate
    645 do_generate_commit() {
    646 	scm_begin
    647 	mkdir -p -- "$(dirname "${PREFIX}/$1.age")"
    648 	EXTRA=
    649 
    650 	if [ -d "${PREFIX}/$1.age" ]; then
    651 		die "Cannot replace directory $1.age"
    652 
    653 	elif [ -e "${PREFIX}/$1.age" ] && [ "${OVERWRITE}" = reuse ]; then
    654 		printf '%s\n' "Decrypting previous secret for $1"
    655 		OLD_SECRET_FULL="$(do_decrypt "${PREFIX}/$1.age")"
    656 		OLD_SECRET="${OLD_SECRET_FULL#*"${NL}"}"
    657 		if ! [ "${OLD_SECRET}" = "${OLD_SECRET_FULL}" ]; then
    658 			EXTRA="${OLD_SECRET}"
    659 		fi
    660 		unset OLD_SECRET
    661 		unset OLD_SECRET_FULL
    662 		OVERWRITE=once
    663 		VERB="Replace"
    664 
    665 	else
    666 		if [ -e "${PREFIX}/$1.age" ] && ! [ "${OVERWRITE}" = yes ]; then
    667 			yesno "An entry already exists for $1. Overwrite it?"
    668 			[ "${ANSWER}" = y ] || return 0
    669 			unset ANSWER
    670 			OVERWRITE=once
    671 		fi
    672 
    673 		VERB="Add"
    674 	fi
    675 
    676 	if [ "${MULTILINE}" = yes ]; then
    677 		echo 'Enter extra secrets then Ctrl+D when finished:'
    678 		while IFS='' read -r LINE; do
    679 			EXTRA="${EXTRA}${EXTRA:+${NL}}${LINE}"
    680 		done
    681 	fi
    682 
    683 	do_encrypt "$1.age" <<-EOF
    684 		${NEW_PASS}${EXTRA:+${NL}}${EXTRA}
    685 	EOF
    686 
    687 	unset EXTRA
    688 
    689 	scm_add "${PREFIX}/$1.age"
    690 	scm_commit "${VERB} generated password for $1."
    691 
    692 	unset VERB
    693 }
    694 
    695 # Showing part of do_generate
    696 do_generate_show() {
    697 	if [ "${SHOW}" = text ]; then
    698 		printf '%sThe generated password for %s%s%s is:%s\n' \
    699 		    "${BOLD_TEXT}" \
    700 		    "${UNDERLINE_TEXT}" \
    701 		    "$1" \
    702 		    "${NO_UNDERLINE_TEXT}" \
    703 		    "${NORMAL_TEXT}"
    704 	fi
    705 
    706 	do_show "$1" <<-EOF
    707 		${NEW_PASS}
    708 	EOF
    709 }
    710 
    711 # Recursively grep decrypted secrets in current directory
    712 #   $1: current subdirectory name
    713 #   ... grep arguments
    714 do_grep() {
    715 	SUBDIR="$1"
    716 	shift
    717 
    718 	glob_exists ./*
    719 	[ "${ANSWER}" = y ] || return 0
    720 	unset ANSWER
    721 
    722 	for ARG in *; do
    723 		if [ -d "${ARG}" ]; then
    724 			( cd "${ARG}" && do_grep "${SUBDIR}${ARG}/" "$@" )
    725 		elif [ "${ARG}" = "${ARG%.age}.age" ]; then
    726 			HEADER="${BLUE_TEXT}${SUBDIR}${BOLD_TEXT}"
    727 			HEADER="${HEADER}${ARG%.age}${NORMAL_TEXT}:"
    728 			SECRET="$(do_decrypt "${ARG}")"
    729 			do_grep_filter "$@" <<-EOF
    730 				${SECRET}
    731 			EOF
    732 		fi
    733 	done
    734 
    735 	unset ARG
    736 	unset HEADER
    737 }
    738 
    739 # Wrapper around grep filter to add a header when a match is found
    740 #   ... grep arguments
    741 #   HEADER header to print before matches, if any
    742 do_grep_filter() {
    743 	unset SECRET
    744 
    745 	grep_filter "$@" | while IFS= read -r LINE; do
    746 		[ -n "${HEADER}" ] && printf '%s\n' "${HEADER}"
    747 		printf '%s\n' "${LINE}"
    748 		HEADER=''
    749 	done
    750 }
    751 
    752 # Add identities to a subdirectory
    753 #   $1: relative subdirectory (may be empty)
    754 #   ... identities
    755 #   DECISION: whether to re-encrypt or not
    756 do_init() {
    757 	LOC="${1:-store root}"
    758 	SUBDIR="${PREFIX}${1:+/}${1%/}"
    759 	TARGET="${SUBDIR}/.age-recipients"
    760 	shift
    761 
    762 	mkdir -p -- "${SUBDIR}"
    763 
    764 	scm_begin
    765 
    766 	if ! [ -f "${TARGET}" ] || [ "${OVERWRITE}" = yes ]; then
    767 		: >|"${TARGET}"
    768 	fi
    769 
    770 	printf '%s\n' "$@" >>"${TARGET}"
    771 	scm_add "${TARGET#"${PREFIX}/"}"
    772 	if ! [ "${DECISION}" = keep ]; then
    773 		do_reencrypt_dir "${SUBDIR}"
    774 	fi
    775 	scm_commit "Set age recipients at ${LOC}"
    776 	printf '%s\n' "Password store recipients set at ${LOC}"
    777 
    778 	unset LOC
    779 	unset TARGET
    780 	unset SUBDIR
    781 }
    782 
    783 # Insert a new secret from standard input
    784 #   $1: entry name
    785 #   ECHO: whether interactive echo is kept
    786 #   MULTILINE: whether whole standard input is used
    787 #   OVERWRITE: whether to overwrite without confirmation
    788 do_insert() {
    789 	if [ -e "${PREFIX}/$1.age" ] && [ "${OVERWRITE}" = no ]; then
    790 		yesno "An entry already exists for $1. Overwrite it?"
    791 		[ "${ANSWER}" = y ] || return 0
    792 		unset ANSWER
    793 		OVERWRITE=once
    794 	fi
    795 
    796 	scm_begin
    797 	mkdir -p -- "$(dirname "${PREFIX}/$1.age")"
    798 
    799 	if [ "${MULTILINE}" = yes ]; then
    800 		printf '%s\n' \
    801 		    "Enter contents of $1 and" \
    802 		    "press Ctrl+D or enter an empty line when finished:"
    803 		while IFS= read -r LINE; do
    804 			if [ -n "${LINE}" ]; then
    805 				printf '%s\n' "${LINE}"
    806 			else
    807 				break
    808 			fi
    809 		done | do_encrypt "$1.age"
    810 
    811 	elif [ "${ECHO}" = yes ] \
    812 	    || ! type stty >/dev/null 2>&1 \
    813 	    || ! stty >/dev/null 2>&1
    814 	then
    815 		printf 'Enter password for %s: ' "$1"
    816 		IFS= read -r LINE
    817 		do_encrypt "$1.age" <<-EOF
    818 			${LINE}
    819 		EOF
    820 		unset LINE
    821 
    822 	else
    823 		while true; do
    824 			printf 'Enter password for %s:  ' "$1"
    825 			stty -echo
    826 			read -r LINE1
    827 			printf '\nRetype password for %s: ' "$1"
    828 			read -r LINE2
    829 			stty echo
    830 			printf '\n'
    831 
    832 			if [ "${LINE1}" = "${LINE2}" ]; then
    833 				break
    834 			else
    835 				unset LINE1 LINE2
    836 				echo "Passwords don't match"
    837 			fi
    838 		done
    839 
    840 		do_encrypt "$1.age" <<-EOF
    841 			${LINE1}
    842 		EOF
    843 		unset LINE1 LINE2
    844 	fi
    845 
    846 	scm_add "$1.age"
    847 	scm_commit "Add given password for $1 to store."
    848 }
    849 
    850 # Display the entry list rooted at the given relative directory
    851 #   $1: path relative to prefix
    852 #  ...: (optional) grep arguments to filter
    853 #   BEGIN_GPG_NAME: marker before gpg secret name
    854 #   END_GPG_NAME: marker after gpg secret name
    855 #   HAS_ITEMS: (output) set to `yes` when something has been printed
    856 #   LIST_EMPTY: include empty directories in output when set to `yes`
    857 # Note that this function is recrusive and cannot use variables to hold state
    858 # (except for HAS_ITEMS which is carefully designed with this constraint).
    859 do_list() {
    860 	for FULL_ENTRY in "${PREFIX}/$1${1:+/}"*; do
    861 		ENTRY="${FULL_ENTRY#"${PREFIX}/"}"
    862 		ITEM_NAME="${ENTRY##*/}"
    863 		if [ -d "${FULL_ENTRY}" ]; then
    864 			shift
    865 			set -- "${ENTRY}" "$@"
    866 			HAS_ITEMS=no
    867 			do_list "$@"
    868 			if [ "${LIST_EMPTY}${HAS_ITEMS}" = 'yesno' ]; then
    869 				printf '%s/\n' "$1"
    870 			fi
    871 			HAS_ITEMS=yes
    872 		elif [ "${ENTRY%.age}.age" = "${ENTRY}" ]; then
    873 			shift
    874 			set -- "-q" "$@"
    875 			if [ $# -le 1 ] \
    876 			    || printf '%s\n' "${ITEM_NAME%.age}" | grep "$@"
    877 			then
    878 				printf '%s\n' "${ENTRY%.age}"
    879 				HAS_ITEMS=yes
    880 			fi
    881 		elif [ "${ENTRY%.gpg}.gpg" = "${ENTRY}" ]; then
    882 			shift
    883 			set -- "-q" "$@"
    884 			if [ $# -le 1 ] \
    885 			    || printf '%s\n' "${ITEM_NAME%.gpg}" | grep "$@"
    886 			then
    887 				if ! [ "${ENTRY}" = "${ITEM_NAME}" ]; then
    888 					printf '%s' "${ENTRY%/*}/"
    889 				fi
    890 				if ! [ -d "${FULL_ENTRY%.gpg}" ] \
    891 				    && ! [ -f "${FULL_ENTRY%.gpg}.age" ]
    892 				then
    893 					ITEM_NAME="${ITEM_NAME%.gpg}"
    894 				fi
    895 				printf '%s%s%s\n' \
    896 				    "${BEGIN_GPG_NAME}" \
    897 				    "${ITEM_NAME}" \
    898 				    "${END_GPG_NAME}"
    899 				HAS_ITEMS=yes
    900 			fi
    901 		fi
    902 		unset ENTRY
    903 		unset ITEM_NAME
    904 	done
    905 	unset FULL_ENTRY
    906 }
    907 
    908 # Display a single directory or entry
    909 #   $1: entry name
    910 #   LIST_VIEW: whether directories are displayed as a list rather then a tree
    911 do_list_or_show() {
    912 	if [ -z "$1" ]; then
    913 		if [ "${LIST_VIEW-no}" = "yes" ]; then
    914 			BEGIN_GPG_NAME=''
    915 			END_GPG_NAME=''
    916 			LIST_EMPTY='no'
    917 			do_list ''
    918 			unset BEGIN_GPG_NAME
    919 			unset END_GPG_NAME
    920 			unset LIST_EMPTY
    921 		else
    922 			printf 'Password Store\n'
    923 			do_tree ''
    924 		fi
    925 	elif [ -f "${PREFIX}/$1.age" ]; then
    926 		SECRET="$(do_decrypt "${PREFIX}/$1.age")"
    927 		do_show "$1" <<-EOF
    928 			${SECRET}
    929 		EOF
    930 		unset SECRET
    931 	elif [ -d "${PREFIX}/$1" ]; then
    932 		if [ "${LIST_VIEW-no}" = "yes" ]; then
    933 			BEGIN_GPG_NAME=''
    934 			END_GPG_NAME=''
    935 			LIST_EMPTY='no'
    936 			do_list "${1%/}"
    937 			unset BEGIN_GPG_NAME
    938 			unset END_GPG_NAME
    939 			unset LIST_EMPTY
    940 		else
    941 			printf '%s\n' "${1%/}"
    942 			do_tree "${1%/}"
    943 		fi
    944 	elif [ -f "${PREFIX}/$1.gpg" ]; then
    945 		SECRET="$(do_decrypt_gpg "${PREFIX}/$1.gpg")"
    946 		do_show "$1" <<-EOF
    947 			${SECRET}
    948 		EOF
    949 		unset SECRET
    950 	else
    951 		die "Error: $1 is not in the password store."
    952 	fi
    953 }
    954 
    955 # Re-encrypts a file or a directory
    956 #   $1: entry name
    957 #   DECISION: whether to ask before re-encryption
    958 do_reencrypt() {
    959 	scm_begin
    960 
    961 	if [ "$1" = "${1%/}/" ]; then
    962 		if ! [ -d "${PREFIX}/${1%/}" ]; then
    963 			die "Error: $1 is not in the password store."
    964 		fi
    965 		do_reencrypt_dir "${PREFIX}/${1%/}"
    966 		LOC="$1"
    967 
    968 	elif [ -f "${PREFIX}/$1.age" ]; then
    969 		do_reencrypt_file "$1"
    970 		LOC="$1"
    971 
    972 	elif [ -d "${PREFIX}/$1" ]; then
    973 		do_reencrypt_dir "${PREFIX}/$1"
    974 		LOC="$1/"
    975 
    976 	else
    977 		die "Error: $1 is not in the password store."
    978 	fi
    979 
    980 	scm_commit "Re-encrypt ${LOC}"
    981 	unset LOC
    982 }
    983 
    984 # Recursively re-encrypts a directory
    985 #   $1: absolute directory path
    986 #   DECISION: whether to ask before re-encryption
    987 do_reencrypt_dir() {
    988 	for ENTRY in "${1%/}"/*; do
    989 		if [ -d "${ENTRY}" ]; then
    990 			if ! [ -e "${ENTRY}/.age-recipients" ] \
    991 			    || [ "${DECISION}" = force ]
    992 			then
    993 				( do_reencrypt_dir "${ENTRY}" )
    994 			fi
    995 		elif [ "${ENTRY}" = "${ENTRY%.age}.age" ]; then
    996 			ENTRY="${ENTRY#"${PREFIX}"/}"
    997 			do_reencrypt_file "${ENTRY%.age}"
    998 		fi
    999 	done
   1000 }
   1001 
   1002 # Re-encrypts a file
   1003 #   $1: entry name
   1004 #   DECISION: whether to ask before re-encryption
   1005 do_reencrypt_file() {
   1006 	if [ "${DECISION}" = interactive ]; then
   1007 		yesno "Re-encrypt $1?"
   1008 		[ "${ANSWER}" = y ] || return 0
   1009 		unset ANSWER
   1010 	fi
   1011 
   1012 	OVERWRITE=once
   1013 	WIP_FILE="$(mktemp -u "${PREFIX}/$1-XXXXXXXXX.age")"
   1014 	SECRET="$(do_decrypt "${PREFIX}/$1.age")"
   1015 	do_encrypt "${WIP_FILE#"${PREFIX}"/}" <<-EOF
   1016 		${SECRET}
   1017 	EOF
   1018 	mv -f -- "${WIP_FILE}" "${PREFIX}/$1.age"
   1019 	unset WIP_FILE
   1020 	scm_add "$1.age"
   1021 }
   1022 
   1023 # Display a decrypted secret from standard input
   1024 #   $1: title
   1025 #   SELECTED_LINE: which line to paste or diplay as qr-code
   1026 #   SHOW: how to show the secret
   1027 do_show() {
   1028 	unset SECRET
   1029 
   1030 	case "${SHOW}" in
   1031 	    text)
   1032 		cat
   1033 		;;
   1034 	    clip)
   1035 		tail -n "+${SELECTED_LINE}" \
   1036 		    | head -n 1 \
   1037 		    | tr -d '\n' \
   1038 		    | platform_clip "$1"
   1039 		;;
   1040 	    qrcode)
   1041 		tail -n "+${SELECTED_LINE}" \
   1042 		    | head -n 1 \
   1043 		    | tr -d '\n' \
   1044 		    | platform_qrcode "$1"
   1045 		;;
   1046 	    *)
   1047 		die "Unexpected SHOW value \"${SHOW}\""
   1048 		;;
   1049 	esac
   1050 }
   1051 
   1052 # Display a list of secret as a tree
   1053 #   $1: path relative to prefix
   1054 #  ...: (optional) grep arguments to filter
   1055 do_tree() {
   1056 	REVERSE=""
   1057 	BEGIN_GPG_NAME="${RED_TEXT}"
   1058 	END_GPG_NAME="${NORMAL_TEXT}"
   1059 	LIST_EMPTY="${2+no}"
   1060 	LIST_EMPTY="${LIST_EMPTY:-yes}"
   1061 	ENTRY_LIST="$(do_list "$@")"
   1062 	while read -r LINE; do
   1063 		REVERSE="${LINE#"${1-}${1:+/}"}${REVERSE:+"${NL}"}${REVERSE}"
   1064 	done <<-EOF
   1065 		${ENTRY_LIST}
   1066 	EOF
   1067 	unset BEGIN_GPG_NAME
   1068 	unset END_GPG_NAME
   1069 	unset LIST_EMPTY
   1070 	unset ENTRY_LIST
   1071 
   1072 	GRAPH="_"
   1073 	TREE=""
   1074 	PREV_LINE=""
   1075 	while read -r LINE; do
   1076 		LINE_COPY="${LINE}"
   1077 		# Skip common directory prefix
   1078 		while ! [ "${LINE_COPY%%/*}" = "${LINE_COPY}" ] \
   1079 		    && ! [ "${PREV_LINE%%/*}" = "${PREV_LINE}" ] \
   1080 		    && [ "${LINE_COPY%%/*}" = "${PREV_LINE%%/*}" ]
   1081 		do
   1082 			LINE_COPY="${LINE_COPY#*/}"
   1083 			PREV_LINE="${PREV_LINE#*/}"
   1084 		done
   1085 
   1086 		# Output obsolete directories
   1087 		while ! [ "${PREV_LINE%/*}" = "${PREV_LINE}" ]; do
   1088 			PREV_LINE="${PREV_LINE%/*}"
   1089 			do_tree_prefix "${GRAPH%?}"
   1090 			GRAPH="${GRAPH%??}I"
   1091 			TREE="${PREV_LINE##*/}${NORMAL_TEXT}${NL}${TREE}"
   1092 			TREE="${CGRAPH}${BLUE_TEXT}${TREE}"
   1093 			unset CGRAPH
   1094 		done
   1095 
   1096 		[ -z "${LINE}" ] && continue
   1097 
   1098 		# Prepare new directories
   1099 		while ! [ "${LINE_COPY%/*}" = "${LINE_COPY}" ]; do
   1100 			LINE_COPY="${LINE_COPY%/*}"
   1101 			GRAPH="${GRAPH}_"
   1102 		done
   1103 		unset LINE_COPY
   1104 
   1105 		if [ -n "${LINE##*/}" ]; then
   1106 			do_tree_prefix "${GRAPH}"
   1107 			GRAPH="${GRAPH%?}I"
   1108 			TREE="${CGRAPH}${LINE##*/}${NL}${TREE}"
   1109 			unset CGRAPH
   1110 		fi
   1111 
   1112 		PREV_LINE="${LINE}"
   1113 	done <<-EOF
   1114 		${REVERSE}
   1115 
   1116 	EOF
   1117 	# Note the extra blank line above to flush the first directories
   1118 	unset GRAPH
   1119 	unset LINE
   1120 	unset PREV_LINE
   1121 
   1122 	printf '%s' "${TREE}"
   1123 	unset TREE
   1124 }
   1125 
   1126 
   1127 # Convert a tree prefix into user-facing representation
   1128 #   $1: encoded tree prefix
   1129 #   CGRAPH: output user-facing representation
   1130 do_tree_prefix() {
   1131 	CGRAPH=""
   1132 	while [ -n "${1#?}" ]; do
   1133 		case "${1%"${1#?}"}" in
   1134 		    (_)
   1135 			CGRAPH="${CGRAPH}${TREE__}"
   1136 			;;
   1137 		    (I)
   1138 			CGRAPH="${CGRAPH}${TREE_I}"
   1139 			;;
   1140 		    (*)
   1141 			die "Invalid tree prefix: \"$1\""
   1142 			;;
   1143 		esac
   1144 		set -- "${1#?}"
   1145 	done
   1146 
   1147 	case "$1" in
   1148 	    (_)
   1149 		CGRAPH="${CGRAPH}${TREE_L}"
   1150 		;;
   1151 	    (I)
   1152 		CGRAPH="${CGRAPH}${TREE_T}"
   1153 		;;
   1154 	    (*)
   1155 		die "Invalid tree prefix: \"$1\""
   1156 		;;
   1157 	esac
   1158 }
   1159 
   1160 
   1161 ############
   1162 # COMMANDS #
   1163 ############
   1164 
   1165 cmd_copy() {
   1166 	ACTION=Copy
   1167 	SCM_ACTION=scm_cp
   1168 	cmd_copy_move "$@"
   1169 }
   1170 
   1171 cmd_copy_move() {
   1172 	DECISION=default
   1173 	OVERWRITE=no
   1174 	PARSE_ERROR=no
   1175 
   1176 	while [ $# -ge 1 ]; do
   1177 		case "$1" in
   1178 		    -f|--force)
   1179 			OVERWRITE=yes
   1180 			shift ;;
   1181 		    -e|--reencrypt)
   1182 			[ "${DECISION}" = default ] || PARSE_ERROR=yes
   1183 			DECISION=force
   1184 			shift ;;
   1185 		    -i|--interactive)
   1186 			[ "${DECISION}" = default ] || PARSE_ERROR=yes
   1187 			DECISION=interactive
   1188 			shift ;;
   1189 		    -k|--keep)
   1190 			[ "${DECISION}" = default ] || PARSE_ERROR=yes
   1191 			DECISION=keep
   1192 			shift ;;
   1193 		    -[efik]?*)
   1194 			REST="${1#??}"
   1195 			FIRST="${1%"${REST}"}"
   1196 			shift
   1197 			set -- "${FIRST}" "-${REST}" "$@"
   1198 			unset FIRST
   1199 			unset REST
   1200 			;;
   1201 		    --)
   1202 			shift
   1203 			break ;;
   1204 		    -*)
   1205 			PARSE_ERROR=yes
   1206 			break ;;
   1207 		    *)
   1208 			break ;;
   1209 		esac
   1210 	done
   1211 
   1212 	if [ "${PARSE_ERROR}" = yes ] || [ $# -lt 2 ]; then
   1213 		if [ "${COMMAND}" = "c${COMMAND#c}" ]; then
   1214 			cmd_usage 'Usage: ' copy >&2
   1215 			exit 1
   1216 		elif [ "${COMMAND}" = "m${COMMAND#m}" ]; then
   1217 			cmd_usage 'Usage: ' move >&2
   1218 			exit 1
   1219 		else
   1220 			cmd_usage 'Usage: ' copy move >&2
   1221 			exit 1
   1222 		fi
   1223 	fi
   1224 	unset PARSE_ERROR
   1225 
   1226 	check_sneaky_paths "$@"
   1227 
   1228 	if [ $# -gt 2 ]; then
   1229 		SHARED_DEST="$1"
   1230 		shift
   1231 		for ARG in "$@"; do
   1232 			shift
   1233 			set -- "$@" "${SHARED_DEST}"
   1234 			SHARED_DEST="${ARG}"
   1235 		done
   1236 
   1237 		for ARG in "$@"; do
   1238 			do_copy_move "${ARG}" "${SHARED_DEST%/}/"
   1239 		done
   1240 	else
   1241 		do_copy_move "$@"
   1242 	fi
   1243 }
   1244 
   1245 cmd_delete() {
   1246 	DECISION=default
   1247 	PARSE_ERROR=no
   1248 	RECURSIVE=no
   1249 
   1250 	while [ $# -ge 1 ]; do
   1251 		case "$1" in
   1252 		    -f|--force)
   1253 			DECISION=force
   1254 			shift ;;
   1255 		    -r|--recursive)
   1256 			RECURSIVE=yes
   1257 			shift ;;
   1258 		    -[fr]?*)
   1259 			REST="${1#??}"
   1260 			FIRST="${1%"${REST}"}"
   1261 			shift
   1262 			set -- "${FIRST}" "-${REST}" "$@"
   1263 			unset FIRST
   1264 			unset REST
   1265 			;;
   1266 		    --)
   1267 			shift
   1268 			break ;;
   1269 		    -*)
   1270 			PARSE_ERROR=yes
   1271 			break ;;
   1272 		    *)
   1273 			break ;;
   1274 		esac
   1275 	done
   1276 
   1277 	if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ]; then
   1278 		cmd_usage 'Usage: ' delete >&2
   1279 		exit 1
   1280 	fi
   1281 	unset PARSE_ERROR
   1282 
   1283 	check_sneaky_paths "$@"
   1284 
   1285 	for ARG in "$@"; do
   1286 		do_delete "${ARG}"
   1287 	done
   1288 }
   1289 
   1290 cmd_edit() {
   1291 	if [ $# -eq 0 ]; then
   1292 		cmd_usage 'Usage: ' edit >&2
   1293 		exit 1
   1294 	fi
   1295 
   1296 	check_sneaky_paths "$@"
   1297 	platform_tmpdir
   1298 
   1299 	for ARG in "$@"; do
   1300 		do_edit "${ARG}"
   1301 	done
   1302 }
   1303 
   1304 cmd_find() {
   1305 	LIST_VIEW=no
   1306 	case "${1-}" in
   1307 	    -r|--raw)
   1308 		LIST_VIEW=yes
   1309 		shift ;;
   1310 	    *)
   1311 		;;
   1312 	esac
   1313 
   1314 	if [ $# -eq 0 ]; then
   1315 		cmd_usage 'Usage: ' find >&2
   1316 		exit 1
   1317 	fi
   1318 
   1319 	if [ "${LIST_VIEW}" = yes ]; then
   1320 		BEGIN_GPG_NAME=''
   1321 		END_GPG_NAME=''
   1322 		LIST_EMPTY='no'
   1323 		do_list '' "$@"
   1324 		unset BEGIN_GPG_NAME
   1325 		unset END_GPG_NAME
   1326 		unset LIST_EMPTY
   1327 	else
   1328 		printf 'Search pattern: %s\n' "$*"
   1329 		do_tree '' "$@"
   1330 	fi
   1331 }
   1332 
   1333 cmd_generate() {
   1334 	CHARSET="${CHARACTER_SET}"
   1335 	DECISION=default
   1336 	MULTILINE=no
   1337 	OVERWRITE=no
   1338 	PARSE_ERROR=no
   1339 	SELECTED_LINE=1
   1340 	SHOW=text
   1341 
   1342 	while [ $# -ge 1 ]; do
   1343 		case "$1" in
   1344 		    -c|--clip)
   1345 			if ! [ "${SHOW}" = text ]; then
   1346 				PARSE_ERROR=yes
   1347 				break
   1348 			fi
   1349 			SHOW=clip
   1350 			shift ;;
   1351 		    -f|--force)
   1352 			if ! [ "${OVERWRITE}" = no ]; then
   1353 				PARSE_ERROR=yes
   1354 				break
   1355 			fi
   1356 			OVERWRITE=yes
   1357 			shift ;;
   1358 		    -i|--in-place)
   1359 			if ! [ "${OVERWRITE}" = no ]; then
   1360 				PARSE_ERROR=yes
   1361 				break
   1362 			fi
   1363 			OVERWRITE=reuse
   1364 			shift ;;
   1365 		    -m|--multiline)
   1366 			MULTILINE=yes
   1367 			shift ;;
   1368 		    -n|--no-symbols)
   1369 			CHARSET="${CHARACTER_SET_NO_SYMBOLS}"
   1370 			shift ;;
   1371 		    -q|--qrcode)
   1372 			if ! [ "${SHOW}" = text ]; then
   1373 				PARSE_ERROR=yes
   1374 				break
   1375 			fi
   1376 			SHOW=qrcode
   1377 			shift ;;
   1378 		    -t|--try)
   1379 			DECISION=interactive
   1380 			shift ;;
   1381 		    -[cfimnqt]?*)
   1382 			REST="${1#-?}"
   1383 			ARG="${1%"${REST}"}"
   1384 			shift
   1385 			set -- "${ARG}" "-${REST}" "$@"
   1386 			unset ARG
   1387 			unset REST
   1388 			;;
   1389 		    --)
   1390 			shift
   1391 			break ;;
   1392 		    -*)
   1393 			PARSE_ERROR=yes
   1394 			break ;;
   1395 		    *)
   1396 			break ;;
   1397 		esac
   1398 	done
   1399 
   1400 	if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ] || [ $# -gt 3 ]; then
   1401 		cmd_usage 'Usage: ' generate >&2
   1402 		exit 1
   1403 	fi
   1404 
   1405 	unset PARSE_ERROR
   1406 
   1407 	check_sneaky_path "$1"
   1408 	LENGTH="${2:-${GENERATED_LENGTH}}"
   1409 	[ -n "${LENGTH##*[!0-9]*}" ] \
   1410 	    || die "Error: passlength \"${LENGTH}\" must be a number."
   1411 	[ "${LENGTH}" -gt 0 ] \
   1412 	    || die "Error: pass-length must be greater than zero."
   1413 
   1414 	do_generate "$1" "${LENGTH}" "${3:-${CHARSET}}"
   1415 
   1416 	unset CHARSET
   1417 	unset LENGTH
   1418 }
   1419 
   1420 cmd_git() {
   1421 	if [ -d "${PREFIX}/.git" ]; then
   1422 		platform_tmpdir
   1423 		TMPDIR="${SECURE_TMPDIR}" git -C "${PREFIX}" "$@"
   1424 	elif [ "${1-}" = init ]; then
   1425 		mkdir -p -- "${PREFIX}"
   1426 		git -C "${PREFIX}" "$@"
   1427 		scm_add '.'
   1428 		scm_commit "Add current contents of password store."
   1429 		cmd_gitconfig
   1430 	elif [ "${1-}" = clone ]; then
   1431 		git "$@" "${PREFIX}"
   1432 		cmd_gitconfig
   1433 	else
   1434 		die "Error: the password store is not a git repository." \
   1435 		    "Try \"${PROGRAM} git init\"."
   1436 	fi
   1437 }
   1438 
   1439 cmd_grep() {
   1440 	if [ $# -eq 0 ]; then
   1441 		cmd_usage 'Usage: ' grep >&2
   1442 		exit 1
   1443 	fi
   1444 
   1445 	( cd "${PREFIX}" && do_grep "" "$@" )
   1446 }
   1447 
   1448 cmd_gitconfig() {
   1449 	[ -d "${PREFIX}/.git" ] || die "The store is not a git repository."
   1450 
   1451 	if ! [ -f "${PREFIX}/.gitattributes" ] ||
   1452 	    ! grep -Fqx '*.age diff=age' "${PREFIX}/.gitattributes"
   1453 	then
   1454 		scm_begin
   1455 		printf '*.age diff=age\n' >>"${PREFIX}/.gitattributes"
   1456 		scm_add ".gitattributes"
   1457 		scm_commit "Configure git repository for age file diff."
   1458 	fi
   1459 
   1460 	git -C "${PREFIX}" config --local diff.age.binary true
   1461 	git -C "${PREFIX}" config --local diff.age.textconv \
   1462 	    "${AGE} -d -i ${IDENTITIES_FILE}"
   1463 }
   1464 
   1465 cmd_help() {
   1466 	cmd_version
   1467 	printf '\n'
   1468 	cmd_usage '    '
   1469 }
   1470 
   1471 cmd_init() {
   1472 	DECISION=default
   1473 	OVERWRITE=yes
   1474 	PARSE_ERROR=no
   1475 	SUBDIR=''
   1476 
   1477 	while [ $# -ge 1 ]; do
   1478 		case "$1" in
   1479 		    -i|--interactive)
   1480 			[ "${DECISION}" = default ] || PARSE_ERROR=yes
   1481 			DECISION=interactive
   1482 			shift ;;
   1483 
   1484 		    -k|--keep)
   1485 			[ "${DECISION}" = default ] || PARSE_ERROR=yes
   1486 			DECISION=keep
   1487 			shift ;;
   1488 
   1489 		    -p|--path)
   1490 			if [ $# -lt 2 ]; then
   1491 				PARSE_ERROR=yes
   1492 				break
   1493 			fi
   1494 
   1495 			SUBDIR="$2"
   1496 			shift 2 ;;
   1497 
   1498 		    -p?*)
   1499 			SUBDIR="${1#-p}"
   1500 			shift ;;
   1501 
   1502 		    --path=*)
   1503 			SUBDIR="${1#--path=}"
   1504 			shift ;;
   1505 
   1506 		    -[ik]?*)
   1507 			REST="${1#-?}"
   1508 			ARG="${1%"${REST}"}"
   1509 			shift
   1510 			set -- "${ARG}" "-${REST}" "$@"
   1511 			unset ARG
   1512 			unset REST
   1513 			;;
   1514 
   1515 		    --)
   1516 			shift
   1517 			break ;;
   1518 
   1519 		    -*)
   1520 			PARSE_ERROR=yes
   1521 			break ;;
   1522 
   1523 		    *)
   1524 			break ;;
   1525 		esac
   1526 	done
   1527 
   1528 	if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ]; then
   1529 		cmd_usage 'Usage: ' init >&2
   1530 		exit 1
   1531 	fi
   1532 
   1533 	check_sneaky_path "${SUBDIR}"
   1534 
   1535 	if [ $# -eq 1 ] && [ -z "$1" ]; then
   1536 		do_deinit "${SUBDIR}"
   1537 	else
   1538 		do_init "${SUBDIR}" "$@"
   1539 	fi
   1540 
   1541 	unset PARSE_ERROR
   1542 	unset SUBDIR
   1543 }
   1544 
   1545 cmd_insert() {
   1546 	ECHO=no
   1547 	MULTILINE=no
   1548 	OVERWRITE=no
   1549 	PARSE_ERROR=no
   1550 
   1551 	while [ $# -ge 1 ]; do
   1552 		case "$1" in
   1553 		    -e|--echo)
   1554 			ECHO=yes
   1555 			shift ;;
   1556 		    -f|--force)
   1557 			OVERWRITE=yes
   1558 			shift ;;
   1559 		    -m|--multiline)
   1560 			MULTILINE=yes
   1561 			shift ;;
   1562 		    -[efm]?*)
   1563 			REST="${1#-?}"
   1564 			ARG="${1%"${REST}"}"
   1565 			shift
   1566 			set -- "${ARG}" "-${REST}" "$@"
   1567 			unset ARG
   1568 			unset REST
   1569 			;;
   1570 		    --)
   1571 			shift
   1572 			break ;;
   1573 		    -?*)
   1574 			PARSE_ERROR=yes
   1575 			break ;;
   1576 		    *)
   1577 			break ;;
   1578 		esac
   1579 	done
   1580 
   1581 	if [ "${PARSE_ERROR}" = yes ] \
   1582             || [ $# -lt 1 ] \
   1583             || [ "${ECHO}${MULTILINE}" = yesyes ]
   1584 	then
   1585 		cmd_usage 'Usage: ' insert >&2
   1586 		exit 1
   1587 	fi
   1588 	unset PARSE_ERROR
   1589 
   1590 	check_sneaky_paths "$@"
   1591 
   1592 	for ARG in "$@"; do
   1593 		do_insert "${ARG}"
   1594 	done
   1595 	unset ARG
   1596 }
   1597 
   1598 cmd_list_or_show() {
   1599 	LIST_VIEW=no
   1600 	PARSE_ERROR=no
   1601 	SELECTED_LINE=1
   1602 	USE_CLIP=no
   1603 	USE_QRCODE=no
   1604 
   1605 	while [ $# -ge 1 ]; do
   1606 		case "$1" in
   1607 		    -c|--clip)
   1608 			USE_CLIP=yes
   1609 			shift ;;
   1610 		    -c?*)
   1611 			SELECTED_LINE="${1#-c}"
   1612 			USE_CLIP=yes
   1613 			shift ;;
   1614 		    --clip=*)
   1615 			SELECTED_LINE="${1#--clip=}"
   1616 			USE_CLIP=yes
   1617 			shift ;;
   1618 		    -q|--qrcode)
   1619 			USE_QRCODE=yes
   1620 			shift ;;
   1621 		    -q?*)
   1622 			SELECTED_LINE="${1#-q}"
   1623 			USE_QRCODE=yes
   1624 			shift ;;
   1625 		    --qrcode=*)
   1626 			SELECTED_LINE="${1#--qrcode=}"
   1627 			USE_QRCODE=yes
   1628 			shift ;;
   1629 		    -r|--raw)
   1630 			LIST_VIEW=yes
   1631 			shift ;;
   1632 		    --)
   1633 			shift
   1634 			break ;;
   1635 		    -*)
   1636 			PARSE_ERROR=yes
   1637 			break ;;
   1638 		    *)
   1639 			break ;;
   1640 		esac
   1641 	done
   1642 
   1643 	case "${USE_CLIP}-${USE_QRCODE}" in
   1644 	    no-no)
   1645 		SHOW=text
   1646 		;;
   1647 	    yes-no)
   1648 		SHOW=clip
   1649 		;;
   1650 	    no-yes)
   1651 		SHOW=qrcode
   1652 		;;
   1653 	    *)
   1654 		PARSE_ERROR=yes
   1655 		;;
   1656 	esac
   1657 
   1658 	if [ "${PARSE_ERROR}" = yes ]; then
   1659 		if [ "${COMMAND}" = "l${COMMAND#l}" ]; then
   1660 			cmd_usage 'Usage: ' list >&2
   1661 			exit 1
   1662 		elif [ "${COMMAND}" = "s${COMMAND#s}" ]; then
   1663 			cmd_usage 'Usage: ' show >&2
   1664 			exit 1
   1665 		else
   1666 			cmd_usage 'Usage: ' list show >&2
   1667 			exit 1
   1668 		fi
   1669 	fi
   1670 	unset PARSE_ERROR
   1671 
   1672 	check_sneaky_paths "$@"
   1673 
   1674 	if [ $# -eq 0 ]; then
   1675 		do_list_or_show ""
   1676 	else
   1677 		for ARG in "$@"; do
   1678 			do_list_or_show "${ARG}"
   1679 		done
   1680 	fi
   1681 
   1682 	unset ARG
   1683 	unset PARSING
   1684 }
   1685 
   1686 cmd_move() {
   1687 	ACTION=Move
   1688 	SCM_ACTION=scm_mv
   1689 	cmd_copy_move "$@"
   1690 }
   1691 
   1692 cmd_random() {
   1693 	if [ $# -gt 2 ]; then
   1694 		cmd_usage 'Usage: ' random >&2
   1695 		exit 1
   1696 	fi
   1697 
   1698 	random_chars "${1:-${GENERATED_LENGTH}}" "${2:-${CHARACTER_SET}}"
   1699 }
   1700 
   1701 cmd_reencrypt() {
   1702 	DECISION=default
   1703 	OVERWRITE=yes
   1704 	PARSE_ERROR=no
   1705 
   1706 	while [ $# -ge 1 ]; do
   1707 		case "$1" in
   1708 		    -i|--interactive)
   1709 			DECISION=interactive
   1710 			shift ;;
   1711 		    --)
   1712 			shift
   1713 			break ;;
   1714 		    -*)
   1715 			PARSE_ERROR=yes
   1716 			break ;;
   1717 		    *)
   1718 			break ;;
   1719 		esac
   1720 	done
   1721 
   1722 	if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ]; then
   1723 		cmd_usage 'Usage: ' reencrypt >&2
   1724 		exit 1
   1725 	fi
   1726 
   1727 	unset PARSE_ERROR
   1728 
   1729 	check_sneaky_paths "$@"
   1730 
   1731 	for ARG in "$@"; do
   1732 		do_reencrypt "${ARG}"
   1733 	done
   1734 	unset ARG
   1735 }
   1736 
   1737 # Outputs the whole usage text
   1738 #   $1: indentation
   1739 #   ... commands to document
   1740 cmd_usage(){
   1741 	if [ $# -eq 0 ]; then
   1742 		F='    '
   1743 		I='    '
   1744 	else
   1745 		F="$1"
   1746 		NON_BLANK="$1"
   1747 		I=''
   1748 		while [ -n "${NON_BLANK}" ]; do
   1749 			I=" ${I}"
   1750 			NON_BLANK="${NON_BLANK#?}"
   1751 		done
   1752 		shift
   1753 	fi
   1754 
   1755 	if [ $# -eq 0 ]; then
   1756 		echo 'Usage:'
   1757 		set -- list show copy delete edit find generate git gitconfig \
   1758 		    grep help init insert move random reencrypt version
   1759 		VERBOSE=yes
   1760 	else
   1761 		VERBOSE=no
   1762 	fi
   1763 
   1764 	NON_BLANK="${PROGRAM}"
   1765 	BLANKPG=''
   1766 	while [ -n "${NON_BLANK}" ]; do
   1767 		BLANKPG=" ${BLANKPG}"
   1768 		NON_BLANK="${NON_BLANK#?}"
   1769 	done
   1770 	unset NON_BLANK
   1771 
   1772 	for ARG in "$@"; do
   1773 		case "${ARG}" in
   1774 		    list)
   1775 			cat <<EOF
   1776 ${F}${PROGRAM} [list] [--raw,-r] [subfolder]
   1777 EOF
   1778 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1779 ${I}    List passwords as a tree or as a raw list.
   1780 EOF
   1781 			;;
   1782 		    show)
   1783 			cat <<EOF
   1784 ${F}${PROGRAM} [show] [--clip[=line-number],-c[line-number] |
   1785 ${I}${BLANKPG}         --qrcode[=line-number],-q[line-number]] pass-name
   1786 EOF
   1787 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1788 ${I}    Show existing password and optionally put it on the clipboard
   1789 ${I}    or display it as a QR-code.
   1790 ${I}    If put on the clipboard, it will be cleared in ${CLIP_TIME:-45} seconds.
   1791 EOF
   1792 			;;
   1793 		    copy)
   1794 			cat <<EOF
   1795 ${F}${PROGRAM} copy [--reencrypt,-e | --interactive,-i | --keep,-k ]
   1796 ${I}${BLANKPG}      [--force,-f] old-path new-path
   1797 EOF
   1798 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1799 ${I}    Copies old-path to new-path, optionally forcefully,
   1800 ${I}    reencrypting if needed or forced.
   1801 EOF
   1802 			;;
   1803 		    delete)
   1804 			cat <<EOF
   1805 ${F}${PROGRAM} delete [--recursive,-r] [--force,-f] pass-name
   1806 EOF
   1807 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1808 ${I}    Remove existing passwords or directories, optionally forcefully.
   1809 EOF
   1810 			;;
   1811 		    edit)
   1812 			cat <<EOF
   1813 ${F}${PROGRAM} edit pass-name
   1814 EOF
   1815 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1816 ${I}    Insert a new password or edit an existing password using an editor.
   1817 EOF
   1818 			;;
   1819 		    find)
   1820 			cat <<EOF
   1821 ${F}${PROGRAM} find [--raw,-r] [GREP_OPTIONS] regex
   1822 EOF
   1823 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1824 ${I}    List passwords that match the given regex.
   1825 EOF
   1826 			;;
   1827 		    generate)
   1828 			cat <<EOF
   1829 ${F}${PROGRAM} generate [--no-symbols,-n] [--clip,-c | --qrcode,-q]
   1830 ${I}${BLANKPG}          [--in-place,-i | --force,-f] [--multiline,-m]
   1831 ${I}${BLANKPG}          [--try,-t] pass-name [pass-length [character-set]]
   1832 EOF
   1833 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1834 ${I}    Generate a new password of pass-length (or ${GENERATED_LENGTH:-25} if unspecified)
   1835 ${I}    with optionally no symbols.
   1836 ${I}    Optionally put it on the clipboard and clear board after ${CLIP_TIME:-45} seconds
   1837 ${I}    or display it as a QR-code.
   1838 ${I}    Prompt before overwriting existing password unless forced.
   1839 ${I}    Optionally replace only the first line of an existing file
   1840 ${I}    with a new password.
   1841 ${I}    Optionally prompt for confirmation between generation and saving.
   1842 EOF
   1843 			;;
   1844 		    git)
   1845 			cat <<EOF
   1846 ${F}${PROGRAM} git git-command-args ...
   1847 EOF
   1848 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1849 ${I}    If the password store is a git repository, execute a git command
   1850 ${I}    specified by git-command-args.
   1851 EOF
   1852 			;;
   1853 		    gitconfig)
   1854 			cat <<EOF
   1855 ${F}${PROGRAM} gitconfig
   1856 EOF
   1857 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1858 ${I}    If the password store is a git repository, enforce local configuration.
   1859 EOF
   1860 			;;
   1861 		    grep)
   1862 			cat <<EOF
   1863 ${F}${PROGRAM} grep [GREP_OPTIONS] search-regex
   1864 EOF
   1865 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1866 ${I}    Search for password files matching search-regex when decrypted.
   1867 EOF
   1868 			;;
   1869 		    help)
   1870 			cat <<EOF
   1871 ${F}${PROGRAM} help
   1872 EOF
   1873 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1874 ${I}    Show this text.
   1875 EOF
   1876 			;;
   1877 		    init)
   1878 			cat <<EOF
   1879 ${F}${PROGRAM} init [--interactive,-i | --keep,-k ]
   1880 ${I}${BLANKPG}      [--path=subfolder,-p subfolder] age-recipient ...
   1881 EOF
   1882 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1883 ${I}    Initialize new password storage and use the given age recipients
   1884 ${I}    for encryption.
   1885 ${I}    Selectively reencrypt existing passwords using new recipients.
   1886 EOF
   1887 			;;
   1888 		    insert)
   1889 			cat <<EOF
   1890 ${F}${PROGRAM} insert [--echo,-e | --multiline,-m] [--force,-f] pass-name
   1891 EOF
   1892 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1893 ${I}    Insert new password. Optionally, echo the password back to the console
   1894 ${I}    during entry. Or, optionally, the entry may be multiline.
   1895 ${I}    Prompt before overwriting existing password unless forced.
   1896 EOF
   1897 			;;
   1898 		    move)
   1899 			cat <<EOF
   1900 ${F}${PROGRAM} move [--reencrypt,-e | --interactive,-i | --keep,-k ]
   1901 ${I}${BLANKPG}      [--force,-f] old-path new-path
   1902 EOF
   1903 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1904 ${I}    Renames or moves old-path to new-path, optionally forcefully,
   1905 ${I}    reencrypting if needed or forced.
   1906 EOF
   1907 			;;
   1908 		    random)
   1909 			cat <<EOF
   1910 ${F}${PROGRAM} random [pass-length [character-set]]
   1911 EOF
   1912 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1913 ${I}    Generate a new password of pass-length (or ${GENERATED_LENGTH:-25} if unspecified)
   1914 ${I}    using the given character set (or ${CHARACTER_SET} if unspecified)
   1915 ${I}    without recording it in the password store.
   1916 EOF
   1917 			;;
   1918 		    reencrypt)
   1919 			cat <<EOF
   1920 ${F}${PROGRAM} reencrypt [--interactive,-i] pass-name|subfolder ...
   1921 EOF
   1922 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1923 ${I}    Re-encrypt in-place a secret or all the secrets in a subfolder,
   1924 ${I}    optionally asking before each one.
   1925 EOF
   1926 			;;
   1927 		    version)
   1928 			cat <<EOF
   1929 ${F}${PROGRAM} version
   1930 EOF
   1931 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1932 ${I}    Show version information.
   1933 EOF
   1934 			;;
   1935 		    *)
   1936 			die "cmd_usage: unknown command \"${ARG}\""
   1937 			;;
   1938 		esac
   1939 
   1940 		F="${I}"
   1941 	done
   1942 }
   1943 
   1944 cmd_version() {
   1945 	cat <<-EOF
   1946 	==============================================
   1947 	= pashage: age-backed POSIX password manager =
   1948 	=                                            =
   1949 	=                   v0.1.0                   =
   1950 	=                                            =
   1951 	=            Natasha Kerensikova             =
   1952 	=                                            =
   1953 	=                 Based on:                  =
   1954 	=   password-store  by Jason A. Donenfeld    =
   1955 	=          passage  by Filippo Valsorda      =
   1956 	=             pash  by Dylan Araps           =
   1957 	==============================================
   1958 	EOF
   1959 }