[PATCH] Add symbolic link command

Ethan Alker ealker1 at gmail.com
Sun Aug 3 01:49:36 UTC 2025


---
A small patch to add a symbolic link command, for cases where the same
credentials are used in multiple places. For example, Microsoft has
about twenty domains that accept login information, so in order for
auto-fill to work on each domain with my setup I had to link each domain
to the same password.  While you could also just use 'cp', that would
then require updating each password individually if the password ever
changed or if other entries are added.

 man/pass.1                          |  8 +++++
 src/completion/pass.bash-completion |  4 +--
 src/completion/pass.fish-completion |  4 +++
 src/completion/pass.zsh-completion  |  3 +-
 src/password-store.sh               | 21 ++++++++----
 tests/t0070-ln-tests.sh             | 51 +++++++++++++++++++++++++++++
 6 files changed, 81 insertions(+), 10 deletions(-)
 create mode 100755 tests/t0070-ln-tests.sh

diff --git a/man/pass.1 b/man/pass.1
index a555dcb..d0e9943 100644
--- a/man/pass.1
+++ b/man/pass.1
@@ -164,6 +164,14 @@ silently overwrite \fInew-path\fP if it exists. If \fInew-path\fP ends in a
 trailing \fI/\fP, it is always treated as a directory. Passwords are selectively
 reencrypted to the corresponding keys of their new destination.
 .TP
+\fBln\fP [ \fI--force\fP, \fI-f\fP ] \fItarget\fP \fIlink-path\fP
+Adds a symbolic link at the path \fIlink-path\fP to the password or directory
+named \fItarget\fP. This command is alternatively named \fBlink\fP. If
+\fI--force\fP is specified, silently overwrite \fIlink-path\fP if it exists. If
+\fIlink-path\fP ends in a trailing \fI/\fP, it is always treated as a directory.
+Passwords are not reencrypted to the corresponding keys of the link
+destination.
+.TP
 \fBgit\fP \fIgit-command-args\fP...
 If the password store is a git repository, pass \fIgit-command-args\fP as arguments to
 .BR git (1)
diff --git a/src/completion/pass.bash-completion b/src/completion/pass.bash-completion
index 2d23cbf..e7dc9dc 100644
--- a/src/completion/pass.bash-completion
+++ b/src/completion/pass.bash-completion
@@ -84,7 +84,7 @@ _pass()
 {
 	COMPREPLY=()
 	local cur="${COMP_WORDS[COMP_CWORD]}"
-	local commands="init ls find grep show insert generate edit rm mv cp git help version ${PASSWORD_STORE_EXTENSION_COMMANDS[*]}"
+	local commands="init ls find grep show insert generate edit rm mv cp ln git help version ${PASSWORD_STORE_EXTENSION_COMMANDS[*]}"
 	if [[ $COMP_CWORD -gt 1 ]]; then
 		local lastarg="${COMP_WORDS[$COMP_CWORD-1]}"
 		case "${COMP_WORDS[1]}" in
@@ -112,7 +112,7 @@ _pass()
 				COMPREPLY+=($(compgen -W "-n --no-symbols -c --clip -f --force -i --in-place" -- ${cur}))
 				_pass_complete_entries
 				;;
-			cp|copy|mv|rename)
+			cp|copy|mv|rename|ln|link)
 				COMPREPLY+=($(compgen -W "-f --force" -- ${cur}))
 				_pass_complete_entries
 				;;
diff --git a/src/completion/pass.fish-completion b/src/completion/pass.fish-completion
index 0f57dd2..6690ab5 100644
--- a/src/completion/pass.fish-completion
+++ b/src/completion/pass.fish-completion
@@ -87,6 +87,10 @@ complete -c $PROG -f -n '__fish_pass_needs_command' -a cp -d 'Command: copy exis
 complete -c $PROG -f -n '__fish_pass_uses_command cp' -s f -l force -d 'Force copy'
 complete -c $PROG -f -n '__fish_pass_uses_command cp' -a "(__fish_pass_print_entries_and_dirs)"
 
