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


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