commit 6c95a954cf1f2bae5a6fb50f795d5e5a5e74ff3b
parent d5cb4bee4f7d44d118267537246f229b54b5cef9
Author: Natasha Kerensikova <natgh@instinctive.eu>
Date: Tue, 10 Sep 2024 11:46:03 +0000
First draft of the project
Diffstat:
25 files changed, 5595 insertions(+), 0 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
@@ -0,0 +1,48 @@
+# pashage Architecture and Design Choices
+
+## Source Overview
+
+The following files are present in `src/` directory:
+
+ - `pashage.sh` defines shell functions, providing most of the functionality,
+ - `platform-*.sh` defines platform-specific helper shell functions,
+ - `run.sh` prepares the environment and calls the relevant function.
+
+Note that `run.sh` detects dynamically the platform, like `pass` and
+`passage`, but the author intended `pashage` to be the platform-specific
+amalgamation of the relevant sources.
+
+The shell functions are organized in prefix-designated layers, from the
+highest to the lowest:
+
+ - `cmd_`-prefixed functions implement the commands, by parsing arguments
+and calling the relevant actions;
+ - `do_`-prefixed functions implemenmt the actions, which are the core logic
+of the program;
+ - `scm_`-prefixed functions are an abstraction over git and some file-system
+operations on the checkout;
+ - `platform_`-prefixed function are an abstraction of platform-specific
+operations;
+ - prefixless internal helper functions are used throughout the program.
+
+## Test Overview
+
+Best practices are enforced using [shellcheck](https://www.shellcheck.net/),
+and tests are performed using [shellspec](https://shellspec.info/)
+in sandbox mode.
+
+The following test sets can be found in `spec/` directory:
+
+- `internal_spec.sh` tests internal helper functions in isolation;
+- `action_spec.sh` tests action functions in isolation, mocking everything;
+- `usage_spec.sh` tests command functions in isolation, mocking everything;
+- TODO tests SCM functions in isolation;
+- TODO tests integration, calling command functions with minimal mocks;
+- TODO tests `pass`-like behavior of the whole script;
+- TODO tests `passage`-like behavior of the whole script.
+
+Platform functions are not tested, because the platform adherence make it
+too difficult to test it automatically.
+
+`age`, `git`, and `gpg` are always mocked, to make the tests reproducible
+and the failures easier to investigate.
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,355 @@
+Pashage is Copyright (C) 2024 Natasha Kerensikova. All Rights Reserved.
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+
+
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, write to the Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Ty Coon>, 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
+
+
diff --git a/Makefile b/Makefile
@@ -0,0 +1,49 @@
+# pashage - age-backed POSIX password manager
+# Copyright (C) 2024 Natasha Kerensikova
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+PLATFORM != uname | cut -d _ -f 1 | tr '[:upper:]' '[:lower:]'
+
+pashage: bin/pashage-$(PLATFORM).sh
+ cp -i "$>" "$@"
+
+.PHONY: all check clean cov tests validate
+
+all: bin/pashage-freebsd.sh
+
+check: bin/pashage-freebsd.sh
+ shellcheck -o all $>
+
+clean:
+ rm -rf pashage bin/
+
+cov:
+ shellspec --kcov -s bash
+
+tests:
+ shellspec
+
+validate: check tests cov
+
+bin/pashage-freebsd.sh: src/platform-freebsd.sh src/pashage.sh src/run.sh
+ mkdir -p bin
+ sed '1{;x;d;};/^###########$$/{;x;q;};x' src/run.sh >|"$@"
+ sed '1,/^$$/d' src/platform-freebsd.sh >>"$@"
+ echo >>"$@"
+ sed '1,/^$$/d' src/pashage.sh >>"$@"
+ echo >>"$@"
+ echo '############' >>"$@"
+ sed '1,/^############$$/d' src/run.sh >>"$@"
diff --git a/README.md b/README.md
@@ -0,0 +1,67 @@
+[![Casual Maintenance Intended](https://casuallymaintained.tech/badge.svg)](https://casuallymaintained.tech/)
+
+# pashage
+
+Yet Another Opinionated Re-engineering of the Unix Password Store
+
+Core objectives:
+
+- same interface and similar feature set
+ as [pass](https://www.passwordstore.org/)
+- simplicity, understandability, and hackability, from using POSIX shell,
+ like [pash](https://github.com/dylanaraps/pash)
+- [age](https://age-encryption.org) as encryption backend,
+ like [passage](https://github.com/FiloSottile/passage)
+- validation using [shellcheck](https://www.shellcheck.net/)
+ and [shellspec tests](https://shellspec.info/)
+
+Portability is not a core objective, but a nice side-effect of using
+basic POSIX shell, and it is embraced when possible.
+
+Security is not branded as a core objective, because the author does not
+have the clout to declare anything secure, and you should probably not
+trust random READMEs anyway.
+However the simplicity should help you assess whether this password store
+is a worthwhile compromise for _your_ threat model.
+
+For the reference, the author has views [similar to those of Filippo
+Valsorda](https://words.filippo.io/dispatches/passage/) and considers
+the password store shell script to be about as critical as the rest
+of her computer, and relies mostly on age to provide secure encryption
+at rest and on a [YubiKey](https://www.yubico.com/) to gatekeep decryption.
+
+## Licencing
+
+This project was written from scratch, and every character of the script
+was typed with my fingers.
+However I looked deeply into pass, passage, and pash code bases.
+I don't know whether that's enough to make it a derivative work covered
+by the GPL, so to be on the safe side I'm using GPL v2+ too.
+
+## Differences with `pass`
+
+### Behavior Differences
+
+- The `edit` command does not warn a about using `/tmp` rather than
+`/dev/shm`, because the warning does not seem actionable and quickly
+becomes ignored noise.
+
+- The `edit` command uses `$VISUAL` rather than `$EDITOR` when it set and
+the terminal is not dumb.
+
+- The `find` command search-pattern is a regular expression rather than
+a glob.
+
+- The `init` command is redesigned to accommodate `age` backend.
+I didn't really understand the original `init` command, so I'm not sure
+how different it is; but now it installs `.age-recipients` and re-encrypts.
+
+- TODO
+
+### New Features and Extensions
+
+TODO
+
+## Manual
+
+TODO
diff --git a/spec/action_spec.sh b/spec/action_spec.sh
@@ -0,0 +1,1633 @@
+# pashage - age-backed POSIX password manager
+# Copyright (C) 2024 Natasha Kerensikova
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+# This test file fully covers all action functions in isolation,
+# with all interactions fully mocked.
+
+Describe 'Action Functions'
+ Include src/pashage.sh
+ Set 'errexit:on' 'nounset:on' 'pipefail:on'
+
+ mocklog() {
+ if [ $# -eq 1 ]; then
+ %printf '$ %s\n' "$1" >&2
+ else
+ %printf '$ %s' "$1" >&2
+ shift
+ %printf ' %s' "$@" >&2
+ %printf '\n' >&2
+ fi
+ }
+
+ Describe 'do_copy_move'
+ DECISION=default
+ OVERWRITE=yes
+ PREFIX="${SHELLSPEC_WORKDIR}/prefix"
+
+ ACTION=Move
+ SCM_ACTION=scm_mv
+
+ do_decrypt() {
+ mocklog do_decrypt "$@"
+ %putsn data
+ }
+
+ do_encrypt() {
+ @cat >/dev/null
+ mocklog do_encrypt "$@"
+ }
+
+ basename() { @basename "$@"; }
+ diff() { @diff "$@"; }
+ dirname() { @dirname "$@"; }
+
+ mkdir() { mocklog mkdir "$@"; }
+ scm_add() { mocklog scm_add "$@"; }
+ scm_begin() { mocklog scm_begin "$@"; }
+ scm_commit() { mocklog scm_commit "$@"; }
+ scm_cp() { mocklog scm_cp "$@"; }
+ scm_mv() { mocklog scm_mv "$@"; }
+ scm_rm() { mocklog scm_rm "$@"; }
+
+ setup() {
+ @mkdir -p "${PREFIX}/sub/bare/sub" "${PREFIX}/subdir/notes.txt"
+ %putsn 'identity 1' >"${PREFIX}/.age-recipients"
+ %putsn 'identity 2' >"${PREFIX}/sub/.age-recipients"
+ %putsn 'identity 2' >"${PREFIX}/subdir/.age-recipients"
+ %putsn data >"${PREFIX}/sub/secret.age"
+ %putsn data >"${PREFIX}/sub/bare/deep.age"
+ %putsn data >"${PREFIX}/sub/bare/sub/deepest.age"
+ %putsn data >"${PREFIX}/subdir/lower.age"
+ %putsn data >"${PREFIX}/root.age"
+ %putsn data >"${PREFIX}/notes.txt"
+ }
+
+ cleanup() {
+ @rm -rf "${PREFIX}"
+ }
+
+ BeforeEach setup
+ AfterEach cleanup
+
+ It 'renames a file without re-encrypting'
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}/sub
+ #|$ scm_begin
+ #|$ scm_mv sub/secret.age sub/renamed.age
+ #|$ scm_commit Move sub/secret.age to sub/renamed.age
+ }
+ When call do_copy_move sub/secret sub/renamed
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 're-encrypts when copying to another identity'
+ ACTION=Copy
+ SCM_ACTION=scm_cp
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}/sub/
+ #|$ scm_begin
+ #|$ do_decrypt ${PREFIX}/root.age
+ #|$ do_encrypt sub/root.age
+ #|$ scm_add sub/root.age
+ #|$ scm_commit Copy root.age to sub/root.age
+ }
+ When call do_copy_move root sub/
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'accepts explicit .age extensions'
+ ACTION=Copy
+ SCM_ACTION=scm_cp
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}/sub
+ #|$ scm_begin
+ #|$ do_decrypt ${PREFIX}/root.age
+ #|$ do_encrypt sub/moved.age
+ #|$ scm_add sub/moved.age
+ #|$ scm_commit Copy root.age to sub/moved.age
+ }
+ When call do_copy_move root.age sub/moved.age
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'can be prevented from re-encrypting when copying to another identity'
+ DECISION=keep
+ ACTION=Copy
+ SCM_ACTION=scm_cp
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}/sub/
+ #|$ scm_begin
+ #|$ scm_cp root.age sub/root.age
+ #|$ scm_commit Copy root.age to sub/root.age
+ }
+ When call do_copy_move root sub/
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'does not re-encrypt a non-encrypted file'
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}/sub/
+ #|$ scm_begin
+ #|$ scm_mv notes.txt sub/notes.txt
+ #|$ scm_commit Move notes.txt to sub/notes.txt
+ }
+ When call do_copy_move notes.txt sub/
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'does not re-encrypt a non-encrypted file even when forced'
+ DECISION=force
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}/sub/
+ #|$ scm_begin
+ #|$ scm_mv notes.txt sub/notes.txt
+ #|$ scm_commit Move notes.txt to sub/notes.txt
+ }
+ When call do_copy_move notes.txt sub/
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'moves a file without re-encrypting to another directory'
+ result() {
+ %text:expand
+ #|$ scm_begin
+ #|$ scm_mv sub/secret.age subdir/secret.age
+ #|$ scm_commit Move sub/secret.age to subdir/secret.age
+ }
+ When call do_copy_move sub/secret subdir
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'asks confirmation before overwriting a file'
+ OVERWRITE=no
+ rm() { mocklog rm "$@"; }
+ yesno() {
+ mocklog yesno "$@"
+ ANSWER=y
+ }
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}/sub
+ #|$ scm_begin
+ #|$ yesno sub/secret.age already exists. Overwrite?
+ #|$ rm -f ${PREFIX}/sub/secret.age
+ #|$ do_decrypt ${PREFIX}/root.age
+ #|$ do_encrypt sub/secret.age
+ #|$ scm_rm root.age
+ #|$ scm_add sub/secret.age
+ #|$ scm_commit Move root.age to sub/secret.age
+ }
+ When call do_copy_move root sub/secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'moves a whole directory with identity'
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}/subdir/sub/
+ #|$ scm_begin
+ #|$ scm_mv sub/ subdir/sub/
+ #|$ scm_commit Move sub/ to subdir/sub/
+ }
+ When call do_copy_move sub subdir/
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'recursively re-enecrypts a directory'
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}/subdir/new-bare/
+ #|$ scm_begin
+ #|$ do_decrypt ${PREFIX}/sub/bare/deep.age
+ #|$ do_encrypt subdir/new-bare/deep.age
+ #|$ scm_rm sub/bare/deep.age
+ #|$ scm_add subdir/new-bare/deep.age
+ #|$ mkdir -p ${PREFIX}/subdir/new-bare/sub
+ #|$ do_decrypt ${PREFIX}/sub/bare/sub/deepest.age
+ #|$ do_encrypt subdir/new-bare/sub/deepest.age
+ #|$ scm_rm sub/bare/sub/deepest.age
+ #|$ scm_add subdir/new-bare/sub/deepest.age
+ #|$ scm_commit Move sub/bare/ to subdir/new-bare/
+ }
+ When call do_copy_move sub/bare subdir/new-bare
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'interactively re-enecrypts or copies files from a directory'
+ DECISION=interactive
+ ACTION=Copy
+ SCM_ACTION=scm_cp
+ YESNO_NEXT=n
+ yesno() {
+ mocklog yesno "$@"
+ ANSWER="${YESNO_NEXT}"
+ YESNO_NEXT=y
+ }
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}/subdir/new-bare/
+ #|$ scm_begin
+ #|$ yesno Reencrypt sub/bare/deep into subdir/new-bare/deep?
+ #|$ scm_cp sub/bare/deep.age subdir/new-bare/deep.age
+ #|$ mkdir -p ${PREFIX}/subdir/new-bare/sub
+ #|$ yesno Reencrypt sub/bare/sub/deepest into subdir/new-bare/sub/deepest?
+ #|$ do_decrypt ${PREFIX}/sub/bare/sub/deepest.age
+ #|$ do_encrypt subdir/new-bare/sub/deepest.age
+ #|$ scm_add subdir/new-bare/sub/deepest.age
+ #|$ scm_commit Copy sub/bare/ to subdir/new-bare/
+ }
+ When call do_copy_move sub/bare subdir/new-bare
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'reports a file masqueraded as a directory'
+ When run do_copy_move root.age/ subdir
+ The output should be blank
+ The error should equal 'Error: root.age/ is not in the password store.'
+ The status should equal 1
+ End
+
+ It 'reports non-existent source'
+ When run do_copy_move nonexistent subdir
+ The output should be blank
+ The error should equal 'Error: nonexistent is not in the password store.'
+ The status should equal 1
+ End
+
+ It 'cannot merge similarly-named directories'
+ When run do_copy_move sub/bare/sub /
+ The output should be blank
+ The error should equal 'Error: / already contains sub'
+ The status should equal 1
+ End
+
+ It 'cannot move a directory into a file'
+ When run do_copy_move sub/ root.age
+ The output should be blank
+ The error should equal 'Error: root.age is not a directory'
+ The status should equal 1
+ End
+
+ It 'cannot overwrite a directory with a file'
+ When run do_copy_move notes.txt subdir
+ The output should be blank
+ The error should equal 'Error: subdir already contains notes.txt'
+ The status should equal 1
+ End
+
+ It 'checks internal consistency of DECISION'
+ DECISION=garbage
+ When run do_copy_move root subdir
+ The output should be blank
+ The error should equal 'Unexpected DECISION value "garbage"'
+ The status should equal 1
+ End
+
+ # Unreachable branches in do_copy_move_file, defensively implemented
+ It 'defensively avois re-encrypting'
+ DECISION=keep
+ result() {
+ %text
+ #|$ scm_mv root.age non-existent
+ }
+ When run do_copy_move_file root.age non-existent
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'defensively checks internal consistency of DECISION'
+ DECISION=garbage
+ When run do_copy_move_file root.age non-existent
+ The output should be blank
+ The error should equal 'Unexpected DECISION value "garbage"'
+ The status should equal 1
+ End
+ End
+
+ Specify 'do_decrypt'
+ AGE=age
+ age() {
+ mocklog age "$@"
+ %= 'cleartext'
+ }
+
+ IDENTITIES_FILE='/path/to/identity'
+ When call do_decrypt '/path/to/encrypted/file.age'
+ The output should equal 'cleartext'
+ The error should equal \
+ '$ age -d -i /path/to/identity /path/to/encrypted/file.age'
+ End
+
+ Describe 'do_decrypt_gpg'
+ It 'uses gpg when agent is not available'
+ gpg() { mocklog gpg "$@"; }
+ unset GPG_AGENT_INFO
+ unset GPG
+ When call do_decrypt_gpg /path/to/encrypted/file.gpg
+ The error should equal \
+ '$ gpg -d --quiet --yes --compress-algo=none --no-encrypt-to /path/to/encrypted/file.gpg'
+ End
+
+ It 'uses gpg when agent is available'
+ gpg() { mocklog gpg "$@"; }
+ GPG_AGENT_INFO=agent-info
+ unset GPG
+ When call do_decrypt_gpg /path/to/encrypted/file.gpg
+ The error should equal \
+ '$ gpg -d --quiet --yes --compress-algo=none --no-encrypt-to --batch --use-agent /path/to/encrypted/file.gpg'
+ End
+
+ It 'uses gpg2'
+ gpg2() { mocklog gpg2 "$@"; }
+ unset GPG_AGENT_INFO
+ unset GPG
+ When call do_decrypt_gpg /path/to/encrypted/file.gpg
+ The error should equal \
+ '$ gpg2 -d --quiet --yes --compress-algo=none --no-encrypt-to --batch --use-agent /path/to/encrypted/file.gpg'
+ End
+
+ It 'uses user-provided command'
+ user_cmd() { mocklog user_cmd "$@"; }
+ unset GPG_AGENT_INFO
+ GPG=user_cmd
+ When call do_decrypt_gpg /path/to/encrypted/file.gpg
+ The error should equal \
+ '$ user_cmd -d --quiet --yes --compress-algo=none --no-encrypt-to /path/to/encrypted/file.gpg'
+ End
+
+ It 'bails out when command cannot be guessed'
+ unset GPG
+ When run do_decrypt_gpg /path/to/encrypted/file.gpg
+ The error should equal 'GPG does not seem available'
+ The status should equal 1
+ End
+ End
+
+ Describe 'do_deinit'
+ DECISION=default
+ PREFIX="${SHELLSPEC_WORKDIR}/prefix"
+
+ do_reencrypt_dir() { mocklog do_reencrypt_dir "$@"; }
+ scm_begin() { mocklog scm_begin "$@"; }
+ scm_commit() { mocklog scm_commit "$@"; }
+ scm_rm() { mocklog scm_rm "$@"; }
+
+ setup() {
+ @mkdir -p "${PREFIX}/empty" "${PREFIX}/sub"
+ %putsn data > "${PREFIX}/.age-recipients"
+ %putsn data > "${PREFIX}/sub/.age-recipients"
+ }
+
+ cleanup() {
+ @rm -rf "${PREFIX}"
+ }
+
+ BeforeEach setup
+ AfterEach cleanup
+
+ It 'de-initializes the whole store'
+ result() {
+ %text:expand
+ #|$ scm_begin
+ #|$ scm_rm .age-recipients
+ #|$ do_reencrypt_dir ${PREFIX}/
+ #|$ scm_commit Deinitialize store root
+ }
+ When call do_deinit ''
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'de-initializes a subdirectory'
+ result() {
+ %text:expand
+ #|$ scm_begin
+ #|$ scm_rm sub/.age-recipients
+ #|$ do_reencrypt_dir ${PREFIX}/sub
+ #|$ scm_commit Deinitialize sub
+ }
+ When call do_deinit sub
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'can de-initialize without re-encryption'
+ DECISION=keep
+ result() {
+ %text:expand
+ #|$ scm_begin
+ #|$ scm_rm sub/.age-recipients
+ #|$ scm_commit Deinitialize sub
+ }
+ When call do_deinit sub
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'reports impossible de-initialization'
+ When run do_deinit non-existent
+ The output should be blank
+ The error should equal 'No existing recipient to remove at non-existent'
+ The status should equal 1
+ End
+ End
+
+ Describe 'do_delete'
+ DECISION=force
+ PREFIX="${SHELLSPEC_WORKDIR}/prefix"
+
+ dirname() { @dirname "$@"; }
+ scm_begin() { mocklog scm_begin "$@"; }
+ scm_commit() { mocklog scm_commit "$@"; }
+ scm_rm() { mocklog scm_rm "$@"; }
+
+ setup() {
+ @mkdir -p "${PREFIX}/empty" "${PREFIX}/sub"
+ %putsn data > "${PREFIX}/non-encrypted"
+ %putsn data > "${PREFIX}/sub.age"
+ %putsn data > "${PREFIX}/sub/entry.age"
+ }
+
+ cleanup() {
+ @rm -rf "${PREFIX}"
+ }
+
+ BeforeEach setup
+ AfterEach cleanup
+
+ It 'deletes a file after confirmation'
+ DECISION=default
+ yesno() {
+ mocklog yesno "$@"
+ ANSWER=y
+ }
+ result() {
+ %text:expand
+ #|$ yesno Are you sure you would like to delete sub/entry?
+ #|$ scm_begin
+ #|$ scm_rm sub/entry.age
+ #|$ scm_commit Remove sub/entry from store.
+ }
+ When call do_delete sub/entry
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'does not delete a file without confirmation'
+ DECISION=default
+ yesno() {
+ mocklog yesno "$@"
+ ANSWER=n
+ }
+ result() {
+ %text
+ #|$ yesno Are you sure you would like to delete sub/entry?
+ }
+ When call do_delete sub/entry
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'deletes a directory'
+ result() {
+ %text:expand
+ #|$ scm_begin
+ #|$ scm_rm empty/
+ #|$ scm_commit Remove empty/ from store.
+ }
+ When call do_delete empty
+ The output should equal 'Removing empty/'
+ The error should equal "$(result)"
+ End
+
+ It 'deletes a file rather than a directory on ambiguity'
+ result() {
+ %text:expand
+ #|$ scm_begin
+ #|$ scm_rm sub.age
+ #|$ scm_commit Remove sub from store.
+ }
+ When call do_delete sub
+ The output should equal 'Removing sub'
+ The error should equal "$(result)"
+ End
+
+ It 'deletes a directory when explicitly asked'
+ result() {
+ %text:expand
+ #|$ scm_begin
+ #|$ scm_rm sub/
+ #|$ scm_commit Remove sub/ from store.
+ }
+ When call do_delete sub/
+ The output should equal 'Removing sub/'
+ The error should equal "$(result)"
+ End
+
+ It 'does not delete a non-encrypted file'
+ When run do_delete non-encrypted
+ The output should be blank
+ The error should equal \
+ 'Error: non-encrypted is not in the password store.'
+ The status should equal 1
+ End
+
+ It 'does not delete a file presented as a directory'
+ When run do_delete non-encrypted/
+ The output should be blank
+ The error should equal \
+ 'Error: non-encrypted/ is not a directory.'
+ The status should equal 1
+ End
+
+ It 'reports a non-existent directory'
+ When run do_delete non-existent/
+ The output should be blank
+ The error should equal \
+ 'Error: non-existent/ is not in the password store.'
+ The status should equal 1
+ End
+ End
+
+ Describe 'do_edit'
+ SECURE_TMPDIR="${SHELLSPEC_WORKDIR}/secure"
+ PREFIX="${SHELLSPEC_WORKDIR}/prefix"
+
+ diff(){ @diff "$@"; }
+
+ do_decrypt() {
+ mocklog do_decrypt "$@"
+ %= foo
+ }
+ old_do_decrypt() {
+ mocklog do_decrypt "$@"
+ %text
+ #|old line 1
+ #|old line 2
+ }
+
+ do_encrypt() {
+ mocklog do_encrypt "$@"
+ @sed 's/^/> /' >&2
+ }
+
+ mktemp() {
+ mocklog mktemp "$@"
+ %putsn "$2"
+ }
+
+ rm(){ mocklog rm "$@"; @rm "$@"; }
+
+ setup() {
+ @mkdir -p "${PREFIX}"
+ %text > "${PREFIX}/existing.age"
+ #|encrypted data
+ @mkdir -p "${SECURE_TMPDIR}"
+ %text > "${SECURE_TMPDIR}/new-cleartext.txt"
+ #|new line 1
+ #|old line 2
+ #|new line 3
+ }
+
+ scm_add() { mocklog scm_add "$@"; }
+ scm_begin() { mocklog scm_begin "$@"; }
+ scm_commit() { mocklog scm_commit "$@"; }
+
+ cleanup() {
+ @rm -rf "${PREFIX}" "${SECURE_TMPDIR}"
+ }
+
+ BeforeEach setup
+ AfterEach cleanup
+
+ It 'creates a new file'
+ edit(){ @cat "${SECURE_TMPDIR}/new-cleartext.txt" >|"$1"; }
+ result() {
+ %text:expand
+ #|$ mktemp -u ${SECURE_TMPDIR}/XXXXXX
+ #|$ scm_begin
+ #|$ do_encrypt sub/new.age
+ #|> new line 1
+ #|> old line 2
+ #|> new line 3
+ #|$ scm_add sub/new.age
+ #|$ scm_commit Add password for sub/new using edit
+ #|$ rm ${SECURE_TMPDIR}/XXXXXX-sub-new.txt
+ }
+ EDIT_CMD=edit
+ When call do_edit sub/new
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'handles NOT creating a new file'
+ result() {
+ %text:expand
+ #|$ mktemp -u ${SECURE_TMPDIR}/XXXXXX
+ #|$ scm_begin
+ }
+ EDIT_CMD=true
+ When call do_edit new
+ The output should equal 'New password for new not saved.'
+ The error should equal "$(result)"
+ End
+
+ It 'updates a file'
+ edit(){ @cat "${SECURE_TMPDIR}/new-cleartext.txt" >|"$1"; }
+ cat(){ mocklog cat "$@"; @cat "$@"; }
+ result() {
+ %text:expand
+ #|$ mktemp -u ${SECURE_TMPDIR}/XXXXXX
+ #|$ do_decrypt ${PREFIX}/existing.age
+ #|$ cat ${SECURE_TMPDIR}/XXXXXX-existing.txt
+ #|$ scm_begin
+ #|$ do_encrypt existing.age
+ #|> new line 1
+ #|> old line 2
+ #|> new line 3
+ #|$ scm_add existing.age
+ #|$ scm_commit Edit password for existing using edit
+ #|$ rm ${SECURE_TMPDIR}/XXXXXX-existing.txt
+ }
+ EDIT_CMD=edit
+ When call do_edit existing
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'does not re-encrypt an unchanged file'
+ cat(){ mocklog cat "$@"; @cat "$@"; }
+ result() {
+ %text:expand
+ #|$ mktemp -u ${SECURE_TMPDIR}/XXXXXX
+ #|$ do_decrypt ${PREFIX}/existing.age
+ #|$ cat ${SECURE_TMPDIR}/XXXXXX-existing.txt
+ #|$ scm_begin
+ #|$ rm ${SECURE_TMPDIR}/XXXXXX-existing.txt
+ }
+ EDIT_CMD=true
+ When call do_edit existing
+ The output should equal 'Password for existing unchanged.'
+ The error should equal "$(result)"
+ End
+
+ It 'uses VISUAL on non-dumb terminal'
+ edit() { mocklog edit "$@"; }
+ VISUAL=edit
+ TERM=non-dumb
+ EDITOR=false
+ unset EDIT_CMD
+ result() {
+ %text:expand
+ #|$ mktemp -u ${SECURE_TMPDIR}/XXXXXX
+ #|$ scm_begin
+ #|$ edit ${SECURE_TMPDIR}/XXXXXX-subdir-new.txt
+ }
+ When call do_edit subdir/new
+ The output should equal 'New password for subdir/new not saved.'
+ The error should equal "$(result)"
+ End
+
+ It 'uses EDITOR on dumb terminal'
+ edit() { mocklog edit "$@"; }
+ VISUAL=false
+ TERM=dumb
+ EDITOR=edit
+ unset EDIT_CMD
+ result() {
+ %text:expand
+ #|$ mktemp -u ${SECURE_TMPDIR}/XXXXXX
+ #|$ scm_begin
+ #|$ edit ${SECURE_TMPDIR}/XXXXXX-subdir-new.txt
+ }
+ When call do_edit subdir/new
+ The output should equal 'New password for subdir/new not saved.'
+ The error should equal "$(result)"
+ End
+
+ It 'uses EDITOR without terminal'
+ edit() { mocklog edit "$@"; }
+ VISUAL=false
+ EDITOR=edit
+ unset EDIT_CMD
+ unset TERM
+ result() {
+ %text:expand
+ #|$ mktemp -u ${SECURE_TMPDIR}/XXXXXX
+ #|$ scm_begin
+ #|$ edit ${SECURE_TMPDIR}/XXXXXX-subdir-new.txt
+ }
+ When call do_edit subdir/new
+ The output should equal 'New password for subdir/new not saved.'
+ The error should equal "$(result)"
+ End
+
+ It 'uses EDITOR on non-dumb terminal without VISUAL'
+ edit() { mocklog edit "$@"; }
+ TERM=non-dumb
+ EDITOR=edit
+ unset VISUAL
+ unset EDIT_CMD
+ result() {
+ %text:expand
+ #|$ mktemp -u ${SECURE_TMPDIR}/XXXXXX
+ #|$ scm_begin
+ #|$ edit ${SECURE_TMPDIR}/XXXXXX-subdir-new.txt
+ }
+ When call do_edit subdir/new
+ The output should equal 'New password for subdir/new not saved.'
+ The error should equal "$(result)"
+ End
+
+ It 'falls back on vi without EDITOR nor VISUAL'
+ vi() { mocklog vi "$@"; }
+ unset EDITOR
+ unset VISUAL
+ unset EDIT_CMD
+ result() {
+ %text:expand
+ #|$ mktemp -u ${SECURE_TMPDIR}/XXXXXX
+ #|$ scm_begin
+ #|$ vi ${SECURE_TMPDIR}/XXXXXX-subdir-new.txt
+ }
+ When call do_edit subdir/new
+ The output should equal 'New password for subdir/new not saved.'
+ The error should equal "$(result)"
+ End
+ End
+
+ Describe 'do_encrypt'
+ unset PASHAGE_RECIPIENTS_FILE
+ unset PASSAGE_RECIPIENTS_FILE
+ unset PASHAGE_RECIPIENTS
+ unset PASSAGE_RECIPIENTS
+ AGE=age
+ PREFIX=/prefix
+ age() { mocklog age "$@"; }
+
+ setup() {
+ %= data >"${SHELLSPEC_WORKDIR}/existing-file"
+ }
+ BeforeAll 'setup'
+
+ It 'falls back on identity when there is no recipient'
+ OVERWRITE=yes
+ IDENTITIES_FILE='/path/to/identity'
+ set_LOCAL_RECIPIENT_FILE() {
+ LOCAL_RECIPIENT_FILE=''
+ }
+ When run do_encrypt 'encrypted/file.age'
+ The error should equal \
+ '$ age -e -i /path/to/identity -o /prefix/encrypted/file.age'
+ End
+
+ It 'overwrites existing file only once'
+ OVERWRITE=once
+ PREFIX="${SHELLSPEC_WORKDIR}"
+ set_LOCAL_RECIPIENT_FILE() {
+ LOCAL_RECIPIENT_FILE='/path/to/recipients'
+ }
+ preserve() { %preserve OVERWRITE; }
+ AfterRun 'preserve'
+ When run do_encrypt 'existing-file'
+ The error should equal \
+ "$ age -e -R /path/to/recipients -o ${PREFIX}/existing-file"
+ The variable OVERWRITE should equal no
+ End
+
+ It 'overwrites existing file when requested'
+ OVERWRITE=yes
+ PREFIX="${SHELLSPEC_WORKDIR}"
+ set_LOCAL_RECIPIENT_FILE() {
+ LOCAL_RECIPIENT_FILE='/path/to/recipients'
+ }
+ preserve() { %preserve OVERWRITE; }
+ AfterRun 'preserve'
+ When run do_encrypt 'existing-file'
+ The error should equal \
+ "$ age -e -R /path/to/recipients -o ${PREFIX}/existing-file"
+ The variable OVERWRITE should equal yes
+ End
+
+ It 'refuses to overwrite an existing file'
+ OVERWRITE=no
+ PREFIX="${SHELLSPEC_WORKDIR}"
+ set_LOCAL_RECIPIENT_FILE() {
+ LOCAL_RECIPIENT_FILE='/path/to/recipients'
+ }
+ When run do_encrypt 'existing-file'
+ The error should equal 'Refusing to overwite existing-file'
+ The status should equal 1
+ End
+
+ It 'uses all recipient sources simultaneously'
+ PASHAGE_RECIPIENTS_FILE='/path/to/recipients/1'
+ PASSAGE_RECIPIENTS_FILE='/path/to/recipients/2'
+ PASHAGE_RECIPIENTS='inline-recipient-1 inline-recipient-2'
+ PASSAGE_RECIPIENTS='inline-recipient-3 inline-recipient-4'
+ set_LOCAL_RECIPIENT_FILE() {
+ LOCAL_RECIPIENT_FILE='/path/to/recipients/3'
+ }
+ OVERWRITE=yes
+
+ When call do_encrypt 'encrypted/file.age'
+ The error should equal \
+ '$ age -e -R /path/to/recipients/1 -R /path/to/recipients/2 -r inline-recipient-1 -r inline-recipient-2 -r inline-recipient-3 -r inline-recipient-4 -R /path/to/recipients/3 -o /prefix/encrypted/file.age'
+ End
+ End
+
+ Describe 'do_generate'
+ PREFIX="${SHELLSPEC_WORKDIR}/prefix"
+
+ dd() { %- 0123456789 ; }
+ dirname() { @dirname "$@"; }
+ tr() { @tr "$@"; }
+
+ do_encrypt() {
+ mocklog do_encrypt "$@"
+ @sed 's/^/> /' >&2
+ }
+
+ do_show() {
+ mocklog do_show "$@"
+ @sed 's/^/> /' >&2
+ }
+
+ mkdir() { mocklog mkdir "$@"; }
+ scm_add() { mocklog scm_add "$@"; }
+ scm_begin() { mocklog scm_begin "$@"; }
+ scm_commit() { mocklog scm_commit "$@"; }
+
+ setup() {
+ @mkdir -p "${PREFIX}/suspicious.age"
+ %putsn data >"${PREFIX}/existing.age"
+ }
+
+ cleanup() {
+ @rm -rf "${PREFIX}"
+ }
+
+ BeforeEach setup
+ AfterEach cleanup
+
+ It 'detects short reads'
+ When run do_generate suspicious 12 '[:alnum:]'
+ The output should be blank
+ The error should equal \
+ 'Error while generating password: 10/12 bytes read'
+ The status should equal 1
+ End
+
+ It 'aborts when a directory is in the way'
+ result(){
+ %text:expand
+ #|$ scm_begin
+ #|$ mkdir -p ${PREFIX}
+ #|Cannot replace directory suspicious.age
+ }
+ When run do_generate suspicious 10 '[:alnum:]'
+ The output should be blank
+ The error should equal "$(result)"
+ The status should equal 1
+ End
+
+ It 'generates a new file'
+ result(){
+ %text:expand
+ #|$ scm_begin
+ #|$ mkdir -p ${PREFIX}/sub
+ #|$ do_encrypt sub/new.age
+ #|> 0123456789
+ #|$ scm_add ${PREFIX}/sub/new.age
+ #|$ scm_commit Add generated password for sub/new
+ #|$ do_show sub/new
+ #|> 0123456789
+ }
+ When call do_generate sub/new 10 '[alnum:]'
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'overwrites an existing file when forced'
+ OVERWRITE=no
+ DECISION=force
+ result(){
+ %text:expand
+ #|$ scm_begin
+ #|$ mkdir -p ${PREFIX}
+ #|$ do_encrypt existing.age
+ #|> 0123456789
+ #|$ scm_add ${PREFIX}/existing.age
+ #|$ scm_commit Add generated password for existing
+ #|$ do_show existing
+ #|> 0123456789
+ }
+ When call do_generate existing 10 '[alnum:]'
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'overwrites an existing file after confirmation'
+ OVERWRITE=no
+ DECISION=default
+ yesno() {
+ mocklog yesno "$@";
+ ANSWER=y
+ }
+ result(){
+ %text:expand
+ #|$ scm_begin
+ #|$ mkdir -p ${PREFIX}
+ #|$ yesno An entry already exists for existing. Overwrite it?
+ #|$ do_encrypt existing.age
+ #|> 0123456789
+ #|$ scm_add ${PREFIX}/existing.age
+ #|$ scm_commit Add generated password for existing
+ #|$ do_show existing
+ #|> 0123456789
+ }
+ When call do_generate existing 10 '[alnum:]'
+ The output should be blank
+ The error should equal "$(result)"
+ The variable OVERWRITE should equal 'once'
+ End
+
+ It 'does not overwrite an existing file without confirmation'
+ OVERWRITE=no
+ DECISION=default
+ yesno() {
+ mocklog yesno "$@";
+ ANSWER=n
+ }
+ result(){
+ %text:expand
+ #|$ scm_begin
+ #|$ mkdir -p ${PREFIX}
+ #|$ yesno An entry already exists for existing. Overwrite it?
+ }
+ When call do_generate existing 10 '[alnum:]'
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'updates the first line of an existing file'
+ OVERWRITE=yes
+ mktemp() { %= "$1"; }
+ tail() { @tail "$@"; }
+ do_decrypt() {
+ mocklog do_decrypt "$@"
+ %text
+ #|old password
+ #|line 2
+ #|line 3
+ }
+ mv() { mocklog mv "$@"; }
+ result(){
+ %text:expand
+ #|$ scm_begin
+ #|$ mkdir -p ${PREFIX}
+ #|$ do_decrypt ${PREFIX}/existing.age
+ #|$ do_encrypt existing-XXXXXXXXX.age
+ #|> 0123456789
+ #|> line 2
+ #|> line 3
+ #|$ mv ${PREFIX}/existing-XXXXXXXXX.age ${PREFIX}/existing.age
+ #|$ scm_add ${PREFIX}/existing.age
+ #|$ scm_commit Replace generated password for existing
+ #|$ do_show existing
+ #|> 0123456789
+ }
+ When call do_generate existing 10 '[alnum:]'
+ The output should equal 'Decrypting previous secret for existing'
+ The error should equal "$(result)"
+ End
+ End
+
+ Describe 'do_grep'
+ PREFIX="${SHELLSPEC_WORKDIR}/prefix"
+ BLUE_TEXT='(B)'
+ BOLD_TEXT='(G)'
+ NORMAL_TEXT='(N)'
+
+ do_decrypt() { @cat "$1"; }
+ grep() { @grep "$1"; }
+
+ setup() {
+ @mkdir -p "${PREFIX}/subdir"
+ %putsn data >"${PREFIX}/non-match.age"
+ %text >"${PREFIX}/subdir/match.age"
+ #|non-match
+ #|other
+ #|suffix
+ }
+
+ cleanup() {
+ @rm -rf "${PREFIX}"
+ }
+
+ BeforeEach setup
+ AfterEach cleanup
+
+ It 'outputs matching files'
+ result(){
+ %text
+ #|(B)subdir/(G)match(N):
+ #|other
+ }
+ start_do_grep(){
+ ( cd "${PREFIX}" && do_grep '' "$@" )
+ }
+ When call start_do_grep ot
+ The output should equal "$(result)"
+ End
+ End
+
+ Describe 'do_init'
+ PREFIX="${SHELLSPEC_WORKDIR}/prefix"
+ DECISION=default
+ OVERWRITE=no
+
+ do_reencrypt_dir() { mocklog do_reencrypt_dir "$@"; }
+ mkdir() { mocklog mkdir "$@"; @mkdir "$@"; }
+ scm_add() { mocklog scm_add "$@"; }
+ scm_begin() { mocklog scm_begin "$@"; }
+ scm_commit() { mocklog scm_commit "$@"; }
+
+ cleanup() {
+ @rm -rf "${PREFIX}"
+ }
+
+ AfterEach cleanup
+
+ It 'initializes the store'
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}
+ #|$ scm_begin
+ #|$ scm_add .age-recipients
+ #|$ do_reencrypt_dir ${PREFIX}
+ #|$ scm_commit Set age recipients at store root
+ }
+ When call do_init '' identity
+ The output should equal 'Password store recipients set at store root'
+ The error should equal "$(result)"
+ The file "${PREFIX}/.age-recipients" should be exist
+ The contents of the file "${PREFIX}/.age-recipients" should equal \
+ 'identity'
+ End
+
+ It 'initializes a subdirectory'
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}/sub
+ #|$ scm_begin
+ #|$ scm_add sub/.age-recipients
+ #|$ do_reencrypt_dir ${PREFIX}/sub
+ #|$ scm_commit Set age recipients at sub
+ }
+ two_id() {
+ %text
+ #|identity 1
+ #|identity 2
+ }
+ When call do_init sub 'identity 1' 'identity 2'
+ The output should equal 'Password store recipients set at sub'
+ The error should equal "$(result)"
+ The file "${PREFIX}/sub/.age-recipients" should be exist
+ The contents of the file "${PREFIX}/sub/.age-recipients" should equal \
+ "$(two_id)"
+ End
+
+ It 'can initialize without re-encryption'
+ DECISION=keep
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}
+ #|$ scm_begin
+ #|$ scm_add .age-recipients
+ #|$ scm_commit Set age recipients at store root
+ }
+ When call do_init '' identity
+ The output should equal 'Password store recipients set at store root'
+ The error should equal "$(result)"
+ The file "${PREFIX}/.age-recipients" should be exist
+ The contents of the file "${PREFIX}/.age-recipients" should equal \
+ 'identity'
+ End
+ End
+
+ Describe 'do_insert'
+ PREFIX="${SHELLSPEC_WORKDIR}/prefix"
+
+ do_encrypt() {
+ mocklog do_encrypt "$@"
+ @sed 's/^/> /' >&2
+ }
+
+ dirname() { @dirname "$@"; }
+ head() { @head "$@"; }
+
+ mkdir() { mocklog mkdir "$@"; }
+ scm_add() { mocklog scm_add "$@"; }
+ scm_begin() { mocklog scm_begin "$@"; }
+ scm_commit() { mocklog scm_commit "$@"; }
+
+ setup() {
+ @mkdir -p "${PREFIX}"
+ %putsn data >"${PREFIX}/existing.age"
+ }
+
+ cleanup() {
+ @rm -rf "${PREFIX}"
+ }
+
+ BeforeEach setup
+ AfterEach cleanup
+
+ It 'inserts a single line from standard input'
+ ECHO=yes
+ MULTILINE=no
+ OVERWRITE=yes
+ result() {
+ %text:expand
+ #|$ scm_begin
+ #|$ mkdir -p ${PREFIX}/subdir
+ #|$ do_encrypt subdir/new.age
+ #|> line 1
+ #|$ scm_add subdir/new.age
+ #|$ scm_commit Add given password for subdir/new to store.
+ }
+ Data
+ #|line 1
+ #|line 2
+ #|line 3
+ End
+
+ When call do_insert 'subdir/new'
+ The output should equal 'Enter password for subdir/new: '
+ The error should equal "$(result)"
+ End
+
+ It 'inserts the whole standard input'
+ MULTILINE=yes
+ OVERWRITE=yes
+ result() {
+ %text:expand
+ #|$ scm_begin
+ #|$ mkdir -p ${PREFIX}/subdir
+ #|$ do_encrypt subdir/new.age
+ #|> line 1
+ #|> line 2
+ #|> line 3
+ #|$ scm_add subdir/new.age
+ #|$ scm_commit Add given password for subdir/new to store.
+ }
+ Data
+ #|line 1
+ #|line 2
+ #|line 3
+ End
+
+ When call do_insert 'subdir/new'
+ The output should equal \
+ 'Enter contents of subdir/new and press Ctrl+D when finished:'
+ The error should equal "$(result)"
+ End
+
+ It 'checks password confirmation before inserting it'
+ ECHO=no
+ MULTILINE=no
+ OVERWRITE=yes
+ stty() { true; }
+ o_result() {
+ %text | @sed 's/\$$//'
+ #|Enter password for subdir/new: $
+ #|Retype password for subdir/new: $
+ #|Passwords don't match$
+ #|Enter password for subdir/new: $
+ #|Retype password for subdir/new: $
+ }
+ e_result() {
+ %text:expand
+ #|$ scm_begin
+ #|$ mkdir -p ${PREFIX}/subdir
+ #|$ do_encrypt subdir/new.age
+ #|> line 3
+ #|$ scm_add subdir/new.age
+ #|$ scm_commit Add given password for subdir/new to store.
+ }
+ Data
+ #|line 1
+ #|line 2
+ #|line 3
+ #|line 3
+ #|line 5
+ End
+
+ When call do_insert 'subdir/new'
+ The output should equal "$(o_result)"
+ The error should equal "$(e_result)"
+ End
+
+ It 'asks confirmation before overwriting'
+ MULTILINE=yes
+ OVERWRITE=no
+ yesno() {
+ mocklog yesno "$@"
+ ANSWER=y
+ }
+ result() {
+ %text:expand
+ #|$ yesno An entry already exists for existing. Overwrite it?
+ #|$ scm_begin
+ #|$ mkdir -p ${PREFIX}
+ #|$ do_encrypt existing.age
+ #|> password
+ #|$ scm_add existing.age
+ #|$ scm_commit Add given password for existing to store.
+ }
+ Data 'password'
+
+ When call do_insert 'existing'
+ The output should equal \
+ 'Enter contents of existing and press Ctrl+D when finished:'
+ The error should equal "$(result)"
+ The variable OVERWRITE should equal once
+ End
+
+ It 'does not overwrite without confirmation'
+ MULTILINE=yes
+ OVERWRITE=no
+ yesno() {
+ mocklog yesno "$@"
+ ANSWER=n
+ }
+ result() {
+ %text:expand
+ #|$ yesno An entry already exists for existing. Overwrite it?
+ }
+ Data 'password'
+
+ When call do_insert 'existing'
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'does not ask confirmation before overwriting when forced'
+ MULTILINE=yes
+ OVERWRITE=yes
+ yesno() {
+ mocklog yesno "$@"
+ ANSWER=y
+ }
+ result() {
+ %text:expand
+ #|$ scm_begin
+ #|$ mkdir -p ${PREFIX}
+ #|$ do_encrypt existing.age
+ #|> password
+ #|$ scm_add existing.age
+ #|$ scm_commit Add given password for existing to store.
+ }
+ Data 'password'
+
+ When call do_insert 'existing'
+ The output should equal \
+ 'Enter contents of existing and press Ctrl+D when finished:'
+ The error should equal "$(result)"
+ End
+ End
+
+ Describe 'do_list_or_show'
+ PREFIX="${SHELLSPEC_WORKDIR}/prefix"
+
+ do_decrypt() {
+ mocklog do_decrypt "$@"
+ %putsn data
+ }
+
+ do_decrypt_gpg() {
+ mocklog do_decrypt_gpg "$@"
+ %putsn data
+ }
+
+ do_show() {
+ @cat >/dev/null
+ mocklog do_show "$@"
+ }
+
+ do_tree() { mocklog do_tree "$@"; }
+
+ setup() {
+ @mkdir -p "${PREFIX}/subdir/subsub" "${PREFIX}/empty" "${PREFIX}/other"
+ %putsn data >"${PREFIX}/root.age"
+ %putsn data >"${PREFIX}/subdir/hidden"
+ %putsn data >"${PREFIX}/subdir/subsub/old.gpg"
+ %putsn data >"${PREFIX}/other/lower.age"
+ }
+
+ cleanup() {
+ @rm -rf "${PREFIX}"
+ }
+
+ BeforeEach setup
+ AfterEach cleanup
+
+ It 'lists the whole store'
+ When call do_list_or_show ''
+ The output should be blank
+ The error should equal "$ do_tree ${PREFIX} Password Store"
+ End
+
+ It 'shows a decrypted age file'
+ result() {
+ %text:expand
+ #|$ do_decrypt ${PREFIX}/other/lower.age
+ #|$ do_show other/lower
+ }
+ When call do_list_or_show 'other/lower'
+ The error should equal "$(result)"
+ End
+
+ It 'shows a decrypted gpg file'
+ result() {
+ %text:expand
+ #|$ do_decrypt_gpg ${PREFIX}/subdir/subsub/old.gpg
+ #|$ do_show subdir/subsub/old
+ }
+ When call do_list_or_show 'subdir/subsub/old'
+ The error should equal "$(result)"
+ End
+
+ It 'lists a subdirectory'
+ When call do_list_or_show 'subdir'
+ The output should be blank
+ The error should equal "$ do_tree ${PREFIX}/subdir subdir"
+ End
+
+ It 'does not show a non-encrypted file'
+ When run do_list_or_show 'subdir/hidden'
+ The output should be blank
+ The error should equal \
+ 'Error: subdir/hidden is not in the password store.'
+ The status should equal 1
+ End
+ End
+
+ Describe 'do_reencrypt'
+ PREFIX="${SHELLSPEC_WORKDIR}/prefix"
+ DECISION=default
+
+ do_decrypt() {
+ mocklog do_decrypt "$@"
+ %putsn 'secret data'
+ }
+
+ do_encrypt() {
+ @cat >/dev/null
+ mocklog do_encrypt "$@"
+ }
+
+ mktemp() { %putsn "$1"; }
+ mv() { mocklog mv "$@"; }
+ scm_add() { mocklog scm_add "$@"; }
+
+ setup() {
+ @mkdir -p "${PREFIX}/subdir/subsub"
+ %putsn data >"${PREFIX}/root.age"
+ %putsn data >"${PREFIX}/subdir/middle.age"
+ %putsn data >"${PREFIX}/subdir/subsub/deep.age"
+ }
+
+ cleanup() {
+ @rm -rf "${PREFIX}"
+ }
+
+ BeforeEach setup
+ AfterEach cleanup
+
+ It 're-encrypts a single file'
+ result() {
+ %text:expand
+ #|$ do_decrypt ${PREFIX}/subdir/subsub/deep.age
+ #|$ do_encrypt subdir/subsub/deep-XXXXXXXXX.age
+ #|$ mv -f ${PREFIX}/subdir/subsub/deep-XXXXXXXXX.age ${PREFIX}/subdir/subsub/deep.age
+ #|$ scm_add subdir/subsub/deep.age
+ }
+ When call do_reencrypt subdir/subsub/deep
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'recursively re-encrypts a directory'
+ result() {
+ %text:expand
+ #|$ do_decrypt ${PREFIX}/subdir/middle.age
+ #|$ do_encrypt subdir/middle-XXXXXXXXX.age
+ #|$ mv -f ${PREFIX}/subdir/middle-XXXXXXXXX.age ${PREFIX}/subdir/middle.age
+ #|$ scm_add subdir/middle.age
+ #|$ do_decrypt ${PREFIX}/subdir/subsub/deep.age
+ #|$ do_encrypt subdir/subsub/deep-XXXXXXXXX.age
+ #|$ mv -f ${PREFIX}/subdir/subsub/deep-XXXXXXXXX.age ${PREFIX}/subdir/subsub/deep.age
+ #|$ scm_add subdir/subsub/deep.age
+ }
+ When call do_reencrypt subdir/
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'asks for confirmation before each file'
+ DECISION=interactive
+ YESNO_NEXT=n
+ yesno() {
+ mocklog yesno "$@"
+ ANSWER="${YESNO_NEXT}"
+ YESNO_NEXT=y
+ }
+ result() {
+ %text:expand
+ #|$ yesno Re-encrypt subdir/middle?
+ #|$ yesno Re-encrypt subdir/subsub/deep?
+ #|$ do_decrypt ${PREFIX}/subdir/subsub/deep.age
+ #|$ do_encrypt subdir/subsub/deep-XXXXXXXXX.age
+ #|$ mv -f ${PREFIX}/subdir/subsub/deep-XXXXXXXXX.age ${PREFIX}/subdir/subsub/deep.age
+ #|$ scm_add subdir/subsub/deep.age
+ }
+ When call do_reencrypt subdir
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'reports a non-existent directory'
+ When run do_reencrypt non-existent/
+ The output should be blank
+ The error should equal \
+ 'Error: non-existent/ is not in the password store.'
+ The status should equal 1
+ End
+
+ It 'reports a non-existent file'
+ When run do_reencrypt non-existent
+ The output should be blank
+ The error should equal \
+ 'Error: non-existent is not in the password store.'
+ The status should equal 1
+ End
+ End
+
+ Describe 'do_show'
+ cleartext(){
+ %text
+ #|password line
+ #|extra line 1
+ #|extra line 2
+ }
+
+ It 'shows a secret on standard output'
+ cat() { @cat; }
+ Data cleartext
+ SHOW=text
+ When call do_show
+ The output should equal "$(cleartext)"
+ End
+
+ It 'pastes a secret into the clipboard'
+ head() { @head "$@"; }
+ tail() { @tail "$@"; }
+ tr() { @tr "$@"; }
+ platform_clip() { @cat >&2; }
+ Data cleartext
+ SELECTED_LINE=1
+ SHOW=clip
+ When call do_show title
+ The output should be blank
+ The error should equal 'password line'
+ End
+
+ It 'shows a secret as a QR-code'
+ head() { @head "$@"; }
+ tail() { @tail "$@"; }
+ tr() { @tr "$@"; }
+ platform_qrcode() { @cat >&2; }
+ Data cleartext
+ SELECTED_LINE=1
+ SHOW=qrcode
+ When call do_show title
+ The output should be blank
+ The error should equal 'password line'
+ End
+
+ It 'aborts on unexpected SHOW'
+ SHOW=bad
+ Data cleartext
+ When run do_show title
+ The output should be blank
+ The error should equal 'Unexpected SHOW value "bad"'
+ The status should equal 1
+ End
+ End
+
+ Describe 'do_tree'
+ PREFIX="${SHELLSPEC_WORKDIR}/prefix"
+ BLUE_TEXT='(B)'
+ NORMAL_TEXT='(N)'
+ RED_TEXT='(R)'
+ TREE_T='T_'
+ TREE_L='L_'
+ TREE_I='I_'
+ TREE__='__'
+
+ grep() { @grep "$@"; }
+
+ setup() {
+ @mkdir -p "${PREFIX}/subdir/subsub" "${PREFIX}/empty" "${PREFIX}/other"
+ %putsn data >"${PREFIX}/root.age"
+ %putsn data >"${PREFIX}/subdir/hidden"
+ %putsn data >"${PREFIX}/subdir/subsub/old.gpg"
+ %putsn data >"${PREFIX}/other/lower.age"
+ }
+
+ cleanup() {
+ @rm -rf "${PREFIX}"
+ }
+
+ BeforeEach setup
+ AfterEach cleanup
+
+ It 'displays everything without a pattern'
+ result() {
+ %text
+ #|Title
+ #|T_(B)empty(N)
+ #|T_(B)other(N)
+ #|I_L_lower
+ #|T_root
+ #|L_(B)subdir(N)
+ #|__L_(B)subsub(N)
+ #|____L_(R)old(N)
+ }
+ When call do_tree "${PREFIX}" 'Title'
+ The output should equal "$(result)"
+ End
+
+ It 'displays matching files and their non-matching parents'
+ result() {
+ %text
+ #|Title
+ #|T_(B)other(N)
+ #|I_L_lower
+ #|L_(B)subdir(N)
+ #|__L_(B)subsub(N)
+ #|____L_(R)old(N)
+ }
+ When call do_tree "${PREFIX}" 'Title' -i L
+ The output should equal "$(result)"
+ End
+
+ It 'does not display matching directories'
+ result() {
+ %text
+ #|Title
+ #|L_root
+ }
+ When call do_tree "${PREFIX}" 'Title' t
+ The output should equal "$(result)"
+ End
+
+ It 'might not display anything'
+ When call do_tree "${PREFIX}" 'Title' z
+ The output should equal ''
+ End
+ End
+End
diff --git a/spec/command_spec.sh b/spec/command_spec.sh
@@ -0,0 +1,85 @@
+Describe 'Command Functions'
+ Include src/pashage.sh
+ Set 'errexit:on' 'nounset:on' 'pipefail:on'
+
+ age() {
+ case "$1" in
+ -e)
+ shift
+ MOCK_AGE_OUTPUT="$(@mktemp "${PREFIX}/mock-age-encrypt.XXXXXXXXX")"
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ -R|-i)
+ @sed 's/^/ageRecipient:/' "$2" >>"${MOCK_AGE_OUTPUT}"
+ shift 2 ;;
+ -r)
+ printf 'ageRecipient:%s\n' "$2" >>"${MOCK_AGE_OUTPUT}"
+ shift 2 ;;
+ -o)
+ [ $# -eq 2 ] || printf 'Unexpected age -e [...] %s\n' "$*" >&2
+ @sed 's/^/age:/' >>"${MOCK_AGE_OUTPUT}"
+ @mv -f "${MOCK_AGE_OUTPUT}" "$2"
+ shift 2 ;;
+ *)
+ printf 'Unexpected age -e [...] %s\n' "$*" >&2
+ exit 1
+ ;;
+ esac
+ done
+ ;;
+ -d)
+ [ "$2" = '-i' ] || echo "Unexpected age -d \$2: \"$2\"" >&2
+ @grep -v '^age' "$4" >&2 && echo "Bad encrypted file \"$4\"" >&2
+ @grep -qFx "ageRecipient:$(@cat "$3")" "$4" \
+ || echo "Bad identity \"$3\": $(@cat "$3")" >&2
+ @sed -n 's/^age://p' "$4"
+ ;;
+ *)
+ echo "Unexpected age \$1: \"$1\"" >&2
+ ;;
+ esac
+ }
+
+ setup() {
+ %putsn 'moi' >"${SHELLSPEC_WORKDIR}/.age-recipients"
+ %putsn 'moi' >"${SHELLSPEC_WORKDIR}/identity"
+ %text >"${SHELLSPEC_WORKDIR}/my-file.age"
+ #|ageRecipient:moi
+ #|age:foo
+ #|age:bar
+ }
+
+ cleartext() {
+ %text
+ #|foo
+ #|bar
+ }
+
+ cleanup() {
+ @rm -f "${SHELLSPEC_WORKDIR}/.age-recipients"
+ @rm -f "${SHELLSPEC_WORKDIR}/identity"
+ @rm -f "${SHELLSPEC_WORKDIR}/my-file.age"
+ @rm -f "${SHELLSPEC_WORKDIR}/my-file.txt"
+ @rm -f "${SHELLSPEC_WORKDIR}/result.age"
+ }
+
+ BeforeEach 'setup'
+ AfterEach 'cleanup'
+
+ Specify 'do_decrypt'
+ AGE=age
+ IDENTITIES_FILE="${SHELLSPEC_WORKDIR}/identity"
+ When call do_decrypt "${SHELLSPEC_WORKDIR}/my-file.age"
+ The output should equal "$(cleartext)"
+ End
+
+ Specify 'do_encrypt'
+ AGE=age
+ PREFIX="${SHELLSPEC_WORKDIR}"
+ Data cleartext
+ When call do_encrypt "result.age"
+ The file "${SHELLSPEC_WORKDIR}/result.age" should be exist
+ The contents of file "${SHELLSPEC_WORKDIR}/result.age" should equal \
+ "$(@cat "${SHELLSPEC_WORKDIR}/my-file.age")"
+ End
+End
diff --git a/spec/internal_spec.sh b/spec/internal_spec.sh
@@ -0,0 +1,281 @@
+# pashage - age-backed POSIX password manager
+# Copyright (C) 2024 Natasha Kerensikova
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+# This test file covers all internal helper functions,
+# except for the interactive path in `yesno`.
+# These functions are fundemantal so there is no need for mocking.
+
+Describe 'Internal Helper Functions'
+ Include src/pashage.sh
+ Set 'errexit:on' 'nounset:on' 'pipefail:on'
+
+ Describe 'check_sneaky_path'
+ It 'accept an empty path'
+ When run check_sneaky_path ''
+ The error should be blank
+ The output should be blank
+ End
+
+ It 'accepts a file name'
+ When run check_sneaky_path 'a'
+ The error should be blank
+ The output should be blank
+ End
+
+ It 'accepts an absolute path'
+ When run check_sneaky_path '/a/b/c'
+ The error should be blank
+ The output should be blank
+ End
+
+ It 'accepts a relative path'
+ When run check_sneaky_path 'a/b/c/'
+ The error should be blank
+ The output should be blank
+ End
+
+ It 'aborts when .. is a path component'
+ When run check_sneaky_path 'a/../b'
+ The error should equal 'Encountered path considered sneaky: "a/../b"'
+ The output should be blank
+ The status should equal 1
+ End
+
+ It 'aborts when .. is a path prefix'
+ When run check_sneaky_path '../a/b'
+ The error should equal 'Encountered path considered sneaky: "../a/b"'
+ The output should be blank
+ The status should equal 1
+ End
+
+ It 'aborts when .. is a path suffix'
+ When run check_sneaky_path '/a/..'
+ The error should equal 'Encountered path considered sneaky: "/a/.."'
+ The output should be blank
+ The status should equal 1
+ End
+
+ It 'aborts when .. is the whole path'
+ When run check_sneaky_path '..'
+ The error should equal 'Encountered path considered sneaky: ".."'
+ The output should be blank
+ The status should equal 1
+ End
+ End
+
+ Describe 'check_sneaky_paths'
+ It 'aborts when all paths are bad'
+ When run check_sneaky_paths ../a b/../c
+ The error should equal 'Encountered path considered sneaky: "../a"'
+ The output should be blank
+ The status should equal 1
+ End
+
+ It 'accepts several good paths'
+ When run check_sneaky_paths a b/c /d/e/f
+ The error should be blank
+ The output should be blank
+ End
+
+ It 'accepts an empty argument list'
+ When run check_sneaky_paths
+ The error should be blank
+ The output should be blank
+ End
+
+ It 'aborts when a single path is bad'
+ When run check_sneaky_paths a b/../c /d/e/f
+ The error should equal 'Encountered path considered sneaky: "b/../c"'
+ The output should be blank
+ The status should equal 1
+ End
+ End
+
+ Describe 'checked'
+ echo_ret() { printf '%s\n' "$1"; return $2; }
+
+ It 'aborts on command failure and reports it'
+ When run checked echo_ret 'it runs' 42
+ The output should equal 'it runs'
+ The error should equal 'Fatal(42): echo_ret it runs 42'
+ The status should equal 42
+ End
+
+ It 'continues silently when the command is successful'
+ When run checked echo_ret 'it runs' 0
+ The output should equal 'it runs'
+ The error should be blank
+ End
+ End
+
+ Specify 'die'
+ When run die Message Word
+ The error should equal 'Message Word'
+ The output should be blank
+ The status should equal 1
+ End
+
+ Describe 'glob_exists'
+ It 'answers y when the glob matches something'
+ When call glob_exists /*
+ The variable ANSWER should equal y
+ End
+
+ It 'answers n when the glob does not match anything'
+ When call glob_exists non-existent/*
+ The variable ANSWER should equal n
+ End
+ End
+
+ Describe 'set_LOCAL_RECIPIENT_FILE'
+ PREFIX="${SHELLSPEC_WORKDIR}/prefix/store"
+ setup() {
+ @mkdir -p "${PREFIX}/subdir/subsub" "${PREFIX}/special"
+ echo "Outside recipient" >"${PREFIX}/../.age-recipients"
+ echo "Toplevel recipient" >"${PREFIX}/.age-recipients"
+ echo "Subdir recipient" >"${PREFIX}/subdir/.age-recipients"
+ }
+ cleanup() { @rm -rf "${PREFIX}"; }
+
+ BeforeEach 'setup'
+ AfterEach 'cleanup'
+
+ It 'returns root from root'
+ When call set_LOCAL_RECIPIENT_FILE foo
+ The variable LOCAL_RECIPIENT_FILE should equal \
+ "${PREFIX}/.age-recipients"
+ End
+
+ It 'returns root from unmarked subdirectory'
+ When call set_LOCAL_RECIPIENT_FILE special/foo
+ The variable LOCAL_RECIPIENT_FILE should equal \
+ "${PREFIX}/.age-recipients"
+ End
+
+ It 'returns subdirectory from itself'
+ When call set_LOCAL_RECIPIENT_FILE subdir/foo
+ The variable LOCAL_RECIPIENT_FILE should equal \
+ "${PREFIX}/subdir/.age-recipients"
+ End
+
+ It 'returns subdirectory from sub-subdirectory'
+ When call set_LOCAL_RECIPIENT_FILE subdir/subsub/foo
+ The variable LOCAL_RECIPIENT_FILE should equal \
+ "${PREFIX}/subdir/.age-recipients"
+ End
+
+ setup() {
+ @mkdir -p "${PREFIX}/subdir/subsub" "${PREFIX}/special"
+ echo "Outside recipient" >"${PREFIX}/../.age-recipients"
+ echo "Subdir recipient" >"${PREFIX}/subdir/.age-recipients"
+ }
+
+ It 'returns nothing from empty root'
+ When call set_LOCAL_RECIPIENT_FILE foo
+ The variable LOCAL_RECIPIENT_FILE should equal ''
+ End
+
+ It 'returns nothing from unmarked subdirectory below empty root'
+ When call set_LOCAL_RECIPIENT_FILE special/foo
+ The variable LOCAL_RECIPIENT_FILE should equal ''
+ End
+
+ It 'returns subdirectory from itself even under empty root'
+ When call set_LOCAL_RECIPIENT_FILE subdir/foo
+ The variable LOCAL_RECIPIENT_FILE should equal \
+ "${PREFIX}/subdir/.age-recipients"
+ End
+
+ It 'returns subdirectory from sub-subdirectory even under empty root'
+ When call set_LOCAL_RECIPIENT_FILE subdir/subsub/foo
+ The variable LOCAL_RECIPIENT_FILE should equal \
+ "${PREFIX}/subdir/.age-recipients"
+ End
+ End
+
+ Describe 'strlen'
+ It 'accepts an ASCII and returns its length'
+ When call strlen 'abc def'
+ The output should equal 7
+ End
+
+ It 'accepts an empty string and returns 0'
+ When call strlen ''
+ The output should equal 0
+ End
+ End
+
+ Describe 'usage1'
+ It 'accepts arguments'
+ PROGRAM=prg
+ COMMAND=cmd
+ When run usage1 'flag1' 'flag2'
+ The error should equal 'Usage: prg cmd flag1 flag2'
+ The output should be blank
+ The status should equal 1
+ End
+
+ It 'works without argument'
+ PROGRAM=prg
+ COMMAND=cmd
+ When run usage1
+ The error should equal 'Usage: prg cmd'
+ The output should be blank
+ The status should equal 1
+ End
+ End
+
+ Describe 'yesno'
+ Describe 'Without stty'
+ It 'accepts an uppercase N'
+ Data 'N'
+ When call yesno 'prompt'
+ The output should equal 'prompt [y/n]'
+ The variable ANSWER should equal 'N'
+ End
+
+ It 'accepts an uppercase Y'
+ Data 'YES'
+ When call yesno 'prompt'
+ The output should equal 'prompt [y/n]'
+ The variable ANSWER should equal 'y'
+ End
+ End
+
+ Describe 'Dumb terminal with stty'
+ stty() { false; }
+
+ It 'accepts a lowercase N'
+ Data 'no'
+ When call yesno 'prompt'
+ The output should equal 'prompt [y/n]'
+ The variable ANSWER should equal 'n'
+ End
+
+ It 'accepts an uppercase Y'
+ Data 'Y'
+ When call yesno 'prompt'
+ The output should equal 'prompt [y/n]'
+ The variable ANSWER should equal 'y'
+ End
+ End
+
+ Describe 'Using a terminal'
+ Skip 'Not testable here'
+ End
+ End
+End
diff --git a/spec/spec_helper.sh b/spec/spec_helper.sh
@@ -0,0 +1,24 @@
+# shellcheck shell=sh
+
+# Defining variables and functions here will affect all specfiles.
+# Change shell options inside a function may cause different behavior,
+# so it is better to set them here.
+# set -eu
+
+# This callback function will be invoked only once before loading specfiles.
+spec_helper_precheck() {
+ # Available functions: info, warn, error, abort, setenv, unsetenv
+ # Available variables: VERSION, SHELL_TYPE, SHELL_VERSION
+ : minimum_version "0.28.1"
+}
+
+# This callback function will be invoked after a specfile has been loaded.
+spec_helper_loaded() {
+ :
+}
+
+# This callback function will be invoked after core modules has been loaded.
+spec_helper_configure() {
+ # Available functions: import, before_each, after_each, before_all, after_all
+ : import 'support/custom_matcher'
+}
diff --git a/spec/support/bin/@basename b/spec/support/bin/@basename
@@ -0,0 +1,3 @@
+#!/bin/sh -e
+. "$SHELLSPEC_SUPPORT_BIN"
+invoke basename "$@"
diff --git a/spec/support/bin/@cat b/spec/support/bin/@cat
@@ -0,0 +1,3 @@
+#!/bin/sh -e
+. "$SHELLSPEC_SUPPORT_BIN"
+invoke cat "$@"
diff --git a/spec/support/bin/@diff b/spec/support/bin/@diff
@@ -0,0 +1,3 @@
+#!/bin/sh -e
+. "$SHELLSPEC_SUPPORT_BIN"
+invoke diff "$@"
diff --git a/spec/support/bin/@dirname b/spec/support/bin/@dirname
@@ -0,0 +1,3 @@
+#!/bin/sh -e
+. "$SHELLSPEC_SUPPORT_BIN"
+invoke dirname "$@"
diff --git a/spec/support/bin/@grep b/spec/support/bin/@grep
@@ -0,0 +1,3 @@
+#!/bin/sh -e
+. "$SHELLSPEC_SUPPORT_BIN"
+invoke grep "$@"
diff --git a/spec/support/bin/@head b/spec/support/bin/@head
@@ -0,0 +1,3 @@
+#!/bin/sh -e
+. "$SHELLSPEC_SUPPORT_BIN"
+invoke head "$@"
diff --git a/spec/support/bin/@mkdir b/spec/support/bin/@mkdir
@@ -0,0 +1,3 @@
+#!/bin/sh -e
+. "$SHELLSPEC_SUPPORT_BIN"
+invoke mkdir "$@"
diff --git a/spec/support/bin/@mktemp b/spec/support/bin/@mktemp
@@ -0,0 +1,3 @@
+#!/bin/sh -e
+. "$SHELLSPEC_SUPPORT_BIN"
+invoke mktemp "$@"
diff --git a/spec/support/bin/@mv b/spec/support/bin/@mv
@@ -0,0 +1,3 @@
+#!/bin/sh -e
+. "$SHELLSPEC_SUPPORT_BIN"
+invoke mv "$@"
diff --git a/spec/support/bin/@rm b/spec/support/bin/@rm
@@ -0,0 +1,3 @@
+#!/bin/sh -e
+. "$SHELLSPEC_SUPPORT_BIN"
+invoke rm "$@"
diff --git a/spec/support/bin/@sed b/spec/support/bin/@sed
@@ -0,0 +1,3 @@
+#!/bin/sh -e
+. "$SHELLSPEC_SUPPORT_BIN"
+invoke sed "$@"
diff --git a/spec/support/bin/@tail b/spec/support/bin/@tail
@@ -0,0 +1,3 @@
+#!/bin/sh -e
+. "$SHELLSPEC_SUPPORT_BIN"
+invoke tail "$@"
diff --git a/spec/support/bin/@tr b/spec/support/bin/@tr
@@ -0,0 +1,3 @@
+#!/bin/sh -e
+. "$SHELLSPEC_SUPPORT_BIN"
+invoke tr "$@"
diff --git a/spec/usage_spec.sh b/spec/usage_spec.sh
@@ -0,0 +1,1372 @@
+# pashage - age-backed POSIX password manager
+# Copyright (C) 2024 Natasha Kerensikova
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+# This test file fully covers all command functions in isolation,
+# using mocks for all action and helper functions.
+# It mostly tests command-line parsing and environment transmission to actions.
+
+Describe 'Command-Line Parsing'
+ Include src/pashage.sh
+ Set 'errexit:on' 'nounset:on' 'pipefail:on'
+
+ PREFIX=/prefix
+ PROGRAM=prg
+
+ CHARACTER_SET='[:punct:][:alnum:]'
+ CHARACTER_SET_NO_SYMBOLS='[:alnum:]'
+ DECISION=default
+ ECHO=no
+ MULTILINE=no
+ OVERWRITE=no
+ SELECTED_LINE=1
+ SHOW=text
+
+ mocklog() {
+ if [ $# -eq 1 ]; then
+ %printf '$ %s\n' "$1" >&2
+ else
+ %printf '$ %s' "$1" >&2
+ shift
+ %printf ' %s' "$@" >&2
+ %printf '\n' >&2
+ fi
+ }
+
+ # mocks
+ platform_tmpdir() {
+ SECURE_TMPDIR=/tmp/secure
+ }
+
+ git() { mocklog git "$@"; }
+ scm_add() { mocklog scm_add "$@"; }
+ scm_begin() { mocklog scm_begin "$@"; }
+ scm_commit() { mocklog scm_commit "$@"; }
+
+ do_copy_move() {
+ mocklog do_copy_move "$@"
+ %text:expand >&2
+ #|ACTION=${ACTION}
+ #|DECISION=${DECISION}
+ #|OVERWRITE=${OVERWRITE}
+ #|SCM_ACTION=${SCM_ACTION}
+ }
+ do_decrypt() {
+ mocklog do_decrypt "$@"
+ }
+ do_decrypt_gpg() {
+ mocklog do_decrypt_gpg "$@"
+ }
+ do_deinit() {
+ mocklog do_deinit "$@"
+ %text:expand >&2
+ #|DECISION=${DECISION}
+ }
+ do_delete() {
+ mocklog do_delete "$@"
+ %text:expand >&2
+ #|DECISION=${DECISION}
+ }
+ do_edit() {
+ mocklog do_edit "$@"
+ %text:expand >&2
+ #|EDITOR=${EDITOR}
+ #|SECURE_TMPDIR=${SECURE_TMPDIR}
+ #|TERM=${TERM}
+ #|VISUAL=${VISUAL}
+ }
+ do_encrypt() {
+ mocklog do_encrypt "$@"
+ %text:expand >&2
+ #|IDENTITIES_FILE=${IDENTITIES_FILE}
+ #|LOCAL_RECIPIENT_FILE=${LOCAL_RECIPIENT_FILE}
+ #|PASHAGE_RECIPIENTS=${PASHAGE_RECIPIENTS}
+ #|PASHAGE_RECIPIENTS_FILE=${PASHAGE_RECIPIENTS_FILE}
+ #|PASSAGE_RECIPIENTS=${PASSAGE_RECIPIENTS}
+ #|PASSAGE_RECIPIENTS_FILE=${PASSAGE_RECIPIENTS_FILE}
+ }
+ do_generate() {
+ mocklog do_generate "$@"
+ %text:expand >&2
+ #|DECISION=${DECISION}
+ #|OVERWRITE=${OVERWRITE}
+ #|SELECTED_LINE=${SELECTED_LINE}
+ #|SHOW=${SHOW}
+ }
+ do_grep() {
+ mocklog do_grep "$@"
+ }
+ do_init() {
+ mocklog do_init "$@"
+ %text:expand >&2
+ #|DECISION=${DECISION}
+ #|OVERWRITE=${OVERWRITE}
+ }
+ do_insert() {
+ mocklog do_insert "$@"
+ %text:expand >&2
+ #|ECHO=${ECHO}
+ #|MULTILINE=${MULTILINE}
+ #|OVERWRITE=${OVERWRITE}
+ }
+ do_list_or_show() {
+ mocklog do_list_or_show "$@"
+ %text:expand >&2
+ #|SELECTED_LINE=${SELECTED_LINE}
+ #|SHOW=${SHOW}
+ }
+ do_reencrypt() {
+ mocklog do_reencrypt "$@"
+ }
+ do_reencrypt_dir() {
+ mocklog do_reencrypt_dir "$@"
+ %text:expand >&2
+ #|DECISION=${DECISION}
+ }
+ do_reencrypt_file() {
+ mocklog do_reencrypt_file "$@"
+ %text:expand >&2
+ #|DECISION=${DECISION}
+ }
+ do_show() {
+ mocklog do_show "$@"
+ %text:expand >&2
+ #|SELECTED_LINE=${SELECTED_LINE}
+ #|SHOW=${SHOW}
+ }
+ do_tree() {
+ mocklog do_tree "$@"
+ }
+
+ Describe 'cmd_copy'
+ COMMAND=copy
+
+ It 'copies multiple files'
+ result() {
+ %text
+ #|$ do_copy_move src1 dest/
+ #|ACTION=Copy
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SCM_ACTION=scm_cp
+ #|$ do_copy_move src2 dest/
+ #|ACTION=Copy
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SCM_ACTION=scm_cp
+ #|$ do_copy_move src3 dest/
+ #|ACTION=Copy
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SCM_ACTION=scm_cp
+ }
+ When call cmd_copy src1 src2 src3 dest
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'copies forcefully with a long option'
+ result() {
+ %text
+ #|$ do_copy_move src dest
+ #|ACTION=Copy
+ #|DECISION=default
+ #|OVERWRITE=yes
+ #|SCM_ACTION=scm_cp
+ }
+ When call cmd_copy --force src dest
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'copies forcefully with a short option'
+ result() {
+ %text
+ #|$ do_copy_move src dest
+ #|ACTION=Copy
+ #|DECISION=default
+ #|OVERWRITE=yes
+ #|SCM_ACTION=scm_cp
+ }
+ When call cmd_copy -f src dest
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'copies a file named like a flag'
+ result() {
+ %text
+ #|$ do_copy_move -s dest
+ #|ACTION=Copy
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SCM_ACTION=scm_cp
+ }
+ When call cmd_copy -- -s dest
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'reports a bad option'
+ When run cmd_copy -s arg
+ The output should be blank
+ The error should equal 'Usage: prg copy [--force,-f] old-path new-path'
+ The status should equal 1
+ End
+
+ It 'reports a lack of argument'
+ When run cmd_copy src
+ The output should be blank
+ The error should equal 'Usage: prg copy [--force,-f] old-path new-path'
+ The status should equal 1
+ End
+ End
+
+ Describe 'cmd_delete'
+ COMMAND=delete
+
+ It 'removes a file forcefully with a long option'
+ result() {
+ %text
+ #|$ do_delete arg1
+ #|DECISION=force
+ }
+ When call cmd_delete --force arg1
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'removes a file forcefully with a short option'
+ result() {
+ %text
+ #|$ do_delete arg1
+ #|DECISION=force
+ }
+ When call cmd_delete -f arg1
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'removes multiple files'
+ result() {
+ %text
+ #|$ do_delete arg1
+ #|DECISION=default
+ #|$ do_delete arg2
+ #|DECISION=default
+ #|$ do_delete arg3
+ #|DECISION=default
+ }
+ When call cmd_delete arg1 arg2 arg3
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'removes a file named like a flag'
+ result() {
+ %text
+ #|$ do_delete -f
+ #|DECISION=default
+ #|$ do_delete arg2
+ #|DECISION=default
+ }
+ When call cmd_delete -- -f arg2
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'reports a bad option'
+ When run cmd_delete -u arg
+ The output should be blank
+ The error should equal 'Usage: prg delete [--force,-f] pass-name ...'
+ The status should equal 1
+ End
+
+ It 'reports a lack of argument'
+ When run cmd_delete
+ The output should be blank
+ The error should equal 'Usage: prg delete [--force,-f] pass-name ...'
+ The status should equal 1
+ End
+ End
+
+ Describe 'cmd_edit'
+ COMMAND=edit
+ EDITOR=ed
+ TERM=dumb
+ VISUAL=vi
+
+ It 'edits multiple files succesively'
+ result() {
+ %text
+ #|$ do_edit arg1
+ #|EDITOR=ed
+ #|SECURE_TMPDIR=/tmp/secure
+ #|TERM=dumb
+ #|VISUAL=vi
+ #|$ do_edit arg2
+ #|EDITOR=ed
+ #|SECURE_TMPDIR=/tmp/secure
+ #|TERM=dumb
+ #|VISUAL=vi
+ #|$ do_edit arg3
+ #|EDITOR=ed
+ #|SECURE_TMPDIR=/tmp/secure
+ #|TERM=dumb
+ #|VISUAL=vi
+ }
+ When call cmd_edit arg1 arg2 arg3
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'reports a lack of argument'
+ When run cmd_edit
+ The output should be blank
+ The error should equal 'Usage: prg edit pass-name'
+ The status should equal 1
+ End
+ End
+
+ Describe 'cmd_find'
+ COMMAND=find
+
+ It 'uses the argument list directly'
+ When call cmd_find -i pattern
+ The output should be blank
+ The error should equal \
+ '$ do_tree /prefix Search pattern: -i pattern -i pattern'
+ End
+
+ It 'reports a lack of argument'
+ When run cmd_find
+ The output should be blank
+ The error should equal 'Usage: prg find [-grepflags] regex'
+ The status should equal 1
+ End
+ End
+
+ Describe 'cmd_generate'
+ COMMAND=generate
+ GENERATED_LENGTH=25
+
+ It 'generates a new entry with default length'
+ result() {
+ %text
+ #|$ do_generate secret 25 [:punct:][:alnum:]
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SELECTED_LINE=1
+ #|SHOW=text
+ }
+ When call cmd_generate secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'generates a new entry with explicit length'
+ result() {
+ %text
+ #|$ do_generate secret 12 [:punct:][:alnum:]
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SELECTED_LINE=1
+ #|SHOW=text
+ }
+ When call cmd_generate secret 12
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'generates a new flag-like entry'
+ result() {
+ %text
+ #|$ do_generate -f 25 [:punct:][:alnum:]
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SELECTED_LINE=1
+ #|SHOW=text
+ }
+ When call cmd_generate -- -f
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'generates a new entry and copies it into the clipboard (long)'
+ result() {
+ %text
+ #|$ do_generate secret 25 [:punct:][:alnum:]
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SELECTED_LINE=1
+ #|SHOW=clip
+ }
+ When call cmd_generate --clip secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'generates a new entry and copies it into the clipboard (short)'
+ result() {
+ %text
+ #|$ do_generate secret 25 [:punct:][:alnum:]
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SELECTED_LINE=1
+ #|SHOW=clip
+ }
+ When call cmd_generate -c secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'generates a new entry and shows it as a QR-code (long)'
+ result() {
+ %text
+ #|$ do_generate secret 25 [:punct:][:alnum:]
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SELECTED_LINE=1
+ #|SHOW=qrcode
+ }
+ When call cmd_generate --qrcode secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'generates a new entry and shows it as a QR-code (short)'
+ result() {
+ %text
+ #|$ do_generate secret 25 [:punct:][:alnum:]
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SELECTED_LINE=1
+ #|SHOW=qrcode
+ }
+ When call cmd_generate -q secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'generates a new alphanumeric entry and copies it into the clipboard (long)'
+ result() {
+ %text
+ #|$ do_generate secret 25 [:alnum:]
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SELECTED_LINE=1
+ #|SHOW=clip
+ }
+ When call cmd_generate --clip --no-symbols secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'generates a new alphanumeric entry and copies it into the clipboard (short)'
+ result() {
+ %text
+ #|$ do_generate secret 25 [:alnum:]
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SELECTED_LINE=1
+ #|SHOW=clip
+ }
+ When call cmd_generate -cn secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'generates a new alphanumeric entry and shows it as a QR-code (long)'
+ result() {
+ %text
+ #|$ do_generate secret 25 [:alnum:]
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SELECTED_LINE=1
+ #|SHOW=qrcode
+ }
+ When call cmd_generate --no-symbols --qrcode secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'generates a new alphanumeric entry and shows it as a QR-code (short)'
+ result() {
+ %text
+ #|$ do_generate secret 25 [:alnum:]
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SELECTED_LINE=1
+ #|SHOW=qrcode
+ }
+ When call cmd_generate -nq secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'generates a new entry in place (long)'
+ result() {
+ %text
+ #|$ do_generate secret 25 [:punct:][:alnum:]
+ #|DECISION=default
+ #|OVERWRITE=yes
+ #|SELECTED_LINE=1
+ #|SHOW=text
+ }
+ When call cmd_generate --inplace secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'generates a new entry in place (short)'
+ result() {
+ %text
+ #|$ do_generate secret 25 [:punct:][:alnum:]
+ #|DECISION=default
+ #|OVERWRITE=yes
+ #|SELECTED_LINE=1
+ #|SHOW=text
+ }
+ When call cmd_generate -i secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'overwrites an existing entry (long)'
+ result() {
+ %text
+ #|$ do_generate secret 25 [:punct:][:alnum:]
+ #|DECISION=force
+ #|OVERWRITE=no
+ #|SELECTED_LINE=1
+ #|SHOW=text
+ }
+ When call cmd_generate --force secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'overwrites an existing entry (short)'
+ result() {
+ %text
+ #|$ do_generate secret 25 [:punct:][:alnum:]
+ #|DECISION=force
+ #|OVERWRITE=no
+ #|SELECTED_LINE=1
+ #|SHOW=text
+ }
+ When call cmd_generate -f secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'reports incompatible generation long options'
+ When run cmd_generate --inplace --force secret
+ The output should be blank
+ The error should equal 'Usage: prg generate [--no-symbols,-n] [--clip,-c | --qrcode,-q] [--in-place,-i | --force,-f] pass-name [pass-length]'
+ The status should equal 1
+ End
+
+ It 'reports incompatible generation short options'
+ When run cmd_generate -fi secret
+ The output should be blank
+ The error should equal 'Usage: prg generate [--no-symbols,-n] [--clip,-c | --qrcode,-q] [--in-place,-i | --force,-f] pass-name [pass-length]'
+ The status should equal 1
+ End
+
+ It 'reports incompatible show long options'
+ When run cmd_generate --qrcode --clip secret
+ The output should be blank
+ The error should equal 'Usage: prg generate [--no-symbols,-n] [--clip,-c | --qrcode,-q] [--in-place,-i | --force,-f] pass-name [pass-length]'
+ The status should equal 1
+ End
+
+ It 'reports incompatible show short options'
+ When run cmd_generate -cq secret
+ The output should be blank
+ The error should equal 'Usage: prg generate [--no-symbols,-n] [--clip,-c | --qrcode,-q] [--in-place,-i | --force,-f] pass-name [pass-length]'
+ The status should equal 1
+ End
+
+ It 'reports a bad option'
+ When run cmd_generate --bad secret
+ The output should be blank
+ The error should equal 'Usage: prg generate [--no-symbols,-n] [--clip,-c | --qrcode,-q] [--in-place,-i | --force,-f] pass-name [pass-length]'
+ The status should equal 1
+ End
+
+ It 'reports a lack of argument'
+ When run cmd_generate
+ The output should be blank
+ The error should equal 'Usage: prg generate [--no-symbols,-n] [--clip,-c | --qrcode,-q] [--in-place,-i | --force,-f] pass-name [pass-length]'
+ The status should equal 1
+ End
+ End
+
+ Describe 'cmd_git'
+ COMMAND=git
+
+ cmd_gitconfig() { mocklog cmd_gitconfig "$@"; }
+ mkdir() { mocklog mkdir "$@"; @mkdir "$@"; }
+
+ setup() {
+ @mkdir -p "${SHELLSPEC_WORKDIR}/repo/.git"
+ @mkdir -p "${SHELLSPEC_WORKDIR}/repo/sub"
+ }
+
+ cleanup() {
+ @rm -rf "${SHELLSPEC_WORKDIR}/repo"
+ }
+
+ BeforeEach setup
+ AfterEach cleanup
+
+ It 'initializes a new repository'
+ PREFIX="${SHELLSPEC_WORKDIR}/repo/sub"
+ result() {
+ %text:expand
+ #|$ mkdir -p ${PREFIX}
+ #|$ git -C ${PREFIX} init
+ #|$ scm_begin
+ #|$ scm_add .
+ #|$ scm_commit Add current contents of password store.
+ #|$ cmd_gitconfig
+ }
+ When call cmd_git init
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'clones a new repository'
+ PREFIX="${SHELLSPEC_WORKDIR}/repo/sub"
+ result() {
+ %text:expand
+ #|$ git clone origin ${PREFIX}
+ #|$ cmd_gitconfig
+ }
+ When call cmd_git clone origin
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'runs the git command into the store'
+ PREFIX="${SHELLSPEC_WORKDIR}/repo"
+ When call cmd_git log --oneline
+ The output should be blank
+ The error should equal "$ git -C ${PREFIX} log --oneline"
+ End
+
+ It 'reports a lack of argument'
+ When run cmd_git
+ The output should be blank
+ The error should equal 'Usage: prg git args ...'
+ The status should equal 1
+ End
+
+ It 'aborts without a git repository'
+ PREFIX="${SHELLSPEC_WORKDIR}/repo/sub"
+ When run cmd_git log
+ The output should be blank
+ The error should equal 'Error: the password store is not a git repository. Try "prg git init".'
+ The status should equal 1
+ End
+ End
+
+ Describe 'cmd_gitconfig'
+ COMMAND=gitconfig
+ AGE=age
+ IDENTITIES_FILE=id
+
+ grep() { @grep "$@"; }
+
+ setup() {
+ @mkdir -p "${SHELLSPEC_WORKDIR}/repo/.git"
+ @mkdir -p "${SHELLSPEC_WORKDIR}/repo/sub-1/.git"
+ %putsn data >"${SHELLSPEC_WORKDIR}/repo/sub-1/.gitattributes"
+ @mkdir -p "${SHELLSPEC_WORKDIR}/repo/sub-2/.git"
+ %putsn '*.age diff=age' >"${SHELLSPEC_WORKDIR}/repo/sub-2/.gitattributes"
+ }
+
+ cleanup() {
+ @rm -rf "${SHELLSPEC_WORKDIR}/repo"
+ }
+
+ BeforeEach setup
+ AfterEach cleanup
+
+ It 'configures a new repository'
+ PREFIX="${SHELLSPEC_WORKDIR}/repo"
+ result() {
+ %text:expand
+ #|$ scm_begin
+ #|$ scm_add .gitattributes
+ #|$ scm_commit Configure git repository for age file diff.
+ #|$ git -C ${PREFIX} config --local diff.age.binary true
+ #|$ git -C ${PREFIX} config --local diff.age.textconv age -d -i id
+ }
+ When call cmd_gitconfig
+ The output should be blank
+ The error should equal "$(result)"
+ The contents of file "${PREFIX}/.gitattributes" should equal '*.age diff=age'
+ End
+
+ It 'expands an existing .gitattributes'
+ PREFIX="${SHELLSPEC_WORKDIR}/repo/sub-1"
+ result() {
+ %text:expand
+ #|$ scm_begin
+ #|$ scm_add .gitattributes
+ #|$ scm_commit Configure git repository for age file diff.
+ #|$ git -C ${PREFIX} config --local diff.age.binary true
+ #|$ git -C ${PREFIX} config --local diff.age.textconv age -d -i id
+ }
+ attrs() {
+ %text
+ #|data
+ #|*.age diff=age
+ }
+ When call cmd_gitconfig
+ The output should be blank
+ The error should equal "$(result)"
+ The contents of file "${PREFIX}/.gitattributes" should equal "$(attrs)"
+ End
+
+ It 'configures a repository with a valid .gitattributes'
+ PREFIX="${SHELLSPEC_WORKDIR}/repo/sub-2"
+ result() {
+ %text:expand
+ #|$ git -C ${PREFIX} config --local diff.age.binary true
+ #|$ git -C ${PREFIX} config --local diff.age.textconv age -d -i id
+ }
+ When call cmd_gitconfig
+ The output should be blank
+ The error should equal "$(result)"
+ The contents of file "${PREFIX}/.gitattributes" should equal '*.age diff=age'
+ End
+
+ It 'aborts without a git repository'
+ PREFIX="${SHELLSPEC_WORKDIR}/repo/prefix"
+ When run cmd_gitconfig
+ The output should be blank
+ The error should equal 'The store is not a git repository.'
+ The status should equal 1
+ End
+ End
+
+ Describe 'cmd_grep'
+ COMMAND=grep
+ PREFIX="${SHELLSPEC_WORKDIR}"
+
+ It 'uses the argument list directly'
+ When call cmd_grep -i pattern
+ The output should be blank
+ The error should equal '$ do_grep -i pattern'
+ End
+
+ It 'reports a lack of argument'
+ When run cmd_grep
+ The output should be blank
+ The error should equal 'Usage: prg grep [GREP_OPTIONS] search-regex'
+ The status should equal 1
+ End
+ End
+
+ Describe 'cmd_init'
+ COMMAND=init
+
+ It 'initializes the whole store'
+ result() {
+ %text
+ #|$ do_init recipient-1 recipient-2
+ #|DECISION=default
+ #|OVERWRITE=no
+ }
+ When call cmd_init recipient-1 recipient-2
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'initializes a subdirectory with a collapsed long option'
+ result() {
+ %text
+ #|$ do_init sub recipient
+ #|DECISION=default
+ #|OVERWRITE=no
+ }
+ When call cmd_init --path=sub recipient
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'initializes a subdirectory with an expanded long option'
+ result() {
+ %text
+ #|$ do_init sub recipient
+ #|DECISION=default
+ #|OVERWRITE=no
+ }
+ When call cmd_init --path sub recipient
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'initializes a subdirectory with a collapsed short option'
+ result() {
+ %text
+ #|$ do_init sub recipient
+ #|DECISION=default
+ #|OVERWRITE=no
+ }
+ When call cmd_init -psub recipient
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'initializes a subdirectory with an expanded short option'
+ result() {
+ %text
+ #|$ do_init sub recipient
+ #|DECISION=default
+ #|OVERWRITE=no
+ }
+ When call cmd_init -p sub recipient
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'de-initializes a subdirectory'
+ result() {
+ %text
+ #|$ do_deinit sub
+ #|DECISION=default
+ }
+ When call cmd_init -p sub ''
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'supports recipients starting with dash'
+ result() {
+ %text
+ #|$ do_init -recipient
+ #|DECISION=default
+ #|OVERWRITE=no
+ }
+ When call cmd_init -- -recipient
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'reports a bad option'
+ When run cmd_init -q arg
+ The output should be blank
+ The error should equal \
+ 'Usage: prg init [--path=subfolder,-p subfolder] recipient ...'
+ The status should equal 1
+ End
+
+ It 'reports a missing recipient'
+ When run cmd_init -p sub
+ The output should be blank
+ The error should equal \
+ 'Usage: prg init [--path=subfolder,-p subfolder] recipient ...'
+ The status should equal 1
+ End
+
+ It 'reports a missing path'
+ When run cmd_init -p
+ The output should be blank
+ The error should equal \
+ 'Usage: prg init [--path=subfolder,-p subfolder] recipient ...'
+ The status should equal 1
+ End
+
+ It 'reports a lack of argument'
+ When run cmd_init
+ The output should be blank
+ The error should equal \
+ 'Usage: prg init [--path=subfolder,-p subfolder] recipient ...'
+ The status should equal 1
+ End
+ End
+
+ Describe 'cmd_insert'
+ COMMAND=insert
+
+ It 'inserts a few new entries'
+ result() {
+ %text
+ #|$ do_insert secret1
+ #|ECHO=no
+ #|MULTILINE=no
+ #|OVERWRITE=no
+ #|$ do_insert secret2
+ #|ECHO=no
+ #|MULTILINE=no
+ #|OVERWRITE=no
+ }
+ When call cmd_insert secret1 secret2
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'inserts a new flag-like entry'
+ result() {
+ %text
+ #|$ do_insert -c
+ #|ECHO=no
+ #|MULTILINE=no
+ #|OVERWRITE=no
+ }
+ When call cmd_insert -- -c
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'inserts a new entry with echo (short option)'
+ result() {
+ %text
+ #|$ do_insert secret
+ #|ECHO=yes
+ #|MULTILINE=no
+ #|OVERWRITE=no
+ }
+ When call cmd_insert -e secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'inserts a new entry with echo (long option)'
+ result() {
+ %text
+ #|$ do_insert secret
+ #|ECHO=yes
+ #|MULTILINE=no
+ #|OVERWRITE=no
+ }
+ When call cmd_insert --echo secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'forcefully inserts a new entry with echo (short option)'
+ result() {
+ %text
+ #|$ do_insert secret
+ #|ECHO=yes
+ #|MULTILINE=no
+ #|OVERWRITE=yes
+ }
+ When call cmd_insert -fe secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'forcefully inserts a new entry with echo (short options)'
+ result() {
+ %text
+ #|$ do_insert secret
+ #|ECHO=yes
+ #|MULTILINE=no
+ #|OVERWRITE=yes
+ }
+ When call cmd_insert -e -f secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'forcefully inserts a new entry with echo (long option)'
+ result() {
+ %text
+ #|$ do_insert secret
+ #|ECHO=yes
+ #|MULTILINE=no
+ #|OVERWRITE=yes
+ }
+ When call cmd_insert --force --echo secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'inserts a new multiline entry (short option)'
+ result() {
+ %text
+ #|$ do_insert secret
+ #|ECHO=no
+ #|MULTILINE=yes
+ #|OVERWRITE=no
+ }
+ When call cmd_insert -m secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'inserts a new multiline entry (long option)'
+ result() {
+ %text
+ #|$ do_insert secret
+ #|ECHO=no
+ #|MULTILINE=yes
+ #|OVERWRITE=no
+ }
+ When call cmd_insert --multiline secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'forcefully inserts a new multiline entry (short option)'
+ result() {
+ %text
+ #|$ do_insert secret
+ #|ECHO=no
+ #|MULTILINE=yes
+ #|OVERWRITE=yes
+ }
+ When call cmd_insert -mf secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'forcefully inserts a new multiline entry (short options)'
+ result() {
+ %text
+ #|$ do_insert secret
+ #|ECHO=no
+ #|MULTILINE=yes
+ #|OVERWRITE=yes
+ }
+ When call cmd_insert -m -f secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'forcefully inserts a new multiline entry (long option)'
+ result() {
+ %text
+ #|$ do_insert secret
+ #|ECHO=no
+ #|MULTILINE=yes
+ #|OVERWRITE=yes
+ }
+ When call cmd_insert --force --multiline secret
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'reports a bad option'
+ When run cmd_insert -u secret
+ The output should be blank
+ The error should equal \
+ 'Usage: prg insert [--echo,-e | --multiline,-m] [--force,-f] pass-name'
+ The status should equal 1
+ End
+
+ It 'reports incompatible long options'
+ When run cmd_insert --multiline --echo secret
+ The output should be blank
+ The error should equal \
+ 'Usage: prg insert [--echo,-e | --multiline,-m] [--force,-f] pass-name'
+ The status should equal 1
+ End
+
+ It 'reports incompatible short options'
+ When run cmd_insert -em secret
+ The output should be blank
+ The error should equal \
+ 'Usage: prg insert [--echo,-e | --multiline,-m] [--force,-f] pass-name'
+ The status should equal 1
+ End
+
+ It 'reports a lack of argument'
+ When run cmd_insert
+ The output should be blank
+ The error should equal \
+ 'Usage: prg insert [--echo,-e | --multiline,-m] [--force,-f] pass-name'
+ The status should equal 1
+ End
+ End
+
+ Describe 'cmd_list_or_show'
+ SELECTED_LINE=1
+ SHOW=text
+
+ It 'lists the whole store'
+ result() {
+ %text | @sed 's/\$$//'
+ #|$ do_list_or_show $
+ #|SELECTED_LINE=1
+ #|SHOW=text
+ }
+ When call cmd_list_or_show
+ The output should be blank
+ The error should equal "$(result)"
+ The variable COMMAND should equal 'show'
+ End
+
+ It 'shows multiple entries'
+ result() {
+ %text
+ #|$ do_list_or_show arg1
+ #|SELECTED_LINE=1
+ #|SHOW=text
+ #|$ do_list_or_show arg2
+ #|SELECTED_LINE=1
+ #|SHOW=text
+ #|$ do_list_or_show arg3
+ #|SELECTED_LINE=1
+ #|SHOW=text
+ }
+ When call cmd_list_or_show arg1 arg2 arg3
+ The output should be blank
+ The error should equal "$(result)"
+ The variable COMMAND should equal 'show'
+ End
+
+ It 'shows a flag-like entry'
+ result() {
+ %text
+ #|$ do_list_or_show -c
+ #|SELECTED_LINE=1
+ #|SHOW=text
+ }
+ When call cmd_list_or_show -- -c
+ The output should be blank
+ The error should equal "$(result)"
+ The variable COMMAND should equal 'show'
+ End
+
+ It 'copies an entry into the clipboard (short option)'
+ result() {
+ %text
+ #|$ do_list_or_show arg
+ #|SELECTED_LINE=1
+ #|SHOW=clip
+ }
+ When call cmd_list_or_show -c arg
+ The output should be blank
+ The error should equal "$(result)"
+ The variable COMMAND should equal 'show'
+ End
+
+ It 'copies an entry into the clipboard (long option)'
+ result() {
+ %text
+ #|$ do_list_or_show arg
+ #|SELECTED_LINE=1
+ #|SHOW=clip
+ }
+ When call cmd_list_or_show --clip arg
+ The output should be blank
+ The error should equal "$(result)"
+ The variable COMMAND should equal 'show'
+ End
+
+ It 'copies a line of an entry into the clipboard (short option)'
+ result() {
+ %text
+ #|$ do_list_or_show arg
+ #|SELECTED_LINE=2
+ #|SHOW=clip
+ }
+ When call cmd_list_or_show -c2 arg
+ The output should be blank
+ The error should equal "$(result)"
+ The variable COMMAND should equal 'show'
+ End
+
+ It 'copies a line of an entry into the clipboard (short option)'
+ result() {
+ %text
+ #|$ do_list_or_show arg
+ #|SELECTED_LINE=2
+ #|SHOW=clip
+ }
+ When call cmd_list_or_show --clip=2 arg
+ The output should be blank
+ The error should equal "$(result)"
+ The variable COMMAND should equal 'show'
+ End
+
+ It 'shows an entry as a QR-code (short option)'
+ result() {
+ %text
+ #|$ do_list_or_show arg
+ #|SELECTED_LINE=1
+ #|SHOW=qrcode
+ }
+ When call cmd_list_or_show -q arg
+ The output should be blank
+ The error should equal "$(result)"
+ The variable COMMAND should equal 'show'
+ End
+
+ It 'shows an entry as a QR-code (long option)'
+ result() {
+ %text
+ #|$ do_list_or_show arg
+ #|SELECTED_LINE=1
+ #|SHOW=qrcode
+ }
+ When call cmd_list_or_show --qrcode arg
+ The output should be blank
+ The error should equal "$(result)"
+ The variable COMMAND should equal 'show'
+ End
+
+ It 'shows the line of an entry as a QR-code (short option)'
+ result() {
+ %text
+ #|$ do_list_or_show arg
+ #|SELECTED_LINE=3
+ #|SHOW=qrcode
+ }
+ When call cmd_list_or_show -q3 arg
+ The output should be blank
+ The error should equal "$(result)"
+ The variable COMMAND should equal 'show'
+ End
+
+ It 'shows the line of an entry as a QR-code (long option)'
+ result() {
+ %text
+ #|$ do_list_or_show arg
+ #|SELECTED_LINE=3
+ #|SHOW=qrcode
+ }
+ When call cmd_list_or_show --qrcode=3 arg
+ The output should be blank
+ The error should equal "$(result)"
+ The variable COMMAND should equal 'show'
+ End
+
+ It 'reports a bad option'
+ When run cmd_list_or_show -f arg
+ The output should be blank
+ The error should equal \
+ 'Usage: prg show [ --clip[=line-number], -c[line-number] ] [ --qrcode[=line-number], -q[line-number] ] pass-name'
+ The status should equal 1
+ End
+ End
+
+ Describe 'cmd_move'
+ COMMAND=move
+
+ It 'moves multiple files'
+ result() {
+ %text
+ #|$ do_copy_move src1 dest/
+ #|ACTION=Move
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SCM_ACTION=scm_mv
+ #|$ do_copy_move src2 dest/
+ #|ACTION=Move
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SCM_ACTION=scm_mv
+ #|$ do_copy_move src3 dest/
+ #|ACTION=Move
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SCM_ACTION=scm_mv
+ }
+ When call cmd_move src1 src2 src3 dest
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'moves forcefully with a long option'
+ result() {
+ %text
+ #|$ do_copy_move src dest
+ #|ACTION=Move
+ #|DECISION=default
+ #|OVERWRITE=yes
+ #|SCM_ACTION=scm_mv
+ }
+ When call cmd_move --force src dest
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'moves forcefully with a short option'
+ result() {
+ %text
+ #|$ do_copy_move src dest
+ #|ACTION=Move
+ #|DECISION=default
+ #|OVERWRITE=yes
+ #|SCM_ACTION=scm_mv
+ }
+ When call cmd_move -f src dest
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'moves a file named like a flag'
+ result() {
+ %text
+ #|$ do_copy_move -s dest
+ #|ACTION=Move
+ #|DECISION=default
+ #|OVERWRITE=no
+ #|SCM_ACTION=scm_mv
+ }
+ When call cmd_move -- -s dest
+ The output should be blank
+ The error should equal "$(result)"
+ End
+
+ It 'reports a bad option'
+ When run cmd_move -s arg
+ The output should be blank
+ The error should equal 'Usage: prg move [--force,-f] old-path new-path'
+ The status should equal 1
+ End
+
+ It 'reports a lack of argument'
+ When run cmd_move src
+ The output should be blank
+ The error should equal 'Usage: prg move [--force,-f] old-path new-path'
+ The status should equal 1
+ End
+ End
+
+ Describe 'cmd_version'
+ COMMAND=version
+
+ It 'shows the version box'
+ cat() { @cat; }
+ result() {
+ %text
+ #|==============================================
+ #|= pashage: age-backed POSIX password manager =
+ #|= =
+ #|= v*
+ #|= =
+ #|= Natasha Kerensikova =
+ #|= =
+ #|= Based on: =
+ #|= password-store by Jason A. Donenfeld =
+ #|= passage by Filippo Valsorda =
+ #|= pash by Dylan Araps =
+ #|==============================================
+ }
+ When call cmd_version
+ The output should match pattern "$(result)"
+ End
+ End
+End
diff --git a/src/pashage.sh b/src/pashage.sh
@@ -0,0 +1,1393 @@
+#!/bin/sh
+# pashage - age-backed POSIX password manager
+# Copyright (C) 2024 Natasha Kerensikova
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+#############################
+# INTERNAL HELPER FUNCTIONS #
+#############################
+
+# Check a path and abort if it looks suspicious
+# $1: path to check
+check_sneaky_path() {
+ if [ "$1" = ".." ] \
+ || [ "$1" = "../${1#../}" ] \
+ || [ "$1" = "${1%/..}/.." ] \
+ || ! [ "$1" = "${1##*/../}" ] \
+ && [ -n "$1" ]
+ then
+ die "Encountered path considered sneaky: \"$1\""
+ fi
+}
+
+# Check paths and abort if any looks suspicious
+check_sneaky_paths() {
+ for ARG in "$@"; do
+ check_sneaky_path "${ARG}"
+ done
+ unset ARG
+}
+
+# Run the arguments as a command an die on failure
+checked() {
+ if "$@"; then
+ :
+ else
+ CODE="$?"
+ printf '%s\n' "Fatal(${CODE}): $*" >&2
+ exit "${CODE}"
+ fi
+}
+
+# Output an error message and quit immediately
+die() {
+ printf '%s\n' "$*" >&2
+ exit 1
+}
+
+# Checks whether a globs expands correctly
+# This lets the shell expand the glob as an argument list, and counts on
+# the glob being passed unchanged as $1 otherwise.
+glob_exists() {
+ if [ -e "$1" ]; then
+ ANSWER=y
+ else
+ ANSWER=n
+ fi
+}
+
+# Find the deepest recipient file above the given path
+set_LOCAL_RECIPIENT_FILE() {
+ LOCAL_RECIPIENT_FILE="/$1"
+
+ while [ -n "${LOCAL_RECIPIENT_FILE}" ] \
+ && ! [ -f "${PREFIX}${LOCAL_RECIPIENT_FILE}/.age-recipients" ]
+ do
+ LOCAL_RECIPIENT_FILE="${LOCAL_RECIPIENT_FILE%/*}"
+ done
+
+ if ! [ -f "${PREFIX}${LOCAL_RECIPIENT_FILE}/.age-recipients" ]; then
+ LOCAL_RECIPIENT_FILE=
+ return 0
+ fi
+
+ LOCAL_RECIPIENT_FILE="${PREFIX}${LOCAL_RECIPIENT_FILE}/.age-recipients"
+}
+
+# Count how many characters are in the first argument
+# $1: string to measure
+strlen(){
+ RESULT=0
+ STR="$1"
+ while [ -n "${STR}" ]; do
+ RESULT=$((RESULT + 1))
+ STR="${STR#?}"
+ done
+ printf '%s\n' "${RESULT}"
+ unset RESULT
+ unset STR
+}
+
+# Output a usage error message message
+usage1() {
+ die "Usage: ${PROGRAM} ${COMMAND}" "$@"
+}
+
+# Ask for confirmation
+# $1: Prompt
+yesno() {
+ printf '%s [y/n]' "$1"
+
+ if type stty >/dev/null 2>&1 && stty >/dev/null 2>&1; then
+
+ # Enable raw input to allow for a single byte to be read from
+ # stdin without needing to wait for the user to press Return.
+ stty -icanon
+
+ ANSWER=''
+
+ while [ "${ANSWER}" = "${ANSWER#[NnYy]}" ]; do
+ # Read a single byte from stdin using 'dd'.
+ # POSIX 'read' has no support for single/'N' byte
+ # based input from the user.
+ ANSWER=$(dd ibs=1 count=1 2>/dev/null)
+ done
+
+ # Disable raw input, leaving the terminal how we *should*
+ # have found it.
+ stty icanon
+
+ printf '\n'
+ else
+ read -r ANSWER
+ ANSWER="${ANSWER%"${ANSWER#?}"}"
+ fi
+
+ if [ "${ANSWER}" = Y ]; then
+ ANSWER=y
+ fi
+}
+
+
+
+##################
+# SCM MANAGEMENT #
+##################
+
+# Add a file or directory to pending changes
+# $1: path
+scm_add() {
+ [ -d "${PREFIX}/.git" ] || return 0
+ git -C "${PREFIX}" add "$1"
+}
+
+# Start a sequence of changes, asserting nothing is pending
+scm_begin() {
+ [ -d "${PREFIX}/.git" ] || return 0
+ if [ -n "$(git -C "${PREFIX}" status --porcelain || true)" ]; then
+ die "There are already pending changes."
+ fi
+}
+
+# Commit pending changes
+# $1: commit message
+scm_commit() {
+ [ -d "${PREFIX}/.git" ] || return 0
+ if [ -n "$(git -C "${PREFIX}" status --porcelain || true)" ]; then
+ git -C "${PREFIX}" commit -m "$1"
+ fi
+}
+
+# Copy a file or directory in the filesystem and put it in pending changes
+# $1: source
+# $2: destination
+scm_cp() {
+ cp -r "${PREFIX}/$1" "${PREFIX}/$2"
+ scm_add "$2"
+}
+
+# Add deletion of a file or directory to pending changes
+# $1: path
+scm_del() {
+ [ -d "${PREFIX}/.git" ] || return 0
+ git -C "${PREFIX}" rm -qr "$1"
+}
+
+# Move a file or directory in the filesystem and put it in pending changes
+# $1: source
+# $2: destination
+scm_mv() {
+ if [ -d "${PREFIX}/.git" ]; then
+ git -C "${PREFIX}" mv "$1" "$2"
+ else
+ mv "${PREFIX}/$1" "${PREFIX}/$2"
+ fi
+}
+
+# Delete a file or directory from filesystem and put it in pending chnages
+scm_rm() {
+ rm -rf "${PREFIX:?}/$1"
+ scm_del "$1"
+}
+
+
+###########
+# ACTIONS #
+###########
+
+# Copy or move (depending on ${ACTION}) a secret file or dectory
+# $1: source name
+# $2: destination name
+# ACTION: Copy or Move
+# DECISION: whether to re-encrypt or copy/move
+# OVERWRITE: whether to overwrite without confirmation
+# SCM_ACTION: scm_cp or scm_mv
+do_copy_move() {
+ if [ "$1" = "${1%/}/" ]; then
+ if ! [ -d "${PREFIX}/$1" ]; then
+ die "Error: $1 is not in the password store."
+ fi
+ SRC="$1"
+ elif [ -e "${PREFIX}/$1.age" ] && ! [ -d "${PREFIX}/$1.age" ]; then
+ SRC="$1.age"
+ elif [ -n "$1" ] && [ -d "${PREFIX}/$1" ]; then
+ SRC="$1/"
+ elif [ -e "${PREFIX}/$1" ]; then
+ SRC="$1"
+ else
+ die "Error: $1 is not in the password store."
+ fi
+
+ if [ -z "${SRC}" ] || [ "${SRC}" = "${SRC%/}/" ]; then
+ LOCAL_ACTION=do_copy_move_dir
+ if [ -d "${PREFIX}/$2" ]; then
+ DEST="${2%/}${2:+/}$(basename "${SRC%/}")/"
+ if [ -e "${PREFIX}/${DEST}" ]; then
+ die "Error: $2 already contains" \
+ "$(basename "${SRC%/}")"
+ fi
+ else
+ DEST="${2%/}${2:+/}"
+ if [ -e "${PREFIX}/${DEST%/}" ]; then
+ die "Error: ${DEST%/} is not a directory"
+ fi
+ fi
+ mkdir -p "${PREFIX}/${DEST}"
+
+ elif [ "$2" = "${2%/}/" ]; then
+ mkdir -p "${PREFIX}/$2"
+ [ -d "${PREFIX}/$2" ] || die "Error: $2 is not a directory"
+ DEST="$2$(basename "${SRC}")"
+ LOCAL_ACTION=do_copy_move_file
+
+ elif [ -d "${PREFIX}/$2" ]; then
+ DEST="${2%/}/$(basename "${SRC}")"
+ if [ -d "${PREFIX}/${DEST}" ]; then
+ die "Error: $2 already contains $(basename "${SRC}")"
+ fi
+ LOCAL_ACTION=do_copy_move_file
+
+ else
+ if [ "${SRC}" = "${SRC%.age}.age" ] \
+ && ! [ "$2" = "${2%.age}.age" ]
+ then
+ DEST="$2.age"
+ else
+ DEST="$2"
+ fi
+
+ mkdir -p "$(dirname "${PREFIX}/${DEST}")"
+ LOCAL_ACTION=do_copy_move_file
+ fi
+
+ case "${DECISION}" in
+ force|interactive)
+ ANSWER=y
+ ;;
+ keep)
+ ANSWER=n
+ ;;
+ default)
+ if [ "${SRC}" = "${SRC%/}/" ]; then
+ # Handled in do_copy_move_dir
+ ANSWER=y
+ else
+ set_LOCAL_RECIPIENT_FILE "${SRC}"
+ SRC_FILE="${LOCAL_RECIPIENT_FILE}"
+ set_LOCAL_RECIPIENT_FILE "${DEST}"
+ DST_FILE="${LOCAL_RECIPIENT_FILE}"
+
+ if [ "${SRC_FILE}" = "${DST_FILE}" ]; then
+ ANSWER=n
+ elif [ -n "${SRC_FILE}" ] \
+ && [ -n "${DST_FILE}" ] \
+ && diff "${SRC_FILE}" "${DST_FILE}" >/dev/null 2>&1
+ then
+ ANSWER=n
+ else
+ ANSWER=y
+ fi
+
+ unset DST_FILE
+ unset SRC_FILE
+ fi
+ ;;
+ *)
+ die "Unexpected DECISION value \"${DECISION}\""
+ ;;
+ esac
+
+ scm_begin
+ SCM_COMMIT_MSG="${ACTION} ${SRC} to ${DEST}"
+
+ if [ "${ANSWER}" = y ]; then
+ "${LOCAL_ACTION}" "${SRC}" "${DEST}"
+ else
+ "${SCM_ACTION}" "${SRC}" "${DEST}"
+ fi
+
+ scm_commit "${SCM_COMMIT_MSG}"
+
+ unset LOCAL_ACTION
+ unset SRC
+ unset DEST
+ unset SCM_COMMIT_MSG
+}
+
+# Copy or move a secret directory (depending on ${ACTION})
+# $1: source directory name (with a trailing slash)
+# $2: destination directory name (with a trailing slash)
+# DECISION: whether to re-encrypt or copy/move
+# SCM_ACTION: scm_cp or scm_mv
+do_copy_move_dir() {
+ [ "$1" = "${1%/}/" ] || [ -z "$1" ] || die 'Internal error'
+ [ "$2" = "${2%/}/" ] || [ -z "$2" ] || die 'Internal error'
+
+ if [ -e "${PREFIX}/$1.age-recipients" ] \
+ && { [ "${DECISION}" = keep ] || [ "${DECISION}" = default ]; }
+ then
+ # Recipiends are transported too, no need to reencrypt
+ "${SCM_ACTION}" "$1" "$2"
+ else
+ for ARG in "${PREFIX}/$1".* "${PREFIX}/$1"*; do
+ SRC="${ARG#"${PREFIX}/"}"
+ DEST="$2$(basename "${ARG}")"
+
+ if [ -f "${ARG}" ]; then
+ do_copy_move_file "${SRC}" "${DEST}"
+ elif [ -d "${ARG}" ] && [ "${ARG}" = "${ARG%/.*}" ]
+ then
+ mkdir -p "${PREFIX}/${DEST}"
+ do_copy_move_dir "${SRC}/" "${DEST}/"
+ fi
+ done
+
+ unset ARG
+ fi
+}
+
+# Copy or move a secret file (depending on ${ACTION})
+# $1: source file name
+# $2: destination file name
+# ACTION: Copy or Move
+# DECISION: whether to re-encrypt or copy/move
+# OVERWRITE: whether to overwrite without confirmation
+# SCM_ACTION: scm_cp or scm_mv
+do_copy_move_file() {
+ if [ -e "${PREFIX}/$2" ]; then
+ if ! [ "${OVERWRITE}" = yes ]; then
+ yesno "$2 already exists. Overwrite?"
+ [ "${ANSWER}" = y ] || return 0
+ unset ANSWER
+ fi
+
+ rm -f "${PREFIX}/$2"
+ fi
+
+ if [ "$1" = "${1%.age}.age" ]; then
+ case "${DECISION}" in
+ keep)
+ ANSWER=n
+ ;;
+ interactive)
+ yesno "Reencrypt ${1%.age} into ${2%.age}?"
+ ;;
+ default|force)
+ ANSWER=y
+ ;;
+ *)
+ die "Unexpected DECISION value \"${DECISION}\""
+ ;;
+ esac
+ else
+ ANSWER=n
+ fi
+
+ if [ "${ANSWER}" = y ]; then
+ do_decrypt "${PREFIX}/$1" | do_encrypt "$2"
+ if [ "${ACTION}" = Move ]; then
+ scm_rm "$1"
+ fi
+ scm_add "$2"
+ else
+ "${SCM_ACTION}" "$1" "$2"
+ fi
+
+ unset ANSWER
+}
+
+# Decrypt a secret file into standard output
+# $1: full path of the encrypted file
+# IDENTITIES_FILE: full path of age identity
+do_decrypt() {
+ checked "${AGE}" -d -i "${IDENTITIES_FILE}" "$1"
+}
+
+# Decrypt a GPG secret file into standard output
+# $1: pull path of the encrypted file
+# GPG: (optional) gpg command
+do_decrypt_gpg() {
+ if [ -z "${GPG-}" ]; then
+ if type gpg2 >/dev/null 2>&1; then
+ GPG=gpg2
+ elif type gpg >/dev/null 2>&1; then
+ GPG=gpg
+ else
+ die "GPG does not seem available"
+ fi
+ fi
+
+ if [ -n "${GPG_AGENT_INFO-}" ] || [ "${GPG}" = "gpg2" ]; then
+ set -- "--batch" "--use-agent" "$@"
+ fi
+ set -- "--quiet" \
+ "--yes" \
+ "--compress-algo=none" \
+ "--no-encrypt-to" \
+ "$@"
+
+ checked "${GPG}" -d "$@"
+}
+
+# Remove identities from a subdirectory
+# $1: relative subdirectory (may be empty)
+# DECISION: whether to re-encrypt or not
+do_deinit() {
+ LOC="${1:-store root}"
+ TARGET="${1%/}${1:+/}.age-recipients"
+
+ if ! [ -f "${PREFIX}/${TARGET}" ]; then
+ die "No existing recipient to remove at ${LOC}"
+ fi
+
+ scm_begin
+ scm_rm "${TARGET}"
+ if ! [ "${DECISION}" = keep ]; then
+ do_reencrypt_dir "${PREFIX}/$1"
+ fi
+ scm_commit "Deinitialize ${LOC}"
+ rmdir -p "${PREFIX}/$1" 2>/dev/null || true
+
+ unset LOC
+ unset TARGET
+}
+
+# Delete a file or directory from the password store
+# $1: file or directory name
+# DECISION: whether to ask before deleting
+do_delete() {
+ # Distinguish between file or directory
+ if [ "$1" = "${1%/}/" ]; then
+ NAME="$1"
+ TARGET="$1"
+ if ! [ -e "${PREFIX}/${NAME%/}" ]; then
+ die "Error: $1 is not in the password store."
+ fi
+ if ! [ -d "${PREFIX}/${NAME%/}" ]; then
+ die "Error: $1 is not a directory."
+ fi
+ elif [ -f "${PREFIX}/$1.age" ]; then
+ NAME="$1"
+ TARGET="$1.age"
+ elif [ -d "${PREFIX}/$1" ]; then
+ NAME="$1/"
+ TARGET="$1/"
+ else
+ die "Error: $1 is not in the password store."
+ fi
+
+ if [ "${DECISION}" = force ]; then
+ printf '%s\n' "Removing ${NAME}"
+ else
+ yesno "Are you sure you would like to delete ${NAME}?"
+ [ "${ANSWER}" = y ] || return 0
+ unset ANSWER
+ fi
+
+ # Perform the deletion
+ scm_begin
+ scm_rm "${TARGET}"
+ scm_commit "Remove ${NAME} from store."
+ rmdir -p "$(dirname "${PREFIX}/${TARGET}")" 2>/dev/null || true
+}
+
+# Edit a secret interactively
+# $1: pass-name
+# EDIT_CMD, EDITOR, VISUAL: editor command
+do_edit() {
+ NAME="${1#/}"
+ TARGET="${PREFIX}/${NAME}.age"
+
+ TMPNAME="${NAME}"
+ while ! [ "${TMPNAME}" = "${TMPNAME#*/}" ]; do
+ TMPNAME="${TMPNAME%%/*}-${TMPNAME#*/}"
+ done
+
+ TMPFILE="$(mktemp -u "${SECURE_TMPDIR}/XXXXXX")-${TMPNAME}.txt"
+
+ if [ -f "${TARGET}" ]; then
+ ACTION="Edit"
+ do_decrypt "${TARGET}" >"${TMPFILE}"
+ OLD_VALUE="$(cat "${TMPFILE}")"
+ else
+ ACTION="Add"
+ OLD_VALUE=
+ fi
+
+ scm_begin
+
+ if [ -z "${EDIT_CMD-}" ]; then
+ if [ -n "${VISUAL-}" ] && ! [ "${TERM:-dumb}" = dumb ]; then
+ EDIT_CMD="${VISUAL}"
+ elif [ -n "${EDITOR-}" ]; then
+ EDIT_CMD="${EDITOR}"
+ else
+ EDIT_CMD="vi"
+ fi
+ fi
+
+ ${EDIT_CMD} "${TMPFILE}"
+
+ if ! [ -f "${TMPFILE}" ]; then
+ printf '%s\n' "New password for ${NAME} not saved."
+ elif [ -n "${OLD_VALUE}" ] \
+ && printf '%s\n' "${OLD_VALUE}" \
+ | diff - "${TMPFILE}" >/dev/null 2>&1
+ then
+ printf '%s\n' "Password for ${NAME} unchanged."
+ rm "${TMPFILE}"
+ else
+ OVERWRITE=once
+ do_encrypt "${NAME}.age" <"${TMPFILE}"
+ scm_add "${NAME}.age"
+ scm_commit "${ACTION} password for ${NAME} using ${EDIT_CMD}"
+ rm "${TMPFILE}"
+ fi
+
+ unset ACTION
+ unset OLD_VALUE
+ unset NAME
+ unset TARGET
+ unset TMPNAME
+ unset TMPFILE
+}
+
+# Encrypt a secret on standard input into a file
+# $1: relative path of the encrypted file
+do_encrypt() {
+ TARGET="$1"
+ set --
+
+ if [ -n "${PASHAGE_RECIPIENTS_FILE-}" ]; then
+ set -- "$@" -R "${PASHAGE_RECIPIENTS_FILE}"
+ fi
+
+ if [ -n "${PASSAGE_RECIPIENTS_FILE-}" ]; then
+ set -- "$@" -R "${PASSAGE_RECIPIENTS_FILE}"
+ fi
+
+ if [ -n "${PASHAGE_RECIPIENTS-}" ]; then
+ for ARG in ${PASHAGE_RECIPIENTS}; do
+ set -- "$@" -r "${ARG}"
+ done
+ unset ARG
+ fi
+
+ if [ -n "${PASSAGE_RECIPIENTS-}" ]; then
+ for ARG in ${PASSAGE_RECIPIENTS}; do
+ set -- "$@" -r "${ARG}"
+ done
+ unset ARG
+ fi
+
+ set_LOCAL_RECIPIENT_FILE "${TARGET}"
+
+ if [ -n "${LOCAL_RECIPIENT_FILE}" ]; then
+ set -- "$@" -R "${LOCAL_RECIPIENT_FILE}"
+ else
+ set -- "$@" -i "${IDENTITIES_FILE}"
+ fi
+
+ unset LOCAL_RECIPIENT_FILE
+
+ if [ -e "${PREFIX}/${TARGET}" ] && ! [ "${OVERWRITE}" = yes ]; then
+ if [ "${OVERWRITE}" = once ]; then
+ OVERWRITE=no
+ else
+ die "Refusing to overwite ${TARGET}"
+ fi
+ fi
+ "${AGE}" -e "$@" -o "${PREFIX}/${TARGET}"
+ unset TARGET
+}
+
+# Generate a new secret
+# $1: secret name
+# $2: new password length
+# $3: new password charset
+# DECISION: whether to ask before overwrite
+# OVERWRITE: whether to re-use existing secret data
+do_generate() {
+ NEW_PASS="$(LC_ALL=C tr -dc "$3" </dev/urandom \
+ | LC_ALL=C dd ibs=1 obs=1 count="$2" 2>/dev/null || true)"
+ NEW_PASS_LEN="$(strlen "${NEW_PASS}")"
+
+ if [ "${NEW_PASS_LEN}" -ne "$2" ]; then
+ die "Error while generating password:" \
+ "${NEW_PASS_LEN}/$2 bytes read"
+ fi
+ unset NEW_PASS_LEN
+
+ scm_begin
+ mkdir -p "$(dirname "${PREFIX}/$1.age")"
+
+ if [ -d "${PREFIX}/$1.age" ]; then
+ die "Cannot replace directory $1.age"
+
+ elif [ -e "${PREFIX}/$1.age" ] && [ "${OVERWRITE}" = yes ]; then
+ printf '%s\n' "Decrypting previous secret for $1"
+ OLD_SECRET="$(do_decrypt "${PREFIX}/$1.age" | tail -n +2)"
+ WIP_FILE="$(mktemp "${PREFIX}/$1-XXXXXXXXX.age")"
+ do_encrypt "${WIP_FILE#"${PREFIX}"/}" <<-EOF
+ ${NEW_PASS}
+ ${OLD_SECRET}
+ EOF
+ mv "${WIP_FILE}" "${PREFIX}/$1.age"
+ VERB="Replace"
+ unset OLD_SECRET
+ unset WIP_FILE
+
+ else
+ if [ -e "${PREFIX}/$1.age" ]; then
+ if ! [ "${DECISION}" = force ]; then
+ yesno "An entry already exists for $1. Overwrite it?"
+ [ "${ANSWER}" = y ] || return 0
+ unset ANSWER
+ fi
+
+ OVERWRITE=once
+ fi
+
+ do_encrypt "$1.age" <<-EOF
+ ${NEW_PASS}
+ EOF
+
+ VERB="Add"
+ fi
+
+ scm_add "${PREFIX}/$1.age"
+ scm_commit "${VERB} generated password for $1"
+
+ unset VERB
+
+ do_show "$1" <<-EOF
+ ${NEW_PASS}
+ EOF
+
+ unset NEW_PASS
+}
+
+# Recursively grep decrypted secrets in current directory
+# $1: current subdirectory name
+# ... grep arguments
+do_grep() {
+ SUBDIR="$1"
+ shift
+
+ glob_exists ./*
+ [ "${ANSWER}" = y ] || return 0
+ unset ANSWER
+
+ for ARG in *; do
+ if [ -d "${ARG}" ]; then
+ ( cd "${ARG}" && do_grep "${SUBDIR}${ARG}/" "$@" )
+ elif [ "${ARG}" = "${ARG%.age}.age" ]; then
+ FOUND="$(do_decrypt "${ARG}" | grep "$@")"
+ if [ -n "${FOUND}" ]; then
+ printf '%s%s\n%s\n' \
+ "${BLUE_TEXT}${SUBDIR}" \
+ "${BOLD_TEXT}${ARG%.age}${NORMAL_TEXT}:" \
+ "${FOUND}"
+ fi
+ fi
+ done
+}
+
+# Add identities to a subdirectory
+# $1: relative subdirectory (may be empty)
+# ... identities
+# DECISION: whether to re-encrypt or not
+do_init() {
+ LOC="${1:-store root}"
+ SUBDIR="${PREFIX}${1:+/}${1%/}"
+ TARGET="${SUBDIR}/.age-recipients"
+ shift
+
+ mkdir -p "${SUBDIR}"
+
+ if ! [ -f "${TARGET}" ] || [ "${OVERWRITE}" = yes ]; then
+ : >|"${TARGET}"
+ fi
+
+ scm_begin
+ printf '%s\n' "$@" >>"${TARGET}"
+ scm_add "${TARGET#"${PREFIX}/"}"
+ if ! [ "${DECISION}" = keep ]; then
+ do_reencrypt_dir "${SUBDIR}"
+ fi
+ scm_commit "Set age recipients at ${LOC}"
+ printf '%s\n' "Password store recipients set at ${LOC}"
+
+ unset LOC
+ unset TARGET
+ unset SUBDIR
+}
+
+# Insert a new secret from standard input
+# $1: entry name
+# ECHO: whether interactive echo is kept
+# OVERWRITE: whether to overwrite without confirmation
+# MULTILINE: whether whole standard input is used
+do_insert() {
+ if [ -e "${PREFIX}/$1.age" ] && [ "${OVERWRITE}" = no ]; then
+ yesno "An entry already exists for $1. Overwrite it?"
+ [ "${ANSWER}" = y ] || return 0
+ unset ANSWER
+ OVERWRITE=once
+ fi
+
+ scm_begin
+ mkdir -p "$(dirname "${PREFIX}/$1.age")"
+
+ if [ "${MULTILINE}" = yes ]; then
+ printf '%s\n' \
+ "Enter contents of $1 and press Ctrl+D when finished:"
+ do_encrypt "$1.age"
+
+ elif [ "${ECHO}" = yes ] \
+ || ! type stty >/dev/null 2>&1 \
+ || ! stty >/dev/null 2>&1
+ then
+ printf 'Enter password for %s: ' "$1"
+ head -n 1 | do_encrypt "$1.age"
+
+ else
+ while true; do
+ printf 'Enter password for %s: ' "$1"
+ stty -echo
+ read -r LINE1
+ printf '\nRetype password for %s: ' "$1"
+ read -r LINE2
+ stty echo
+ printf '\n'
+
+ if [ "${LINE1}" = "${LINE2}" ]; then
+ break
+ else
+ unset LINE1 LINE2
+ echo "Passwords don't match"
+ fi
+ done
+
+ printf '%s\n' "${LINE1}" | do_encrypt "$1.age"
+ unset LINE1 LINE2
+ fi
+
+ scm_add "$1.age"
+ scm_commit "Add given password for $1 to store."
+}
+
+# Display a single directory or entry
+# $1: entry name
+do_list_or_show() {
+ if [ -z "$1" ]; then
+ do_tree "${PREFIX}" "Password Store"
+ elif [ -f "${PREFIX}/$1.age" ]; then
+ do_decrypt "${PREFIX}/$1.age" | do_show "$1"
+ elif [ -d "${PREFIX}/$1" ]; then
+ do_tree "${PREFIX}/$1" "$1"
+ elif [ -f "${PREFIX}/$1.gpg" ]; then
+ do_decrypt_gpg "${PREFIX}/$1.gpg" | do_show "$1"
+ else
+ die "Error: $1 is not in the password store."
+ fi
+}
+
+# Re-encrypts a file or a directory
+# $1: entry name
+# DECISION: whether to ask before re-encryption
+do_reencrypt() {
+ if [ "$1" = "${1%/}/" ]; then
+ if ! [ -d "${PREFIX}/${1%/}" ]; then
+ die "Error: $1 is not in the password store."
+ fi
+ do_reencrypt_dir "${PREFIX}/${1%/}"
+
+ elif [ -f "${PREFIX}/$1.age" ]; then
+ do_reencrypt_file "$1"
+
+ elif [ -d "${PREFIX}/$1" ]; then
+ do_reencrypt_dir "${PREFIX}/$1"
+
+ else
+ die "Error: $1 is not in the password store."
+ fi
+}
+
+# Recursively re-encrypts a directory
+# $1: absolute directory path
+# DECISION: whether to ask before re-encryption
+do_reencrypt_dir() {
+ for ENTRY in "$1"/*; do
+ if [ -d "${ENTRY}" ]; then
+ if ! [ -e "${ENTRY}/.age-recipients" ] \
+ || [ "${DECISION}" = force ]
+ then
+ ( do_reencrypt_dir "${ENTRY}" )
+ fi
+ elif [ "${ENTRY}" = "${ENTRY%.age}.age" ]; then
+ ENTRY="${ENTRY#"${PREFIX}"/}"
+ do_reencrypt_file "${ENTRY%.age}"
+ fi
+ done
+}
+
+# Re-encrypts a file
+# $1: entry name
+# DECISION: whether to ask before re-encryption
+do_reencrypt_file() {
+ if [ "${DECISION}" = interactive ]; then
+ yesno "Re-encrypt $1?"
+ [ "${ANSWER}" = y ] || return 0
+ unset ANSWER
+ fi
+
+ WIP_FILE="$(mktemp "${PREFIX}/$1-XXXXXXXXX.age")"
+ do_decrypt "${PREFIX}/$1.age" \
+ | do_encrypt "${WIP_FILE#"${PREFIX}"/}"
+ mv -f "${WIP_FILE}" "${PREFIX}/$1.age"
+ unset WIP_FILE
+ scm_add "$1.age"
+}
+
+# Display a decrypted secret from standard input
+# $1: title
+# SELECTED_LINE: which line to paste or diplay as qr-code
+# SHOW: how to show the secret
+do_show() {
+ case "${SHOW}" in
+ text)
+ cat
+ ;;
+ clip)
+ tail -n "+${SELECTED_LINE}" \
+ | head -n 1 \
+ | tr -d '\n' \
+ | platform_clip "$1"
+ ;;
+ qrcode)
+ tail -n "+${SELECTED_LINE}" \
+ | head -n 1 \
+ | tr -d '\n' \
+ | platform_qrcode "$1"
+ ;;
+ *)
+ die "Unexpected SHOW value \"${SHOW}\""
+ ;;
+ esac
+}
+
+# Display the tree rooted at the given directory
+# $1: root directory
+# $2: title
+# ...: (optional) grep arguments to filter
+do_tree() {
+ ( cd "$1" && shift && do_tree_cwd "$@" )
+}
+
+# Display the subtree rooted at the current directory
+# $1: title
+# ...: (optional) grep arguments to filter
+do_tree_cwd() {
+ ACC=""
+ PREV=""
+ TITLE="$1"
+ shift
+
+ for ENTRY in *; do
+ [ -e "${ENTRY}" ] || continue
+ ITEM="$(do_tree_item "${ENTRY}" "$@")"
+ [ -z "${ITEM}" ] && continue
+
+ if [ -n "${PREV}" ]; then
+ ACC="$(printf '%s\n' "${PREV}" | do_tree_prefix "${ACC}" "${TREE_T}" "${TREE_I}")"
+ fi
+
+ PREV="${ITEM}"
+ done
+ unset ENTRY
+
+ if [ -n "${PREV}" ]; then
+ ACC="$(printf '%s\n' "${PREV}" | do_tree_prefix "${ACC}" "${TREE_L}" "${TREE__}")"
+ fi
+
+ if [ $# -eq 0 ] || [ -n "${ACC}" ]; then
+ printf '%s\n' "${TITLE}" "${ACC}"
+ fi
+
+ unset ACC
+ unset PREV
+ unset TITLE
+}
+
+# Display a node in a tree
+# $1: item name
+# ...: (optional) grep arguments to filter
+do_tree_item() {
+ ITEM_NAME="$1"
+ shift
+
+ if [ -d "${ITEM_NAME}" ]; then
+ do_tree "${ITEM_NAME}" \
+ "${BLUE_TEXT}${ITEM_NAME}${NORMAL_TEXT}" \
+ "$@"
+ elif [ "${ITEM_NAME%.age}.age" = "${ITEM_NAME}" ]; then
+ if [ $# -eq 0 ] \
+ || printf '%s\n' "${ITEM_NAME%.age}" | grep -q "$@"
+ then
+ printf '%s\n' "${ITEM_NAME%.age}"
+ fi
+ elif [ "${ITEM_NAME%.gpg}.gpg" = "${ITEM_NAME}" ]; then
+ if [ $# -eq 0 ] \
+ || printf '%s\n' "${ITEM_NAME%.age}" | grep -q "$@"
+ then
+ printf '%s\n' \
+ "${RED_TEXT}${ITEM_NAME%.gpg}${NORMAL_TEXT}"
+ fi
+ fi
+
+ unset ITEM_NAME
+}
+
+# Add a tree prefix
+# $1: prefix of the first line
+# $2: prefix of the following lines
+do_tree_prefix() {
+ [ -n "$1" ] && printf '%s\n' "$1"
+ read -r LINE
+ printf '%s%s\n' "$2" "${LINE}"
+ while read -r LINE; do
+ printf '%s%s\n' "$3" "${LINE}"
+ done
+ unset LINE
+}
+
+
+############
+# COMMANDS #
+############
+
+cmd_copy() {
+ ACTION=Copy
+ SCM_ACTION=scm_cp
+ cmd_copy_move "$@"
+}
+
+cmd_copy_move() {
+ PARSE_ERROR=no
+ while [ $# -ge 1 ]; do
+ case "$1" in
+ -f|--force)
+ OVERWRITE=yes
+ shift ;;
+ --)
+ shift
+ break ;;
+ -*)
+ PARSE_ERROR=yes
+ break ;;
+ *)
+ break ;;
+ esac
+ done
+
+ if [ "${PARSE_ERROR}" = yes ] || [ $# -lt 2 ]; then
+ usage1 "[--force,-f] old-path new-path"
+ fi
+ unset PARSE_ERROR
+
+ if [ $# -gt 2 ]; then
+ DEST="$1"
+ shift
+ for ARG in "$@"; do
+ shift
+ set -- "$@" "${DEST}"
+ DEST="${ARG}"
+ done
+
+ for ARG in "$@"; do
+ do_copy_move "${ARG}" "${DEST%/}/"
+ done
+ else
+ do_copy_move "$@"
+ fi
+}
+
+cmd_delete() {
+ check_sneaky_paths "$@"
+
+ PARSE_ERROR=no
+ while [ $# -ge 1 ]; do
+ case "$1" in
+ -f|--force)
+ DECISION=force
+ shift ;;
+ --)
+ shift
+ break ;;
+ -*)
+ PARSE_ERROR=yes
+ break ;;
+ *)
+ break ;;
+ esac
+ done
+
+ if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ]; then
+ usage1 "[--force,-f] pass-name ..."
+ fi
+ unset PARSE_ERROR
+
+ for ARG in "$@"; do
+ do_delete "${ARG}"
+ done
+}
+
+cmd_edit() {
+ [ $# -eq 0 ] && usage1 "pass-name"
+
+ check_sneaky_paths "$@"
+ platform_tmpdir
+
+ for ARG in "$@"; do
+ do_edit "${ARG}"
+ done
+}
+
+cmd_find() {
+ if [ $# -eq 0 ]; then
+ usage1 "[-grepflags] regex"
+ fi
+
+ do_tree "${PREFIX}" "Search pattern: $*" "$@"
+}
+
+cmd_generate() {
+ PARSE_ERROR=no
+ CHARSET="${CHARACTER_SET}"
+ VERB="Add"
+
+ while [ $# -ge 1 ]; do
+ case "$1" in
+ --)
+ shift
+ break ;;
+ -c|--clip)
+ if ! [ "${SHOW}" = text ]; then
+ PARSE_ERROR=yes
+ break
+ fi
+ SHOW=clip
+ shift ;;
+ -f|--force)
+ if [ "${OVERWRITE}" = yes ]; then
+ PARSE_ERROR=yes
+ break
+ fi
+ DECISION=force
+ shift ;;
+ -i|--inplace)
+ if [ "${DECISION}" = force ]; then
+ PARSE_ERROR=yes
+ break
+ fi
+ OVERWRITE=yes
+ shift ;;
+ -n|--no-symbols)
+ CHARSET="${CHARACTER_SET_NO_SYMBOLS}"
+ shift ;;
+ -q|--qrcode)
+ if ! [ "${SHOW}" = text ]; then
+ PARSE_ERROR=yes
+ break
+ fi
+ SHOW=qrcode
+ shift ;;
+ -[cfinq]?*)
+ REST="${1#-?}"
+ ARG="${1%"${REST}"}"
+ shift
+ set -- "${ARG}" "-${REST}" "$@"
+ unset ARG
+ unset REST
+ ;;
+ -*)
+ PARSE_ERROR=yes
+ break ;;
+ *)
+ break ;;
+ esac
+ done
+
+ if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ] || [ $# -gt 2 ] \
+ || [ "${DECISION}-${OVERWRITE}" = force-yes ]
+ then
+ usage1 "[--no-symbols,-n] [--clip,-c | --qrcode,-q]" \
+ "[--in-place,-i | --force,-f] pass-name [pass-length]"
+ fi
+
+ unset PARSE_ERROR
+
+ check_sneaky_path "$1"
+ LENGTH="${2:-${GENERATED_LENGTH}}"
+ [ -n "${LENGTH##*[!0-9]*}" ] \
+ || die "Error: passlength \"${LENGTH}\" must be a number."
+ [ "${LENGTH}" -gt 0 ] \
+ || die "Error: pass-length must be greater than zero."
+
+ do_generate "$1" "${LENGTH}" "${CHARSET}"
+
+ unset CHARSET
+ unset LENGTH
+}
+
+cmd_git() {
+ if [ $# -lt 1 ]; then
+ usage1 'args ...'
+ elif [ -d "${PREFIX}/.git" ]; then
+ platform_tmpdir
+ TMPDIR="${SECURE_TMPDIR}" git -C "${PREFIX}" "$@"
+ elif [ "$1" = init ]; then
+ mkdir -p "${PREFIX}"
+ git -C "${PREFIX}" "$@"
+ scm_begin
+ scm_add '.'
+ scm_commit "Add current contents of password store."
+ cmd_gitconfig
+ elif [ "$1" = clone ]; then
+ git "$@" "${PREFIX}"
+ cmd_gitconfig
+ else
+ die "Error: the password store is not a git repository." \
+ "Try \"${PROGRAM} git init\"."
+ fi
+}
+
+cmd_grep() {
+ [ $# -eq 0 ] && usage1 "[GREP_OPTIONS] search-regex"
+ ( cd "${PREFIX}" && do_grep "" "$@" )
+}
+
+cmd_gitconfig() {
+ [ -d "${PREFIX}/.git" ] || die "The store is not a git repository."
+
+ if ! [ -f "${PREFIX}/.gitattributes" ] ||
+ ! grep -Fqx '*.age diff=age' "${PREFIX}/.gitattributes"
+ then
+ scm_begin
+ printf '*.age diff=age\n' >>"${PREFIX}/.gitattributes"
+ scm_add ".gitattributes"
+ scm_commit "Configure git repository for age file diff."
+ fi
+
+ git -C "${PREFIX}" config --local diff.age.binary true
+ git -C "${PREFIX}" config --local diff.age.textconv \
+ "${AGE} -d -i ${IDENTITIES_FILE}"
+}
+
+cmd_init() {
+ PARSE_ERROR=no
+ SUBDIR=''
+
+ while [ $# -ge 1 ]; do
+ case "$1" in
+ -p|--path)
+ if [ $# -lt 2 ]; then
+ PARSE_ERROR=yes
+ break
+ fi
+
+ SUBDIR="$2"
+ shift 2 ;;
+
+ -p?*)
+ SUBDIR="${1#-p}"
+ shift ;;
+
+ --path=*)
+ SUBDIR="${1#--path=}"
+ shift ;;
+
+ --)
+ shift
+ break ;;
+
+ -*)
+ PARSE_ERROR=yes
+ break ;;
+
+ *)
+ break ;;
+ esac
+ done
+
+ if [ "${PARSE_ERROR}" = yes ] || [ $# -eq 0 ]; then
+ usage1 "[--path=subfolder,-p subfolder] recipient ..."
+ fi
+
+ check_sneaky_path "${SUBDIR}"
+
+ if [ $# -eq 1 ] && [ -z "$1" ]; then
+ do_deinit "${SUBDIR}"
+ else
+ do_init "${SUBDIR}" "$@"
+ fi
+
+ unset PARSE_ERROR
+ unset SUBDIR
+}
+
+cmd_insert() {
+ check_sneaky_paths "$@"
+
+ PARSE_ERROR=no
+ while [ $# -ge 1 ]; do
+ case "$1" in
+ -e|--echo)
+ ECHO=yes
+ shift ;;
+ -f|--force)
+ OVERWRITE=yes
+ shift ;;
+ -m|--multiline)
+ MULTILINE=yes
+ shift ;;
+ --)
+ shift
+ break ;;
+ -e?*)
+ ECHO=yes
+ ARG="-${1#-e}"
+ shift
+ set -- "${ARG}" "$@"
+ unset ARG
+ ;;
+ -f?*)
+ OVERWRITE=yes
+ ARG="-${1#-f}"
+ shift
+ set -- "${ARG}" "$@"
+ unset ARG
+ ;;
+ -m?*)
+ MULTILINE=yes
+ ARG="-${1#-m}"
+ shift
+ set -- "${ARG}" "$@"
+ unset ARG
+ ;;
+ -?*)
+ PARSE_ERROR=yes
+ break ;;
+ *)
+ break ;;
+ esac
+ done
+
+ if [ "${PARSE_ERROR}" = yes ] \
+ || [ $# -lt 1 ] \
+ || [ "${ECHO}${MULTILINE}" = yesyes ]
+ then
+ usage1 "[--echo,-e | --multiline,-m] [--force,-f] pass-name"
+ fi
+ unset PARSE_ERROR
+
+ for ARG in "$@"; do
+ do_insert "${ARG}"
+ done
+ unset ARG
+}
+
+cmd_list_or_show() {
+ COMMAND=show
+ PARSE_ERROR=no
+
+ while [ $# -ge 1 ]; do
+ case "$1" in
+ -c|--clip)
+ SHOW=clip
+ shift ;;
+ -c?*)
+ SELECTED_LINE="${1#-c}"
+ SHOW=clip
+ shift ;;
+ --clip=*)
+ SELECTED_LINE="${1#--clip=}"
+ SHOW=clip
+ shift ;;
+ -q|--qrcode)
+ SHOW=qrcode
+ shift ;;
+ -q?*)
+ SELECTED_LINE="${1#-q}"
+ SHOW=qrcode
+ shift ;;
+ --qrcode=*)
+ SELECTED_LINE="${1#--qrcode=}"
+ SHOW=qrcode
+ shift ;;
+ --)
+ shift
+ break ;;
+ -*)
+ PARSE_ERROR=yes
+ break ;;
+ *)
+ break ;;
+ esac
+ done
+
+ if [ "${PARSE_ERROR}" = yes ]; then
+ usage1 "[ --clip[=line-number], -c[line-number] ]" \
+ "[ --qrcode[=line-number], -q[line-number] ] pass-name"
+ fi
+
+ check_sneaky_paths "$@"
+
+ if [ $# -eq 0 ]; then
+ do_list_or_show ""
+ else
+ for ARG in "$@"; do
+ do_list_or_show "${ARG}"
+ done
+ fi
+
+ unset ARG
+ unset PARSING
+}
+
+cmd_move() {
+ ACTION=Move
+ SCM_ACTION=scm_mv
+ cmd_copy_move "$@"
+}
+
+cmd_version() {
+ cat <<-EOF
+ ==============================================
+ = pashage: age-backed POSIX password manager =
+ = =
+ = v0.1.0 =
+ = =
+ = Natasha Kerensikova =
+ = =
+ = Based on: =
+ = password-store by Jason A. Donenfeld =
+ = passage by Filippo Valsorda =
+ = pash by Dylan Araps =
+ ==============================================
+ EOF
+}
diff --git a/src/platform-freebsd.sh b/src/platform-freebsd.sh
@@ -0,0 +1,129 @@
+#!/bin/sh
+# pashage - age-backed POSIX password manager
+# Copyright (C) 2024 Natasha Kerensikova
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+##########################
+# PLATFORM-SPECIFIC CODE #
+##########################
+
+# Decode base-64 standard input into binary standard output
+platform_b64_decode() {
+ openssl base64 -d
+}
+
+# Encode binary standard input into base-64 standard output
+platform_b64_encode() {
+ openssl base64
+}
+
+# Temporarily paste standard input into clipboard
+# $1: title
+platform_clip() {
+ [ -n "${SECURE_TMPDIR-}" ] && die "Unexpected collision on trap EXIT"
+ CLIP_BACKUP="$(platform_clip_paste | platform_b64_encode)"
+ platform_clip_copy
+ trap 'printf '\''%s\n'\'' "${CLIP_BACKUP}" | platform_b64_decode | platform_clip_copy' EXIT
+ printf '%s\n' \
+ "Copied $1 to clipboard. Will clear in ${CLIP_TIME} seconds."
+ echo "Use Ctrl-C to clear the clipboard earlier."
+ sleep "${CLIP_TIME}"
+ printf '%s\n' "${CLIP_BACKUP}" | platform_b64_decode \
+ | platform_clip_copy
+ trap - EXIT
+ unset CLIP_BACKUP
+}
+
+# Copy standard input into clipboard
+platform_clip_copy() {
+ if [ -n "${WAYLAND_DISPLAY-}" ] && type wl-copy >/dev/null; then
+ checked wl-copy 2>/deb/null
+ elif [ -n "${DISPLAY-}" ] && type xclip >/dev/null; then
+ checked xclip -selection "${X_SELECTION}"
+ else
+ die "Error: No X11 or Wayland display detected"
+ fi
+}
+
+# Paste clipboard into standard output, ignoring failures
+platform_clip_paste() {
+ if [ -n "${WAYLAND_DISPLAY-}" ] && type wl-paste >/dev/null; then
+ wl-paste -n 2>/deb/null || true
+ elif [ -n "${DISPLAY-}" ] && type xclip >/dev/null; then
+ xclip -o -selection "${X_SELECTION}" || true
+ else
+ die "Error: No X11 or Wayland display detected"
+ fi
+}
+
+# Display standard input as a QR-code
+# $1: title
+platform_qrcode() {
+ type qrencode >/dev/null || die "qrencode is not available"
+
+ if [ -n "${DISPLAY-}" ] || [ -n "${WAYLAND_DISPLAY-}" ]; then
+ if type feh >/dev/null; then
+ checked qrencode --size 10 -o - \
+ | checked feh -x --title "pashage: $1" \
+ -g +200+200 -
+ return 0
+ elif type gm >/dev/null; then
+ checked qrencode --size 10 -o - \
+ | checked gm display --title "pashage: $1" \
+ -g +200+200 -
+ return 0
+ elif type display >/dev/null; then
+ checked qrencode --size 10 -o - \
+ | checked display --title "pashage: $1" \
+ -g +200+200 -
+ return 0
+ fi
+ fi
+
+ qrencode -t utf8
+}
+
+# Create a (somewhat) secuture emporary directory
+platform_tmpdir() {
+ [ -n "${SECURE_TMPDIR-}" ] && return 0
+ TEMPLATE="${PROGRAM##*/}.XXXXXXXXXXXXX"
+ if [ -d /dev/shm ] \
+ && [ -w /dev/shm ] \
+ && [ -x /dev/shm ]
+ then
+ SECURE_TMPDIR="$(mktemp -d "/dev/shm/${TEMPLATE}")"
+ trap platform_tmpdir_rm EXIT
+ else
+ SECURE_TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/${TEMPLATE}")"
+ trap platform_tmpdir_shred EXIT
+ fi
+ unset TEMPLATE
+}
+
+# Remove a ramdisk-based tmpdir
+platform_tmpdir_rm() {
+ [ -z "${SECURE_TMPDIR-}" ] && return 0
+ rm -rf "${SECURE_TMPDIR}"
+ unset SECURE_TMPDIR
+}
+
+# Remove a presumed disk-based tmpdir
+platform_tmpdir_shred() {
+ [ -z "${SECURE_TMPDIR-}" ] && return 0
+ find "${SECURE_TMPDIR}" -type f -exec rm -P -f '{}' +
+ rm -rf "${SECURE_TMPDIR}"
+ unset SECURE_TMPDIR
+}
diff --git a/src/run.sh b/src/run.sh
@@ -0,0 +1,120 @@
+#!/bin/sh
+# pashage - age-backed POSIX password manager
+# Copyright (C) 2024 Natasha Kerensikova
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+# Set pipefail if it works in a subshell, disregard if unsupported
+# shellcheck disable=SC3040
+(set -o pipefail 2> /dev/null) && set -o pipefail
+set -Cue
+set +x
+
+#################
+# CONFIGURATION #
+#################
+
+### Pashage/passage specific configuration
+AGE="${PASHAGE_AGE:-${PASSAGE_AGE:-age}}"
+IDENTITIES_FILE="${PASHAGE_IDENTITIES_FILE:-${PASSAGE_IDENTITIES_FILE:-${HOME}/.passage/identities}}"
+PREFIX="${PASHAGE_DIR:-${PASSAGE_DIR:-${PASSWORD_STORE_DIR:-${HOME}/.passage/store}}}"
+
+### Configuration inherited from password-store
+CHARACTER_SET="${PASSWORD_STORE_CHARACTER_SET:-[:punct:][:alnum:]}"
+CHARACTER_SET_NO_SYMBOLS="${PASSWORD_STORE_CHARACTER_SET_NO_SYMBOLS:-[:alnum:]}"
+CLIP_TIME="${PASSWORD_STORE_CLIP_TIME:-45}"
+GENERATED_LENGTH="${PASSWORD_STORE_GENERATED_LENGTH:-25}"
+X_SELECTION="${PASSWORD_STORE_X_SELECTION:-clipboard}"
+
+### UTF-8 or ASCII tree
+TREE__=' '
+TREE_I='| '
+TREE_T='|- '
+TREE_L='`- '
+if [ -n "${LC_CTYPE-}" ] && ! [ "${LC_CTYPE}" = "${LC_CTYPE#*UTF}" ]; then
+ TREE_I='│ '
+ TREE_T='├─ '
+ TREE_L='└─ '
+fi
+
+### Terminal color support
+BOLD_TEXT=""
+NORMAL_TEXT=""
+RED_TEXT=""
+BLUE_TEXT=""
+if [ -n "${CLICOLOR-}" ]; then
+ BOLD_TEXT="$(printf '\033[1m')"
+ NORMAL_TEXT="$(printf '\033[0m')"
+ RED_TEXT="$(printf '\033[31m')"
+ BLUE_TEXT="$(printf '\033[34m')"
+fi
+
+### Git environment clean-up
+unset GIT_DIR
+unset GIT_WORK_TREE
+unset GIT_NAMESPACE
+unset GIT_INDEX_FILE
+unset GIT_INDEX_VERSION
+unset GIT_OBJECT_DIRECTORY
+unset GIT_COMMON_DIR
+export GIT_CEILING_DIRECTORIES="${PREFIX}/.."
+
+### Default state
+DECISION=default #default|force|keep|interactive
+ECHO=no
+MULTILINE=no
+OVERWRITE=no
+SELECTED_LINE=1
+SHOW=text
+
+###########
+# IMPORTS #
+###########
+
+: "${PASHAGE_SRC_DIR:=$(dirname "$0")}"
+PLATFORM="$(uname | cut -d _ -f 1 | tr '[:upper:]' '[:lower:]')"
+. "${PASHAGE_SRC_DIR}/platform-${PLATFORM}.sh"
+. "${PASHAGE_SRC_DIR}/pashage.sh"
+
+############
+# DISPATCH #
+############
+
+PROGRAM="$0"
+COMMAND="${1-}"
+umask "${PASSWORD_STORE_UMASK:-077}"
+
+case "${COMMAND}" in
+ copy|cp) shift; cmd_copy "$@" ;;
+ delete) shift; cmd_delete "$@" ;;
+ edit) shift; cmd_edit "$@" ;;
+ find) shift; cmd_find "$@" ;;
+ gen) shift; cmd_generate "$@" ;;
+ generate) shift; cmd_generate "$@" ;;
+ git) shift; cmd_git "$@" ;;
+ gitconfig) shift; cmd_gitconfig ;;
+ grep) shift; cmd_grep "$@" ;;
+ init) shift; cmd_init "$@" ;;
+ insert) shift; cmd_insert "$@" ;;
+ list) shift; cmd_list_or_show "$@" ;;
+ ls) shift; cmd_list_or_show "$@" ;;
+ move|mv) shift; cmd_move "$@" ;;
+ remove) shift; cmd_delete "$@" ;;
+ rm) shift; cmd_delete "$@" ;;
+ show) shift; cmd_list_or_show "$@" ;;
+ --version) shift; cmd_version ;;
+ version) shift; cmd_version ;;
+ *) cmd_list_or_show "$@" ;;
+esac