+complete -c $PROG -f -n '__fish_pass_needs_command' -a ln -d 'Command: add symbolic link to existing password'
+complete -c $PROG -f -n '__fish_pass_uses_command ln' -s f -l force -d 'Force link'
+complete -c $PROG -f -n '__fish_pass_uses_command ln' -a "(__fish_pass_print_entries_and_dirs)"
+
 complete -c $PROG -f -n '__fish_pass_needs_command' -a rm -d 'Command: remove existing password'
 complete -c $PROG -f -n '__fish_pass_uses_command rm' -s r -l recursive -d 'Remove password groups recursively'
 complete -c $PROG -f -n '__fish_pass_uses_command rm' -s f -l force -d 'Force removal'
diff --git a/src/completion/pass.zsh-completion b/src/completion/pass.zsh-completion
index d911e12..9faa135 100644
--- a/src/completion/pass.zsh-completion
+++ b/src/completion/pass.zsh-completion
@@ -58,7 +58,7 @@ _pass () {
 					"--in-place[replace first line]"
 				_pass_complete_entries_with_subdirs
 				;;
-			cp|copy|mv|rename)
+			cp|copy|mv|rename|ln|link)
 				_arguments : \
 					"-f[force rename]" \
 					"--force[force rename]"
@@ -101,6 +101,7 @@ _pass () {
 			"edit:Edit a password with \$EDITOR"
 			"mv:Rename the password"
 			"cp:Copy the password"
+			"ln:Add symbolic link to the password"
 			"rm:Remove the password"
 			"git:Call git on the password store"
 			"version:Output version information"
diff --git a/src/password-store.sh b/src/password-store.sh
index 22e818f..5d33164 100755
--- a/src/password-store.sh
+++ b/src/password-store.sh
@@ -283,7 +283,7 @@ cmd_usage() {
 	    $PROGRAM [ls] [subfolder]
 	        List passwords.
 	    $PROGRAM find pass-names...
-	    	List passwords that match pass-names.
+	        List passwords that match pass-names.
 	    $PROGRAM [show] [--clip[=line-number],-c[line-number]] pass-name
 	        Show existing password and optionally put it on the clipboard.
 	        If put on the clipboard, it will be cleared in $CLIP_TIME seconds.
@@ -306,6 +306,8 @@ cmd_usage() {
 	        Renames or moves old-path to new-path, optionally forcefully, selectively reencrypting.
 	    $PROGRAM cp [--force,-f] old-path new-path
 	        Copies old-path to new-path, optionally forcefully, selectively reencrypting.
+	    $PROGRAM ln [--force,-f] target link-path
+	        Add symbolic link from link-path to target, optionally forcefully. Does not reencrypt.
 	    $PROGRAM git git-command-args...
 	        If the password store is a git repository, execute a git command
 	        specified by git-command-args.
@@ -594,9 +596,10 @@ cmd_delete() {
 	rmdir -p "${passfile%/*}" 2>/dev/null
 }
 
-cmd_copy_move() {
-	local opts move=1 force=0
-	[[ $1 == "copy" ]] && move=0
+cmd_copy_move_link() {
+	local opts action="move" force=0
+	[[ $1 == "copy" ]] && action="copy"
+	[[ $1 == "link" ]] && action="link"
 	shift
 	opts="$($GETOPT -o f -l force -n "$PROGRAM" -- "$@")"
 	local err=$?
@@ -625,7 +628,7 @@ cmd_copy_move() {
 	[[ ! -t 0 || $force -eq 1 ]] && interactive="-f"
 
 	set_git "$new_path"
-	if [[ $move -eq 1 ]]; then
+	if [[ $action == "move" ]]; then
 		mv $interactive -v "$old_path" "$new_path" || exit 1
 		[[ -e "$new_path" ]] && reencrypt_path "$new_path"
 
@@ -642,6 +645,9 @@ cmd_copy_move() {
 			[[ -n $(git -C "$INNER_GIT_DIR" status --porcelain "$old_path") ]] && git_commit "Remove ${1}."
 		fi
 		rmdir -p "$old_dir" 2>/dev/null
+	elif [[ $action == "link" ]]; then
+		ln $interactive -s -r -v "$old_path" "$new_path" || exit 1
+		git_add_file "$new_path" "Link ${2} to ${1}."
 	else
 		cp $interactive -r -v "$old_path" "$new_path" || exit 1
 		[[ -e "$new_path" ]] && reencrypt_path "$new_path"
@@ -713,8 +719,9 @@ case "$1" in
 	edit) shift;			cmd_edit "$@" ;;
 	generate) shift;		cmd_generate "$@" ;;
 	delete|rm|remove) shift;	cmd_delete "$@" ;;
-	rename|mv) shift;		cmd_copy_move "move" "$@" ;;
-	copy|cp) shift;			cmd_copy_move "copy" "$@" ;;
+	rename|mv) shift;		cmd_copy_move_link "move" "$@" ;;
+	copy|cp) shift;			cmd_copy_move_link "copy" "$@" ;;
+	link|ln) shift;			cmd_copy_move_link "link" "$@" ;;
 	git) shift;			cmd_git "$@" ;;
 	*)				cmd_extension_or_show "$@" ;;
 esac
