pashage

Yet Another Opinionated Re-engineering of the Unix Password Store
git clone https://git.instinctive.eu/pashage.git
Log | Files | Refs | README | LICENSE

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:
AARCHITECTURE.md | 48++++++++++++++++++++++++++++++++++++++++++++++++
ALICENSE | 355+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AMakefile | 49+++++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aspec/action_spec.sh | 1633+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aspec/command_spec.sh | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aspec/internal_spec.sh | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aspec/spec_helper.sh | 24++++++++++++++++++++++++
Aspec/support/bin/@basename | 3+++
Aspec/support/bin/@cat | 3+++
Aspec/support/bin/@diff | 3+++
Aspec/support/bin/@dirname | 3+++
Aspec/support/bin/@grep | 3+++
Aspec/support/bin/@head | 3+++
Aspec/support/bin/@mkdir | 3+++
Aspec/support/bin/@mktemp | 3+++
Aspec/support/bin/@mv | 3+++
Aspec/support/bin/@rm | 3+++
Aspec/support/bin/@sed | 3+++
Aspec/support/bin/@tail | 3+++
Aspec/support/bin/@tr | 3+++
Aspec/usage_spec.sh | 1372+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/pashage.sh | 1393+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/platform-freebsd.sh | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/run.sh | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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