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 (42189B)


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