diff --git a/tests/t0070-ln-tests.sh b/tests/t0070-ln-tests.sh
new file mode 100755
index 0000000..203d2f5
--- /dev/null
+++ b/tests/t0070-ln-tests.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+
+test_description='Test ln command'
+cd "$(dirname "$0")"
+. ./setup.sh
+
+INITIAL_PASSWORD="bla bla bla will we make it!!"
+
+test_expect_success 'Basic link command' '
+	"$PASS" init $KEY1 &&
+	"$PASS" git init &&
+	"$PASS" insert -e cred1 <<<"$INITIAL_PASSWORD" &&
+	"$PASS" ln cred1 cred2 &&
+	[[ -L $PASSWORD_STORE_DIR/cred2.gpg && -e $PASSWORD_STORE_DIR/cred1.gpg ]]
+'
+
+test_expect_success 'Directory creation' '
+	"$PASS" ln cred2 directory/ &&
+	[[ -d $PASSWORD_STORE_DIR/directory && -L $PASSWORD_STORE_DIR/directory/cred2.gpg ]]
+'
+
+test_expect_success 'Directory creation with file rename' '
+	"$PASS" ln directory/cred2 "new directory with spaces"/cred &&
+	[[ -d $PASSWORD_STORE_DIR/"new directory with spaces" && -e $PASSWORD_STORE_DIR/"new directory with spaces"/cred.gpg && -d $PASSWORD_STORE_DIR/directory ]]
+'
+
+test_expect_success 'Directory link' '
+	"$PASS" ln "new directory with spaces" anotherdirectory &&
+	[[ -L $PASSWORD_STORE_DIR/anotherdirectory && -e $PASSWORD_STORE_DIR/anotherdirectory/cred.gpg && -d $PASSWORD_STORE_DIR/"new directory with spaces" ]]
+'
+
+test_expect_success 'Directory ln into new directory' '
+	"$PASS" ln anotherdirectory "another directory with spaces"/ &&
+	[[ -L $PASSWORD_STORE_DIR/"another directory with spaces"/anotherdirectory && -e $PASSWORD_STORE_DIR/"new directory with spaces"/cred.gpg && -L $PASSWORD_STORE_DIR/anotherdirectory ]]
+'
+
+test_expect_success 'Multi-directory creation and multi-directory empty removal' '
+	"$PASS" ln "new directory with spaces"/cred new1/new2/new3/new4/thecred &&
+	"$PASS" ln new1/new2/new3/new4/thecred cred &&
+	[[ -d $PASSWORD_STORE_DIR/"new directory with spaces" && -d $PASSWORD_STORE_DIR/new1/new2/new3/new4 && -L $PASSWORD_STORE_DIR/cred.gpg ]]
+'
+
+test_expect_success 'Password made it until the end' '
+	[[ $("$PASS" show cred) == "$INITIAL_PASSWORD" ]]
+'
+
+test_expect_success 'Git is consistent' '
+	[[ -z $(git status --porcelain 2>&1) ]]
+'
+
+test_done
-- 
2.50.1



More information about the Password-Store mailing list