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


      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 added 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 # Note that this function is recrusive and cannot use variables to hold state.
    854 do_list() {
    855 	for FULL_ENTRY in "${PREFIX}/$1${1:+/}"*; do
    856 		ENTRY="${FULL_ENTRY#"${PREFIX}/"}"
    857 		ITEM_NAME="${ENTRY##*/}"
    858 		if [ -d "${FULL_ENTRY}" ]; then
    859 			shift
    860 			set -- "${ENTRY}" "$@"
    861 			do_list "$@"
    862 		elif [ "${ENTRY%.age}.age" = "${ENTRY}" ]; then
    863 			shift
    864 			set -- "-q" "$@"
    865 			if [ $# -le 1 ] \
    866 			    || printf '%s\n' "${ITEM_NAME%.age}" | grep "$@"
    867 			then
    868 				printf '%s\n' "${ENTRY%.age}"
    869 			fi
    870 		elif [ "${ENTRY%.gpg}.gpg" = "${ENTRY}" ]; then
    871 			shift
    872 			set -- "-q" "$@"
    873 			if [ $# -le 1 ] \
    874 			    || printf '%s\n' "${ITEM_NAME%.gpg}" | grep "$@"
    875 			then
    876 				printf '%s\n' "${ENTRY%.gpg}"
    877 			fi
    878 		fi
    879 		unset ENTRY
    880 		unset ITEM_NAME
    881 	done
    882 	unset FULL_ENTRY
    883 }
    884 
    885 # Display a single directory or entry
    886 #   $1: entry name
    887 #   LIST_VIEW: whether directories are displayed as a list rather then a tree
    888 do_list_or_show() {
    889 	if [ -z "$1" ]; then
    890 		if [ "${LIST_VIEW-no}" = "yes" ]; then
    891 			do_list ''
    892 		else
    893 			do_tree "${PREFIX}" "Password Store"
    894 		fi
    895 	elif [ -f "${PREFIX}/$1.age" ]; then
    896 		SECRET="$(do_decrypt "${PREFIX}/$1.age")"
    897 		do_show "$1" <<-EOF
    898 			${SECRET}
    899 		EOF
    900 		unset SECRET
    901 	elif [ -d "${PREFIX}/$1" ]; then
    902 		if [ "${LIST_VIEW-no}" = "yes" ]; then
    903 			do_list "${1%/}"
    904 		else
    905 			do_tree "${PREFIX}/$1" "$1"
    906 		fi
    907 	elif [ -f "${PREFIX}/$1.gpg" ]; then
    908 		SECRET="$(do_decrypt_gpg "${PREFIX}/$1.gpg")"
    909 		do_show "$1" <<-EOF
    910 			${SECRET}
    911 		EOF
    912 		unset SECRET
    913 	else
    914 		die "Error: $1 is not in the password store."
    915 	fi
    916 }
    917 
    918 # Re-encrypts a file or a directory
    919 #   $1: entry name
    920 #   DECISION: whether to ask before re-encryption
    921 do_reencrypt() {
    922 	scm_begin
    923 
    924 	if [ "$1" = "${1%/}/" ]; then
    925 		if ! [ -d "${PREFIX}/${1%/}" ]; then
    926 			die "Error: $1 is not in the password store."
    927 		fi
    928 		do_reencrypt_dir "${PREFIX}/${1%/}"
    929 		LOC="$1"
    930 
    931 	elif [ -f "${PREFIX}/$1.age" ]; then
    932 		do_reencrypt_file "$1"
    933 		LOC="$1"
    934 
    935 	elif [ -d "${PREFIX}/$1" ]; then
    936 		do_reencrypt_dir "${PREFIX}/$1"
    937 		LOC="$1/"
    938 
    939 	else
    940 		die "Error: $1 is not in the password store."
    941 	fi
    942 
    943 	scm_commit "Re-encrypt ${LOC}"
    944 	unset LOC
    945 }
    946 
    947 # Recursively re-encrypts a directory
    948 #   $1: absolute directory path
    949 #   DECISION: whether to ask before re-encryption
    950 do_reencrypt_dir() {
    951 	for ENTRY in "${1%/}"/*; do
    952 		if [ -d "${ENTRY}" ]; then
    953 			if ! [ -e "${ENTRY}/.age-recipients" ] \
    954 			    || [ "${DECISION}" = force ]
    955 			then
    956 				( do_reencrypt_dir "${ENTRY}" )
    957 			fi
    958 		elif [ "${ENTRY}" = "${ENTRY%.age}.age" ]; then
    959 			ENTRY="${ENTRY#"${PREFIX}"/}"
    960 			do_reencrypt_file "${ENTRY%.age}"
    961 		fi
    962 	done
    963 }
    964 
    965 # Re-encrypts a file
    966 #   $1: entry name
    967 #   DECISION: whether to ask before re-encryption
    968 do_reencrypt_file() {
    969 	if [ "${DECISION}" = interactive ]; then
    970 		yesno "Re-encrypt $1?"
    971 		[ "${ANSWER}" = y ] || return 0
    972 		unset ANSWER
    973 	fi
    974 
    975 	OVERWRITE=once
    976 	WIP_FILE="$(mktemp -u "${PREFIX}/$1-XXXXXXXXX.age")"
    977 	SECRET="$(do_decrypt "${PREFIX}/$1.age")"
    978 	do_encrypt "${WIP_FILE#"${PREFIX}"/}" <<-EOF
    979 		${SECRET}
    980 	EOF
    981 	mv -f -- "${WIP_FILE}" "${PREFIX}/$1.age"
    982 	unset WIP_FILE
    983 	scm_add "$1.age"
    984 }
    985 
    986 # Display a decrypted secret from standard input
    987 #   $1: title
    988 #   SELECTED_LINE: which line to paste or diplay as qr-code
    989 #   SHOW: how to show the secret
    990 do_show() {
    991 	unset SECRET
    992 
    993 	case "${SHOW}" in
    994 	    text)
    995 		cat
    996 		;;
    997 	    clip)
    998 		tail -n "+${SELECTED_LINE}" \
    999 		    | head -n 1 \
   1000 		    | tr -d '\n' \
   1001 		    | platform_clip "$1"
   1002 		;;
   1003 	    qrcode)
   1004 		tail -n "+${SELECTED_LINE}" \
   1005 		    | head -n 1 \
   1006 		    | tr -d '\n' \
   1007 		    | platform_qrcode "$1"
   1008 		;;
   1009 	    *)
   1010 		die "Unexpected SHOW value \"${SHOW}\""
   1011 		;;
   1012 	esac
   1013 }
   1014 
   1015 # Display the tree rooted at the given directory
   1016 #   $1: root directory
   1017 #   $2: title
   1018 #  ...: (optional) grep arguments to filter
   1019 do_tree() {
   1020 	( cd "$1" && shift && do_tree_cwd "$@" )
   1021 }
   1022 
   1023 # Display the subtree rooted at the current directory
   1024 #   $1: title
   1025 #  ...: (optional) grep arguments to filter
   1026 do_tree_cwd() {
   1027 	ACC=""
   1028 	PREV=""
   1029 	TITLE="$1"
   1030 	shift
   1031 
   1032 	for ENTRY in *; do
   1033 		[ -e "${ENTRY}" ] || continue
   1034 		ITEM="$(do_tree_item "${ENTRY}" "$@")"
   1035 		[ -z "${ITEM}" ] && continue
   1036 
   1037 		if [ -n "${PREV}" ]; then
   1038 			ACC="$(printf '%s\n' "${PREV}" | do_tree_prefix "${ACC}" "${TREE_T}" "${TREE_I}")"
   1039 		fi
   1040 
   1041 		PREV="${ITEM}"
   1042 	done
   1043 	unset ENTRY
   1044 
   1045 	if [ -n "${PREV}" ]; then
   1046 		ACC="$(printf '%s\n' "${PREV}" | do_tree_prefix "${ACC}" "${TREE_L}" "${TREE__}")"
   1047 	fi
   1048 
   1049 	if [ $# -eq 0 ] || [ -n "${ACC}" ]; then
   1050 		[ -n "${TITLE}" ] && printf '%s\n' "${TITLE}"
   1051 	fi
   1052 
   1053 	[ -n "${ACC}" ] && printf '%s\n' "${ACC}"
   1054 
   1055 	unset ACC
   1056 	unset PREV
   1057 	unset TITLE
   1058 }
   1059 
   1060 # Display a node in a tree
   1061 #   $1: item name
   1062 #  ...: (optional) grep arguments to filter
   1063 do_tree_item() {
   1064 	ITEM_NAME="$1"
   1065 	shift
   1066 
   1067 	if [ -d "${ITEM_NAME}" ]; then
   1068 		do_tree "${ITEM_NAME}" \
   1069 		    "${BLUE_TEXT}${ITEM_NAME}${NORMAL_TEXT}" \
   1070 		    "$@"
   1071 	elif [ "${ITEM_NAME%.age}.age" = "${ITEM_NAME}" ]; then
   1072 		if [ $# -eq 0 ] \
   1073 		    || printf '%s\n' "${ITEM_NAME%.age}" | grep -q "$@"
   1074 		then
   1075 			printf '%s\n' "${ITEM_NAME%.age}"
   1076 		fi
   1077 	elif [ "${ITEM_NAME%.gpg}.gpg" = "${ITEM_NAME}" ]; then
   1078 		if [ $# -eq 0 ] \
   1079 		    || printf '%s\n' "${ITEM_NAME%.age}" | grep -q "$@"
   1080 		then
   1081 			printf '%s\n' \
   1082 			     "${RED_TEXT}${ITEM_NAME%.gpg}${NORMAL_TEXT}"
   1083 		fi
   1084 	fi
   1085 
   1086 	unset ITEM_NAME
   1087 }
   1088 
   1089 # Add a tree prefix
   1090 #   $1: optional title before the first line
   1091 #   $2: prefix of the first line
   1092 #   $3: prefix of the following lines
   1093 do_tree_prefix() {
   1094 	[ -n "$1" ] && printf '%s\n' "$1"
   1095 	IFS= read -r LINE
   1096 	printf '%s%s\n' "$2" "${LINE}"
   1097 	while IFS= read -r LINE; do
   1098 		printf '%s%s\n' "$3" "${LINE}"
   1099 	done
   1100 	unset LINE
   1101 }
   1102 
   1103 
   1104 ############
   1105 # COMMANDS #
   1106 ############
   1107 
   1108 cmd_copy() {
   1109 	ACTION=Copy
   1110 	SCM_ACTION=scm_cp
   1111 	cmd_copy_move "$@"
   1112 }
   1113 
   1114 cmd_copy_move() {
   1115 	DECISION=default
   1116 	OVERWRITE=no
   1117 	PARSE_ERROR=no
   1118 
   1119 	while [ $# -ge 1 ]; do
   1120 		case "$1" in
   1121 		    -f|--force)
   1122 			OVERWRITE=yes
   1123 			shift ;;
   1124 		    -e|--reencrypt)
   1125 			[ "${DECISION}" = default ] || PARSE_ERROR=yes
   1126 			DECISION=force
   1127 			shift ;;
   1128 		    -i|--interactive)
   1129 			[ "${DECISION}" = default ] || PARSE_ERROR=yes
   1130 			DECISION=interactive
   1131 			shift ;;
   1132 		    -k|--keep)
   1133 			[ "${DECISION}" = default ] || PARSE_ERROR=yes
   1134 			DECISION=keep
   1135 			shift ;;
   1136 		    -[efik]?*)
   1137 			REST="${1#??}"
   1138 			FIRST="${1%"${REST}"}"
   1139 			shift
   1140 			set -- "${FIRST}" "-${REST}" "$@"
   1141 			unset FIRST
   1142 			unset REST
   1143 			;;
   1144 		    --)
   1145 			shift
   1146 			break ;;
   1147 		    -*)
   1148 			PARSE_ERROR=yes
   1149 			break ;;
   1150 		    *)
   1151 			break ;;
   1152 		esac
   1153 	done
   1154 
   1155 	if [ "${PARSE_ERROR}" = yes ] || [ $# -lt 2 ]; then
   1156 		if [ "${COMMAND}" = "c${COMMAND#c}" ]; then
   1157 			cmd_usage 'Usage: ' copy >&2
   1158 			exit 1
   1159 		elif [ "${COMMAND}" = "m${COMMAND#m}" ]; then
   1160 			cmd_usage 'Usage: ' move >&2
   1161 			exit 1
   1162 		else
   1163 			cmd_usage 'Usage: ' copy move >&2
   1164 			exit 1
   1165 		fi
   1166 	fi
   1167 	unset PARSE_ERROR
   1168 
   1169 	check_sneaky_paths "$@"
   1170 
   1171 	if [ $# -gt 2 ]; then
   1172 		SHARED_DEST="$1"
   1173 		shift
   1174 		for ARG in "$@"; do
   1175 			shift
   1176 			set -- "$@" "${SHARED_DEST}"
   1177 			SHARED_DEST="${ARG}"
   1178 		done
   1179 
   1180 		for ARG in "$@"; do
   1181 			do_copy_move "${ARG}" "${SHARED_DEST%/}/"
   1182 		done
   1183 	else
   1184 		do_copy_move "$@"
   1185 	fi
   1186 }
   1187 
   1188 cmd_delete() {
   1189 	DECISION=default
   1190 	PARSE_ERROR=no
   1191 	RECURSIVE=no
   1192 
   1193 	while [ $# -ge 1 ]; do
   1194 		case "$1" in
   1195 		    -f|--force)
   1196 			DECISION=force
   1197 			shift ;;
   1198 		    -r|--recursive)
   1199 			RECURSIVE=yes
   1200 			shift ;;
   1201 		    -[fr]?*)
   1202 			REST="${1#??}"
   1203 			FIRST="${1%"${REST}"}"
   1204 			shift
   1205 			set -- "${FIRST}" "-${REST}" "$@"
   1206 			unset FIRST
   1207 			unset REST
   1208 			;;
   1209 		    --)
   1210 			shift
   1211 			break ;;
   1212 		    -*)
   1213 			PARSE_ERROR=yes
   1214 			break ;;
   1215 		    *)
   1216 			break ;;
   1217 		esac
   1218 	done
   1219 
   1220 	if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ]; then
   1221 		cmd_usage 'Usage: ' delete >&2
   1222 		exit 1
   1223 	fi
   1224 	unset PARSE_ERROR
   1225 
   1226 	check_sneaky_paths "$@"
   1227 
   1228 	for ARG in "$@"; do
   1229 		do_delete "${ARG}"
   1230 	done
   1231 }
   1232 
   1233 cmd_edit() {
   1234 	if [ $# -eq 0 ]; then
   1235 		cmd_usage 'Usage: ' edit >&2
   1236 		exit 1
   1237 	fi
   1238 
   1239 	check_sneaky_paths "$@"
   1240 	platform_tmpdir
   1241 
   1242 	for ARG in "$@"; do
   1243 		do_edit "${ARG}"
   1244 	done
   1245 }
   1246 
   1247 cmd_find() {
   1248 	LIST_VIEW=no
   1249 	case "${1-}" in
   1250 	    -r|--raw)
   1251 		LIST_VIEW=yes
   1252 		shift ;;
   1253 	    *)
   1254 		;;
   1255 	esac
   1256 
   1257 	if [ $# -eq 0 ]; then
   1258 		cmd_usage 'Usage: ' find >&2
   1259 		exit 1
   1260 	fi
   1261 
   1262 	if [ "${LIST_VIEW}" = yes ]; then
   1263 		do_list '' "$@"
   1264 	else
   1265 		printf 'Search pattern: %s\n' "$*"
   1266 		do_tree "${PREFIX}" '' "$@"
   1267 	fi
   1268 }
   1269 
   1270 cmd_generate() {
   1271 	CHARSET="${CHARACTER_SET}"
   1272 	DECISION=default
   1273 	MULTILINE=no
   1274 	OVERWRITE=no
   1275 	PARSE_ERROR=no
   1276 	SELECTED_LINE=1
   1277 	SHOW=text
   1278 
   1279 	while [ $# -ge 1 ]; do
   1280 		case "$1" in
   1281 		    -c|--clip)
   1282 			if ! [ "${SHOW}" = text ]; then
   1283 				PARSE_ERROR=yes
   1284 				break
   1285 			fi
   1286 			SHOW=clip
   1287 			shift ;;
   1288 		    -f|--force)
   1289 			if ! [ "${OVERWRITE}" = no ]; then
   1290 				PARSE_ERROR=yes
   1291 				break
   1292 			fi
   1293 			OVERWRITE=yes
   1294 			shift ;;
   1295 		    -i|--in-place)
   1296 			if ! [ "${OVERWRITE}" = no ]; then
   1297 				PARSE_ERROR=yes
   1298 				break
   1299 			fi
   1300 			OVERWRITE=reuse
   1301 			shift ;;
   1302 		    -m|--multiline)
   1303 			MULTILINE=yes
   1304 			shift ;;
   1305 		    -n|--no-symbols)
   1306 			CHARSET="${CHARACTER_SET_NO_SYMBOLS}"
   1307 			shift ;;
   1308 		    -q|--qrcode)
   1309 			if ! [ "${SHOW}" = text ]; then
   1310 				PARSE_ERROR=yes
   1311 				break
   1312 			fi
   1313 			SHOW=qrcode
   1314 			shift ;;
   1315 		    -t|--try)
   1316 			DECISION=interactive
   1317 			shift ;;
   1318 		    -[cfimnqt]?*)
   1319 			REST="${1#-?}"
   1320 			ARG="${1%"${REST}"}"
   1321 			shift
   1322 			set -- "${ARG}" "-${REST}" "$@"
   1323 			unset ARG
   1324 			unset REST
   1325 			;;
   1326 		    --)
   1327 			shift
   1328 			break ;;
   1329 		    -*)
   1330 			PARSE_ERROR=yes
   1331 			break ;;
   1332 		    *)
   1333 			break ;;
   1334 		esac
   1335 	done
   1336 
   1337 	if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ] || [ $# -gt 3 ]; then
   1338 		cmd_usage 'Usage: ' generate >&2
   1339 		exit 1
   1340 	fi
   1341 
   1342 	unset PARSE_ERROR
   1343 
   1344 	check_sneaky_path "$1"
   1345 	LENGTH="${2:-${GENERATED_LENGTH}}"
   1346 	[ -n "${LENGTH##*[!0-9]*}" ] \
   1347 	    || die "Error: passlength \"${LENGTH}\" must be a number."
   1348 	[ "${LENGTH}" -gt 0 ] \
   1349 	    || die "Error: pass-length must be greater than zero."
   1350 
   1351 	do_generate "$1" "${LENGTH}" "${3:-${CHARSET}}"
   1352 
   1353 	unset CHARSET
   1354 	unset LENGTH
   1355 }
   1356 
   1357 cmd_git() {
   1358 	if [ -d "${PREFIX}/.git" ]; then
   1359 		platform_tmpdir
   1360 		TMPDIR="${SECURE_TMPDIR}" git -C "${PREFIX}" "$@"
   1361 	elif [ "${1-}" = init ]; then
   1362 		mkdir -p -- "${PREFIX}"
   1363 		git -C "${PREFIX}" "$@"
   1364 		scm_add '.'
   1365 		scm_commit "Add current contents of password store."
   1366 		cmd_gitconfig
   1367 	elif [ "${1-}" = clone ]; then
   1368 		git "$@" "${PREFIX}"
   1369 		cmd_gitconfig
   1370 	else
   1371 		die "Error: the password store is not a git repository." \
   1372 		    "Try \"${PROGRAM} git init\"."
   1373 	fi
   1374 }
   1375 
   1376 cmd_grep() {
   1377 	if [ $# -eq 0 ]; then
   1378 		cmd_usage 'Usage: ' grep >&2
   1379 		exit 1
   1380 	fi
   1381 
   1382 	( cd "${PREFIX}" && do_grep "" "$@" )
   1383 }
   1384 
   1385 cmd_gitconfig() {
   1386 	[ -d "${PREFIX}/.git" ] || die "The store is not a git repository."
   1387 
   1388 	if ! [ -f "${PREFIX}/.gitattributes" ] ||
   1389 	    ! grep -Fqx '*.age diff=age' "${PREFIX}/.gitattributes"
   1390 	then
   1391 		scm_begin
   1392 		printf '*.age diff=age\n' >>"${PREFIX}/.gitattributes"
   1393 		scm_add ".gitattributes"
   1394 		scm_commit "Configure git repository for age file diff."
   1395 	fi
   1396 
   1397 	git -C "${PREFIX}" config --local diff.age.binary true
   1398 	git -C "${PREFIX}" config --local diff.age.textconv \
   1399 	    "${AGE} -d -i ${IDENTITIES_FILE}"
   1400 }
   1401 
   1402 cmd_help() {
   1403 	cmd_version
   1404 	printf '\n'
   1405 	cmd_usage '    '
   1406 }
   1407 
   1408 cmd_init() {
   1409 	DECISION=default
   1410 	OVERWRITE=yes
   1411 	PARSE_ERROR=no
   1412 	SUBDIR=''
   1413 
   1414 	while [ $# -ge 1 ]; do
   1415 		case "$1" in
   1416 		    -i|--interactive)
   1417 			[ "${DECISION}" = default ] || PARSE_ERROR=yes
   1418 			DECISION=interactive
   1419 			shift ;;
   1420 
   1421 		    -k|--keep)
   1422 			[ "${DECISION}" = default ] || PARSE_ERROR=yes
   1423 			DECISION=keep
   1424 			shift ;;
   1425 
   1426 		    -p|--path)
   1427 			if [ $# -lt 2 ]; then
   1428 				PARSE_ERROR=yes
   1429 				break
   1430 			fi
   1431 
   1432 			SUBDIR="$2"
   1433 			shift 2 ;;
   1434 
   1435 		    -p?*)
   1436 			SUBDIR="${1#-p}"
   1437 			shift ;;
   1438 
   1439 		    --path=*)
   1440 			SUBDIR="${1#--path=}"
   1441 			shift ;;
   1442 
   1443 		    -[ik]?*)
   1444 			REST="${1#-?}"
   1445 			ARG="${1%"${REST}"}"
   1446 			shift
   1447 			set -- "${ARG}" "-${REST}" "$@"
   1448 			unset ARG
   1449 			unset REST
   1450 			;;
   1451 
   1452 		    --)
   1453 			shift
   1454 			break ;;
   1455 
   1456 		    -*)
   1457 			PARSE_ERROR=yes
   1458 			break ;;
   1459 
   1460 		    *)
   1461 			break ;;
   1462 		esac
   1463 	done
   1464 
   1465 	if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ]; then
   1466 		cmd_usage 'Usage: ' init >&2
   1467 		exit 1
   1468 	fi
   1469 
   1470 	check_sneaky_path "${SUBDIR}"
   1471 
   1472 	if [ $# -eq 1 ] && [ -z "$1" ]; then
   1473 		do_deinit "${SUBDIR}"
   1474 	else
   1475 		do_init "${SUBDIR}" "$@"
   1476 	fi
   1477 
   1478 	unset PARSE_ERROR
   1479 	unset SUBDIR
   1480 }
   1481 
   1482 cmd_insert() {
   1483 	ECHO=no
   1484 	MULTILINE=no
   1485 	OVERWRITE=no
   1486 	PARSE_ERROR=no
   1487 
   1488 	while [ $# -ge 1 ]; do
   1489 		case "$1" in
   1490 		    -e|--echo)
   1491 			ECHO=yes
   1492 			shift ;;
   1493 		    -f|--force)
   1494 			OVERWRITE=yes
   1495 			shift ;;
   1496 		    -m|--multiline)
   1497 			MULTILINE=yes
   1498 			shift ;;
   1499 		    -[efm]?*)
   1500 			REST="${1#-?}"
   1501 			ARG="${1%"${REST}"}"
   1502 			shift
   1503 			set -- "${ARG}" "-${REST}" "$@"
   1504 			unset ARG
   1505 			unset REST
   1506 			;;
   1507 		    --)
   1508 			shift
   1509 			break ;;
   1510 		    -?*)
   1511 			PARSE_ERROR=yes
   1512 			break ;;
   1513 		    *)
   1514 			break ;;
   1515 		esac
   1516 	done
   1517 
   1518 	if [ "${PARSE_ERROR}" = yes ] \
   1519             || [ $# -lt 1 ] \
   1520             || [ "${ECHO}${MULTILINE}" = yesyes ]
   1521 	then
   1522 		cmd_usage 'Usage: ' insert >&2
   1523 		exit 1
   1524 	fi
   1525 	unset PARSE_ERROR
   1526 
   1527 	check_sneaky_paths "$@"
   1528 
   1529 	for ARG in "$@"; do
   1530 		do_insert "${ARG}"
   1531 	done
   1532 	unset ARG
   1533 }
   1534 
   1535 cmd_list_or_show() {
   1536 	LIST_VIEW=no
   1537 	PARSE_ERROR=no
   1538 	SELECTED_LINE=1
   1539 	USE_CLIP=no
   1540 	USE_QRCODE=no
   1541 
   1542 	while [ $# -ge 1 ]; do
   1543 		case "$1" in
   1544 		    -c|--clip)
   1545 			USE_CLIP=yes
   1546 			shift ;;
   1547 		    -c?*)
   1548 			SELECTED_LINE="${1#-c}"
   1549 			USE_CLIP=yes
   1550 			shift ;;
   1551 		    --clip=*)
   1552 			SELECTED_LINE="${1#--clip=}"
   1553 			USE_CLIP=yes
   1554 			shift ;;
   1555 		    -q|--qrcode)
   1556 			USE_QRCODE=yes
   1557 			shift ;;
   1558 		    -q?*)
   1559 			SELECTED_LINE="${1#-q}"
   1560 			USE_QRCODE=yes
   1561 			shift ;;
   1562 		    --qrcode=*)
   1563 			SELECTED_LINE="${1#--qrcode=}"
   1564 			USE_QRCODE=yes
   1565 			shift ;;
   1566 		    -r|--raw)
   1567 			LIST_VIEW=yes
   1568 			shift ;;
   1569 		    --)
   1570 			shift
   1571 			break ;;
   1572 		    -*)
   1573 			PARSE_ERROR=yes
   1574 			break ;;
   1575 		    *)
   1576 			break ;;
   1577 		esac
   1578 	done
   1579 
   1580 	case "${USE_CLIP}-${USE_QRCODE}" in
   1581 	    no-no)
   1582 		SHOW=text
   1583 		;;
   1584 	    yes-no)
   1585 		SHOW=clip
   1586 		;;
   1587 	    no-yes)
   1588 		SHOW=qrcode
   1589 		;;
   1590 	    *)
   1591 		PARSE_ERROR=yes
   1592 		;;
   1593 	esac
   1594 
   1595 	if [ "${PARSE_ERROR}" = yes ]; then
   1596 		if [ "${COMMAND}" = "l${COMMAND#l}" ]; then
   1597 			cmd_usage 'Usage: ' list >&2
   1598 			exit 1
   1599 		elif [ "${COMMAND}" = "s${COMMAND#s}" ]; then
   1600 			cmd_usage 'Usage: ' show >&2
   1601 			exit 1
   1602 		else
   1603 			cmd_usage 'Usage: ' list show >&2
   1604 			exit 1
   1605 		fi
   1606 	fi
   1607 	unset PARSE_ERROR
   1608 
   1609 	check_sneaky_paths "$@"
   1610 
   1611 	if [ $# -eq 0 ]; then
   1612 		do_list_or_show ""
   1613 	else
   1614 		for ARG in "$@"; do
   1615 			do_list_or_show "${ARG}"
   1616 		done
   1617 	fi
   1618 
   1619 	unset ARG
   1620 	unset PARSING
   1621 }
   1622 
   1623 cmd_move() {
   1624 	ACTION=Move
   1625 	SCM_ACTION=scm_mv
   1626 	cmd_copy_move "$@"
   1627 }
   1628 
   1629 cmd_random() {
   1630 	if [ $# -gt 2 ]; then
   1631 		cmd_usage 'Usage: ' random >&2
   1632 		exit 1
   1633 	fi
   1634 
   1635 	random_chars "${1:-${GENERATED_LENGTH}}" "${2:-${CHARACTER_SET}}"
   1636 }
   1637 
   1638 cmd_reencrypt() {
   1639 	DECISION=default
   1640 	OVERWRITE=yes
   1641 	PARSE_ERROR=no
   1642 
   1643 	while [ $# -ge 1 ]; do
   1644 		case "$1" in
   1645 		    -i|--interactive)
   1646 			DECISION=interactive
   1647 			shift ;;
   1648 		    --)
   1649 			shift
   1650 			break ;;
   1651 		    -*)
   1652 			PARSE_ERROR=yes
   1653 			break ;;
   1654 		    *)
   1655 			break ;;
   1656 		esac
   1657 	done
   1658 
   1659 	if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ]; then
   1660 		cmd_usage 'Usage: ' reencrypt >&2
   1661 		exit 1
   1662 	fi
   1663 
   1664 	unset PARSE_ERROR
   1665 
   1666 	check_sneaky_paths "$@"
   1667 
   1668 	for ARG in "$@"; do
   1669 		do_reencrypt "${ARG}"
   1670 	done
   1671 	unset ARG
   1672 }
   1673 
   1674 # Outputs the whole usage text
   1675 #   $1: indentation
   1676 #   ... commands to document
   1677 cmd_usage(){
   1678 	if [ $# -eq 0 ]; then
   1679 		F='    '
   1680 		I='    '
   1681 	else
   1682 		F="$1"
   1683 		NON_BLANK="$1"
   1684 		I=''
   1685 		while [ -n "${NON_BLANK}" ]; do
   1686 			I=" ${I}"
   1687 			NON_BLANK="${NON_BLANK#?}"
   1688 		done
   1689 		shift
   1690 	fi
   1691 
   1692 	if [ $# -eq 0 ]; then
   1693 		echo 'Usage:'
   1694 		set -- list show copy delete edit find generate git gitconfig \
   1695 		    grep help init insert move random reencrypt version
   1696 		VERBOSE=yes
   1697 	else
   1698 		VERBOSE=no
   1699 	fi
   1700 
   1701 	NON_BLANK="${PROGRAM}"
   1702 	BLANKPG=''
   1703 	while [ -n "${NON_BLANK}" ]; do
   1704 		BLANKPG=" ${BLANKPG}"
   1705 		NON_BLANK="${NON_BLANK#?}"
   1706 	done
   1707 	unset NON_BLANK
   1708 
   1709 	for ARG in "$@"; do
   1710 		case "${ARG}" in
   1711 		    list)
   1712 			cat <<EOF
   1713 ${F}${PROGRAM} [list] [--raw,-r] [subfolder]
   1714 EOF
   1715 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1716 ${I}    List passwords as a tree or as a raw list.
   1717 EOF
   1718 			;;
   1719 		    show)
   1720 			cat <<EOF
   1721 ${F}${PROGRAM} [show] [--clip[=line-number],-c[line-number] |
   1722 ${I}${BLANKPG}         --qrcode[=line-number],-q[line-number]] pass-name
   1723 EOF
   1724 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1725 ${I}    Show existing password and optionally put it on the clipboard
   1726 ${I}    or display it as a QR-code.
   1727 ${I}    If put on the clipboard, it will be cleared in ${CLIP_TIME:-45} seconds.
   1728 EOF
   1729 			;;
   1730 		    copy)
   1731 			cat <<EOF
   1732 ${F}${PROGRAM} copy [--reencrypt,-e | --interactive,-i | --keep,-k ]
   1733 ${I}${BLANKPG}      [--force,-f] old-path new-path
   1734 EOF
   1735 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1736 ${I}    Copies old-path to new-path, optionally forcefully,
   1737 ${I}    reencrypting if needed or forced.
   1738 EOF
   1739 			;;
   1740 		    delete)
   1741 			cat <<EOF
   1742 ${F}${PROGRAM} delete [--recursive,-r] [--force,-f] pass-name
   1743 EOF
   1744 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1745 ${I}    Remove existing passwords or directories, optionally forcefully.
   1746 EOF
   1747 			;;
   1748 		    edit)
   1749 			cat <<EOF
   1750 ${F}${PROGRAM} edit pass-name
   1751 EOF
   1752 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1753 ${I}    Insert a new password or edit an existing password using an editor.
   1754 EOF
   1755 			;;
   1756 		    find)
   1757 			cat <<EOF
   1758 ${F}${PROGRAM} find [--raw,-r] [GREP_OPTIONS] regex
   1759 EOF
   1760 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1761 ${I}    List passwords that match the given regex.
   1762 EOF
   1763 			;;
   1764 		    generate)
   1765 			cat <<EOF
   1766 ${F}${PROGRAM} generate [--no-symbols,-n] [--clip,-c | --qrcode,-q]
   1767 ${I}${BLANKPG}          [--in-place,-i | --force,-f] [--multiline,-m]
   1768 ${I}${BLANKPG}          [--try,-t] pass-name [pass-length [character-set]]
   1769 EOF
   1770 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1771 ${I}    Generate a new password of pass-length (or ${GENERATED_LENGTH:-25} if unspecified)
   1772 ${I}    with optionally no symbols.
   1773 ${I}    Optionally put it on the clipboard and clear board after ${CLIP_TIME:-45} seconds
   1774 ${I}    or display it as a QR-code.
   1775 ${I}    Prompt before overwriting existing password unless forced.
   1776 ${I}    Optionally replace only the first line of an existing file
   1777 ${I}    with a new password.
   1778 ${I}    Optionally prompt for confirmation between generation and saving.
   1779 EOF
   1780 			;;
   1781 		    git)
   1782 			cat <<EOF
   1783 ${F}${PROGRAM} git git-command-args ...
   1784 EOF
   1785 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1786 ${I}    If the password store is a git repository, execute a git command
   1787 ${I}    specified by git-command-args.
   1788 EOF
   1789 			;;
   1790 		    gitconfig)
   1791 			cat <<EOF
   1792 ${F}${PROGRAM} gitconfig
   1793 EOF
   1794 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1795 ${I}    If the password store is a git repository, enforce local configuration.
   1796 EOF
   1797 			;;
   1798 		    grep)
   1799 			cat <<EOF
   1800 ${F}${PROGRAM} grep [GREP_OPTIONS] search-regex
   1801 EOF
   1802 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1803 ${I}    Search for password files matching search-regex when decrypted.
   1804 EOF
   1805 			;;
   1806 		    help)
   1807 			cat <<EOF
   1808 ${F}${PROGRAM} help
   1809 EOF
   1810 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1811 ${I}    Show this text.
   1812 EOF
   1813 			;;
   1814 		    init)
   1815 			cat <<EOF
   1816 ${F}${PROGRAM} init [--interactive,-i | --keep,-k ]
   1817 ${I}${BLANKPG}      [--path=subfolder,-p subfolder] age-recipient ...
   1818 EOF
   1819 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1820 ${I}    Initialize new password storage and use the given age recipients
   1821 ${I}    for encryption.
   1822 ${I}    Selectively reencrypt existing passwords using new recipients.
   1823 EOF
   1824 			;;
   1825 		    insert)
   1826 			cat <<EOF
   1827 ${F}${PROGRAM} insert [--echo,-e | --multiline,-m] [--force,-f] pass-name
   1828 EOF
   1829 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1830 ${I}    Insert new password. Optionally, echo the password back to the console
   1831 ${I}    during entry. Or, optionally, the entry may be multiline.
   1832 ${I}    Prompt before overwriting existing password unless forced.
   1833 EOF
   1834 			;;
   1835 		    move)
   1836 			cat <<EOF
   1837 ${F}${PROGRAM} move [--reencrypt,-e | --interactive,-i | --keep,-k ]
   1838 ${I}${BLANKPG}      [--force,-f] old-path new-path
   1839 EOF
   1840 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1841 ${I}    Renames or moves old-path to new-path, optionally forcefully,
   1842 ${I}    reencrypting if needed or forced.
   1843 EOF
   1844 			;;
   1845 		    random)
   1846 			cat <<EOF
   1847 ${F}${PROGRAM} random [pass-length [character-set]]
   1848 EOF
   1849 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1850 ${I}    Generate a new password of pass-length (or ${GENERATED_LENGTH:-25} if unspecified)
   1851 ${I}    using the given character set (or ${CHARACTER_SET} if unspecified)
   1852 ${I}    without recording it in the password store.
   1853 EOF
   1854 			;;
   1855 		    reencrypt)
   1856 			cat <<EOF
   1857 ${F}${PROGRAM} reencrypt [--interactive,-i] pass-name|subfolder ...
   1858 EOF
   1859 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1860 ${I}    Re-encrypt in-place a secret or all the secrets in a subfolder,
   1861 ${I}    optionally asking before each one.
   1862 EOF
   1863 			;;
   1864 		    version)
   1865 			cat <<EOF
   1866 ${F}${PROGRAM} version
   1867 EOF
   1868 			[ "${VERBOSE}" = yes ] && cat <<EOF
   1869 ${I}    Show version information.
   1870 EOF
   1871 			;;
   1872 		    *)
   1873 			die "cmd_usage: unknown command \"${ARG}\""
   1874 			;;
   1875 		esac
   1876 
   1877 		F="${I}"
   1878 	done
   1879 }
   1880 
   1881 cmd_version() {
   1882 	cat <<-EOF
   1883 	==============================================
   1884 	= pashage: age-backed POSIX password manager =
   1885 	=                                            =
   1886 	=                   v0.1.0                   =
   1887 	=                                            =
   1888 	=            Natasha Kerensikova             =
   1889 	=                                            =
   1890 	=                 Based on:                  =
   1891 	=   password-store  by Jason A. Donenfeld    =
   1892 	=          passage  by Filippo Valsorda      =
   1893 	=             pash  by Dylan Araps           =
   1894 	==============================================
   1895 	EOF
   1896 }