├── .bee └── plugins │ ├── gh │ └── gh.bash │ └── release │ ├── plugin.json │ └── release.bash ├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .gitmodules ├── Beefile ├── Beefile.lock ├── CHANGELOG.md ├── DEPENDENCIES.md ├── LICENSE.txt ├── README.md ├── docker ├── alpine │ └── Dockerfile ├── archlinux │ └── Dockerfile ├── debian │ └── Dockerfile ├── docker-entrypoint.sh ├── fedora │ └── Dockerfile ├── opensuse-tumbleweed │ └── Dockerfile └── ubuntu │ └── Dockerfile ├── examples └── pw.conf ├── install ├── plugins ├── gpg │ ├── _gpg_decrypt │ ├── _gpg_encrypt │ ├── _parse_details │ ├── _require │ ├── add │ ├── edit │ ├── fzf_preview │ ├── get │ ├── hook │ ├── init │ ├── keychain_password │ ├── lock │ ├── ls │ ├── open │ ├── rm │ ├── show │ └── unlock ├── keepassxc │ ├── _keepassxc_cli_with_options │ ├── _require │ ├── add │ ├── edit │ ├── fzf_preview │ ├── get │ ├── hook │ ├── init │ ├── keychain_password │ ├── lock │ ├── ls │ ├── open │ ├── rm │ ├── show │ └── unlock ├── macos_keychain │ ├── _parse_details │ ├── add │ ├── edit │ ├── fzf_preview │ ├── get │ ├── hook │ ├── init │ ├── keychain_password │ ├── lock │ ├── ls │ ├── open │ ├── rm │ ├── show │ └── unlock └── parse_options ├── readme ├── pw-fzf.png └── pw-keychains.png ├── src ├── copy ├── migrations │ ├── pwconf-12.0.0 │ ├── pwrc-10.0.0 │ ├── pwrc-11.0.0 │ └── pwrc-9.0.0 ├── paste └── pw ├── test ├── coverage ├── fixtures │ ├── plugins │ │ ├── collision │ │ │ └── hook │ │ └── test │ │ │ ├── add │ │ │ ├── edit │ │ │ ├── fzf_preview │ │ │ ├── get │ │ │ ├── hook │ │ │ ├── init │ │ │ ├── keychain_password │ │ │ ├── lock │ │ │ ├── ls │ │ │ ├── open │ │ │ ├── rm │ │ │ ├── show │ │ │ └── unlock │ ├── pw_test_1.key │ └── pw_test_2.key ├── gpg-setup-teardown.bats ├── gpg.bash ├── gpg.bats ├── keepassxc-setup-teardown.bats ├── keepassxc.bash ├── keepassxc.bats ├── macos_keychain-setup-teardown.bats ├── macos_keychain.bash ├── macos_keychain.bats ├── pw-clip.bats ├── pw-keychain.bats ├── pw-migrations.bats ├── pw-plugins.bats ├── pw.bash ├── pw.bats ├── run ├── shellcheck └── test-helper.bash └── version.txt /.bee/plugins/gh/gh.bash: -------------------------------------------------------------------------------- 1 | gh::run_rerun_retry() { 2 | local run_id 3 | echo -n "🤖 Fetching latest run: " 4 | run_id="$(gh run list --limit 1 --json databaseId --jq '.[0].databaseId')" 5 | echo "${run_id}" 6 | 7 | for i in {1..5}; do 8 | echo "🔁 Attempt $i: Watching run ${run_id}..." 9 | if gh run watch --exit-status "${run_id}"; then 10 | echo "✅ Success on attempt $i!" 11 | return 0 12 | else 13 | echo "❌ Run failed. Retrying failed jobs..." 14 | sleep 10 15 | gh run rerun "${run_id}" --failed 16 | fi 17 | done 18 | 19 | echo "🤖 Giving up after 5 attempts!" 20 | return 1 21 | } 22 | -------------------------------------------------------------------------------- /.bee/plugins/release/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": [ 3 | "changelog", 4 | "github" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.bee/plugins/release/release.bash: -------------------------------------------------------------------------------- 1 | release::publish() { 2 | changelog::merge 3 | git add . 4 | local version 5 | version="$(semver::read)" 6 | git commit -m "Release ${version}" 7 | git push 8 | git tag "${version}" 9 | git push --tags 10 | github::create_release 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset=utf-8 5 | end_of_line=lf 6 | trim_trailing_whitespace=true 7 | insert_final_newline=true 8 | indent_style=space 9 | indent_size=2 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main, develop] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | shellcheck: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: "Install dependencies" 14 | run: sudo apt-get update && sudo apt-get install -y --fix-missing shellcheck 15 | 16 | - name: "Checkout" 17 | uses: actions/checkout@v4 18 | with: 19 | submodules: recursive 20 | 21 | - name: "shellcheck" 22 | run: test/shellcheck 23 | 24 | linux: 25 | runs-on: ubuntu-latest 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | platform: 30 | - alpine 31 | - archlinux 32 | - debian 33 | - fedora 34 | - opensuse-tumbleweed 35 | - ubuntu 36 | steps: 37 | - name: "Checkout" 38 | uses: actions/checkout@v4 39 | with: 40 | submodules: recursive 41 | 42 | - name: "Run tests" 43 | run: test/run -p ${{ matrix.platform }} 44 | 45 | macos: 46 | runs-on: macos-latest 47 | steps: 48 | - name: "Install dependencies" 49 | run: | 50 | brew install bash fzf parallel 51 | brew install --cask keepassxc 52 | 53 | - name: "Checkout" 54 | uses: actions/checkout@v4 55 | with: 56 | submodules: recursive 57 | 58 | - name: "Run tests" 59 | run: test/run -c no-shellcheck -j $(sysctl -n hw.logicalcpu) 60 | 61 | coverage: 62 | runs-on: macos-latest 63 | steps: 64 | - name: "Install dependencies" 65 | run: | 66 | brew install bash fzf kcov parallel 67 | brew install --cask keepassxc 68 | 69 | - name: "Checkout" 70 | uses: actions/checkout@v4 71 | with: 72 | submodules: recursive 73 | 74 | - name: "Test coverage" 75 | run: test/coverage --jobs $(sysctl -n hw.logicalcpu) test 76 | 77 | - name: "Upload coverage report" 78 | uses: actions/upload-artifact@v4 79 | with: 80 | name: ${{ github.event.repository.name }} coverage report 81 | path: coverage 82 | 83 | coveralls: 84 | needs: coverage 85 | runs-on: macos-latest 86 | steps: 87 | - name: "Checkout" 88 | uses: actions/checkout@v4 89 | with: 90 | submodules: recursive 91 | 92 | - name: "Download coverage report" 93 | uses: actions/download-artifact@v4 94 | with: 95 | name: ${{ github.event.repository.name }} coverage report 96 | path: coverage 97 | 98 | - name: "Generate lcov" 99 | run: | 100 | dotnet tool install -g dotnet-reportgenerator-globaltool 101 | reportgenerator -reports:"coverage/**/cobertura.xml" -targetdir:"coverage" -reporttypes:"lcov" 102 | 103 | - name: Coveralls 104 | uses: coverallsapp/github-action@v2 105 | with: 106 | github-token: ${{ secrets.GITHUB_TOKEN }} 107 | path-to-lcov: "coverage/lcov.info" 108 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | /CHANGES.md 4 | /coverage 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test/bats"] 2 | path = test/bats 3 | url = https://github.com/bats-core/bats-core.git 4 | [submodule "test/test_helper/bats-support"] 5 | path = test/test_helper/bats-support 6 | url = https://github.com/bats-core/bats-support.git 7 | [submodule "test/test_helper/bats-assert"] 8 | path = test/test_helper/bats-assert 9 | url = https://github.com/bats-core/bats-assert.git 10 | [submodule "test/test_helper/bats-file"] 11 | path = test/test_helper/bats-file 12 | url = https://github.com/bats-core/bats-file.git 13 | -------------------------------------------------------------------------------- /Beefile: -------------------------------------------------------------------------------- 1 | BEE_PROJECT=pw 2 | BEE_VERSION=1.4.0 3 | BEE_PLUGINS_PATHS=("${BEE_RESOURCES}/plugins") 4 | BEE_PLUGINS=(gh release) 5 | 6 | bee::secrets() { 7 | [[ "$1" != "release" ]] && return 8 | [[ ! -f ~/.bee/secrets.bash ]] || source ~/.bee/secrets.bash 9 | } 10 | 11 | GITHUB_REPO="sschmid/pw-terminal-password-manager" 12 | CHANGELOG_URL="https://github.com/${GITHUB_REPO}" 13 | -------------------------------------------------------------------------------- /Beefile.lock: -------------------------------------------------------------------------------- 1 | └── release:local 2 | ├── changelog:3.1.0 3 | │ └── semver:2.0.1 4 | └── github:2.0.0 5 | └── semver:2.0.1 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [12.0.0] - 2025-05-25 10 | ### Upgrading to pw 12.0.0 11 | The `pw` config file moved to `$XDG_CONFIG_HOME/pw/pw.conf` and the format has 12 | changed to an INI-like format. `pw` can automatically move and migrate your 13 | config to the new format: 14 | 15 | ```ini 16 | [general] 17 | password_length = 35 18 | password_character_class = [:graph:] 19 | clipboard_clear_time = 45 20 | 21 | # pbcopy/pbpaste, xclip, xsel, and wl-copy/wl-paste are supported by default. 22 | # If you're using a different clipboard manager, you can specify it here: 23 | # copy = my-copy-command 24 | # paste = my-paste-command 25 | 26 | [plugins] 27 | plugin = $PW_HOME/plugins/gpg 28 | plugin = $PW_HOME/plugins/keepassxc 29 | plugin = $PW_HOME/plugins/macos_keychain 30 | 31 | [keychains] 32 | # Put your keychains here for easy access 33 | # keychain = $HOME/path/to/your/gpg/vault 34 | # keychain = $HOME/path/to/your/keychain.kdbx 35 | # keychain = $HOME/path/to/your/keychain.keychain-db 36 | ``` 37 | 38 | `pw` now installs to `/opt/pw` instead of `/usr/local/opt/pw`. No action is 39 | required for this change. If you want to migrate to that new location uninstall 40 | the old version and install the new one. 41 | 42 | ### Added 43 | - Add `pw` config migration 44 | - Add stricter config parsing 45 | - Add support for custom copy/paste 46 | 47 | ### Fixed 48 | - Fix config parsing failed when containing quotes 49 | 50 | ### Changed 51 | - Move config to `$XDG_CONFIG_HOME/pw/pw.conf` 52 | - Change `pw.conf` format to follow INI-style conventions 53 | - Install `pw` to `/opt/pw` instead of `/usr/local/opt/pw` 54 | 55 | ## [11.0.0] - 2025-05-16 56 | ### Upgrading to pw 11.0.0 57 | `pw` now respects the `$XDG_CONFIG_HOME` environment variable. Your existing `~/.pwrc` 58 | file will be moved to the new location at `~/.config/pw/config`. If you have 59 | `$XDG_CONFIG_HOME` set, the config file will be moved to `$XDG_CONFIG_HOME/pw/config`. 60 | You can specify a custom config file with `pw -c `. 61 | 62 | ### Added 63 | - Add `.pwrc` migration 64 | - Print supported clipboard tools when no clipboard tool is found 65 | 66 | ### Changed 67 | - Use `$XDG_CONFIG_HOME` and fallback to `~/.config` for config path 68 | - Move `~/.pwrc` to `~/.config/pw/config` 69 | 70 | ## [10.1.0] - 2025-05-10 71 | ### Changed 72 | - Increase chunk_size for faster password generation 73 | - Detect extension based on first `.` 74 | 75 | ### Other 76 | - Add Dockerfile for openSUSE Tumbleweed 77 | - Add `-m` option to run manual tests 78 | 79 | ## [10.0.0] - 2024-11-10 80 | ### Upgrading to pw 10.0.0 81 | The `.pwrc` format has changed to an INI-like format. `pw` can automatically 82 | migrate your `.pwrc` to the new format: 83 | 84 | ```ini 85 | [config] 86 | password_length = 35 87 | password_character_class = [:graph:] 88 | clipboard_clear_time = 45 89 | 90 | [plugins] 91 | $PW_HOME/plugins/gpg 92 | $PW_HOME/plugins/keepassxc 93 | $PW_HOME/plugins/macos_keychain 94 | 95 | [keychains] 96 | secrets.keychain-db 97 | ~/path/to/myproject.keychain-db 98 | ~/path/to/keepassxc.kdbx 99 | ~/path/to/gpg/secrets 100 | ``` 101 | 102 | The new format includes `config`, `plugins`, and `keychains` sections. The 103 | `config` section includes `password_length`, `password_character_class`, and 104 | `clipboard_clear_time`. You can still override these values with the environment 105 | variables `PW_GEN_LENGTH`, `PW_GEN_CLASS`, and `PW_CLIP_TIME` respectively. 106 | 107 | Additionally, with the new plugin section, you now have fine-grained control 108 | over the plugins you want to use. You can specify your own plugins in addition 109 | to the default plugins provided by `pw`. 110 | 111 | ### Added 112 | - Set `SHELL` with `type -p bash` 113 | 114 | ### Changed 115 | - Change `pwrc` to INI-like format including `config`, `plugins`, and `keychains` sections 116 | - Move plugins out of `src` folder 117 | 118 | ### Other 119 | - Run tests and coverage in parallel 120 | 121 | ## [9.2.3] - 2024-10-31 122 | ### Added 123 | - Make `pw` work on Arch btw 124 | - Improve entropy in password generation by reducing read size 125 | 126 | ### Fixed 127 | - Fix character classes for BusyBox `tr` to avoid using `sed` 128 | 129 | ## [9.2.2] - 2024-10-27 130 | ### Added 131 | - `keepassxc`: Display error messages prominently to avoid them being missed 132 | 133 | ### Fixed 134 | - Fix fzf preview in docker container 135 | 136 | ## [9.2.1] - 2024-10-27 137 | ### Fixed 138 | - Fix fzf yank to use new copy paste 139 | - Discard `Xvfb` output when running docker container 140 | 141 | ## [9.2.0] - 2024-10-26 142 | ### Added 143 | - Make `pw` work on Alpine Linux and Ubuntu 144 | - Add Dockerfiles for building and testing `pw` on Alpine Linux and Ubuntu 145 | - Add support for clipboard tools: `xclip`, `xsel`, `wl-clipboard` 146 | - Faster copy to clipboard 147 | 148 | ## [9.1.1] - 2024-10-19 149 | ### Added 150 | - `macos_keychain`: Remove unnecessary password prompt for show command 151 | - `macos_keychain`: Remove unnecessary password prompt for fzf preview 152 | 153 | ## [9.1.0] - 2024-10-19 154 | ### Upgrading to pw 9.1.0 155 | In order to increase security, the `macos_keychain` plugin won't automatically 156 | add the `security` command to the keychain's access control list anymore. 157 | 158 | Typically, when accessing keychain items added by other applications, the user 159 | is prompted to `allow` or `always allow` access. However, when keychain entries are 160 | added using the `security` command itself, the command is automatically granted 161 | access to those items without future prompts. This can be a security risk, because 162 | other applications can use the `security` command to access these items without 163 | prompting the user. 164 | 165 | `pw` changes this behaviour to reduce security risks by not automatically adding 166 | the `security` command to the keychain's access control list. This way you have 167 | full control over which applications can access your keychain items and decide 168 | whether to allow or deny access. 169 | 170 | If you want to add the `security` command to the keychain's access control list 171 | by default, you can set the environment variable 172 | `PW_MACOS_KEYCHAIN_ACCESS_CONTROL` to `always-allow`: 173 | 174 | ```bash 175 | export PW_MACOS_KEYCHAIN_ACCESS_CONTROL="always-allow" 176 | ``` 177 | 178 | ### Added 179 | - Add `PW_MACOS_KEYCHAIN_ACCESS_CONTROL` to control access control list behavior 180 | - Add "Security Considerations" section to readme 181 | 182 | ### Changed 183 | - `macos_keychain`: Don't add `security` command to access control list by default 184 | - `macos_keychain`: Don't unlock keychain for fzf preview 185 | - `gpg`: Don't unlock keychain for fzf preview 186 | 187 | ## [9.0.0] - 2024-10-17 188 | ### Upgrading to pw 9.0.0 189 | In order to increase security, plugins are no longer sourced. Instead they are 190 | executed as separate scripts. This change also makes it easier to write and 191 | maintain plugins. Please migrate your custom plugins to the new format. 192 | 193 | Additionally, `.pwrc` is also no longer sourced and has been replaced by a 194 | new format. `pw` can automatically migrate your `.pwrc` to the new format: 195 | 196 | ```bash 197 | ~/path/to/myproject.keychain-db 198 | ~/path/to/keepassxc.kdbx 199 | ~/path/to/gpg/secrets 200 | ``` 201 | 202 | ### Added 203 | - Add `.pwrc` migration 204 | - Script optimizations 205 | - Explicit variable declarations and strict scoping 206 | 207 | ### Changed 208 | - Plugins are no longer sourced 209 | - Plugins functions have been extracted to separate files 210 | - `.pwrc` is no longer sourced and has a new format 211 | - `.pwrc` is no longer created by default and is optional 212 | 213 | ### Removed 214 | - Remove redirecting from tty 215 | - Delete sample plugin 216 | 217 | ## [8.2.1] - 2024-10-08 218 | ### Fixed 219 | - Fix generated password being empty 220 | 221 | ## [8.2.0] - 2024-10-08 222 | ### Added 223 | - Add `pw show` to show details 224 | - Add fzf shortcut `CTRL-Y` to copy (or print) details 225 | - Add fzf shortcut `?` to toggle preview and make preview hidden by default 226 | - Sort discovered keychains 227 | - Display error message when no keychain was set 228 | - `macos_keychain`: Show name, account, url and notes in fzf preview 229 | - `keepassxc`: Enable yubikey and key-file fzf preview 230 | - `gpg`: Add name to fzf preview 231 | 232 | ### Fixed 233 | - Fix password prompt did trim whitespace 234 | - Support multiline notes when adding new entry interactively 235 | 236 | ### Changed 237 | - Sort using users default `LC_ALL` 238 | 239 | ### Removed 240 | - Remove login.keychain-db as default keychain 241 | 242 | ## [8.1.0] - 2024-09-29 243 | ### Added 244 | - Refactor password generation to ensure desired length in low entropy environments 245 | - `macos_keychain`: Add support for displaying multiline comments in fzf preview 246 | 247 | ### Fixed 248 | - `gpg`: Fix edit removes account, url and notes 249 | - `gpg`: Fix only printing first line of notes in fzf preview 250 | 251 | ## [8.0.0] - 2024-09-27 252 | ### Added 253 | - Add `gpg` plugin 254 | - Add support for adding url and notes for all plugins with `pw add [] [] [] []` 255 | - Add `fzf` preview to all plugins when selecting an entry with `pw` 256 | - `keepassxc`: Add support for creating items in groups 257 | - `keepassxc`: Add key-file support 258 | - `keepassxc`: Add YubiKey support 259 | - Add automatic keychain discovery 260 | - Add adding new entries interactively with `pw add` 261 | - Accept `PW_GEN_LENGTH` and `PW_GEN_CLASS` as arguments for `pw gen [] []` 262 | - Accept combined `pw` options like `pw -pk my-keychain` 263 | - Accept lower and upper case reply when asking to delete item 264 | - Run hooks in a subshell to avoid affecting the current shell 265 | - Print all matching plugins when multiple plugins match file type or file extension 266 | 267 | ### Fixed 268 | - `keepassxc`: Fix not showing password prompt with pw unlock 269 | 270 | ### Changed 271 | - Rename hook functions to `pw::register` and `pw::register_with_extension` 272 | - Plugins use `PW_NAME`, `PW_ACCOUNT`, `PW_URL` and `PW_NOTES` instead of positional arguments 273 | 274 | ### Removed 275 | - Remove `pw --help` 276 | 277 | ### Other 278 | - Add test coverage with `kcov` 279 | 280 | ## [7.0.0] - 2024-09-09 281 | ### Added 282 | - Add shorter bash version check 283 | - Add optional `fzf` format to `ls` 284 | - Add more tests 285 | - Add `_skip_if_github_action()` for tests 286 | - Add uninstall instructions. Closes #5 287 | 288 | ### Fixed 289 | - Support leading and trailing spaces in entry name and account 290 | - Clear clipboard after generating password 291 | - `macos_keychain`: Fix getting entry with empty name or account 292 | - `macos_keychain`: Fix removing entry with empty name or account 293 | - `macos_keychain`: Fix `ls` splitting on `=` 294 | - `macos_keychain`: Accept keychain password from stdin to init 295 | - `macos_keychain`: Accept keychain password from stdin to unlock 296 | 297 | ### Changed 298 | - Drastically simplified plugin architecture and tests 299 | - Migrate `macos_keychain` and tests to new plugin structure 300 | - Migrate `keepassxc` and tests to new plugin structure 301 | 302 | ## [6.1.2] - 2024-05-18 303 | ### Fixed 304 | - `macos_keychain:` Fix not opening keychains with absolute path 305 | 306 | ## [6.1.1] - 2024-05-17 307 | ### Fixed 308 | - `keepassxc:` Exclude `Recycle Bin/` folder, not entry 309 | 310 | ### GitHub Actions 311 | - Upgrade to `actions/checkout@v4` 312 | - Install `shellcheck` instead of using docker image 313 | 314 | ## [6.1.0] - 2024-05-17 315 | ### Added 316 | - Add sample plugin `src/plugins/sample` to demonstrate how to create a plugin 317 | 318 | ### Changed 319 | - `keepassxc`: Sort entries in `ls` 320 | - `keepassxc`: Exclude `Recycle Bin` from `ls` 321 | - `keepassxc`: Show error message when providing wrong database password 322 | - Extract `pw::clip_and_forget` from plugins 323 | - Extract `pw::prompt_password` from plugins 324 | - Print errors to `STDERR` instead of `STDOUT` 325 | 326 | ## [6.0.0] - 2024-05-13 327 | ### Added 328 | - Introduce plugin architecture to support different password managers 329 | - Add plugin for `macOS-keychain` and `keepassxc-cli` 330 | - Add support for choosing from multiple keychains 331 | - Update bats and add bats-file submodule 332 | 333 | ### Changed 334 | - Change `pw init` to accept keychain name as argument 335 | - Increase entry name padding in `pw ls` 336 | - Don't automatically append `.keychain` 337 | 338 | ### Removed 339 | - Remove `-a` option to search in all user keychains 340 | 341 | ## [5.1.0] - 2023-03-14 342 | ### Added 343 | - Clear password from clipboard after 45 seconds 344 | 345 | ## [5.0.0] - 2022-10-31 346 | ### Changed 347 | - Change `help` command to option `--help` 348 | 349 | ## [4.5.1] - 2022-10-11 350 | ### Added 351 | - Display minimum bash version error message 352 | - Upgrade to bee 1.4.0 353 | 354 | ## [4.5.0] - 2022-06-03 355 | ### Added 356 | - Add pw gen 357 | 358 | ### Fixed 359 | - Fix generated passwords end with `)` 360 | 361 | ## [4.4.0] - 2022-03-01 362 | ### Added 363 | - Add PW_GEN_LENGTH (default: 35) 364 | 365 | ## [4.3.0] - 2022-01-18 366 | ### Added 367 | - Add support for spaces in entry names, accounts and keychains 368 | 369 | ## [4.2.0] - 2022-01-11 370 | ### Added 371 | - Add custom fzf prompt 372 | 373 | ## [4.1.0] - 2022-01-03 374 | ### Added 375 | - Add fzf to pw edit 376 | - Add support for account only entries 377 | 378 | ### Fixed 379 | - Copy password without trailing newline 380 | - Fix copying non-existent entry did not fail 381 | 382 | ## [4.0.0] - 2021-12-21 383 | ### Added 384 | - Add pw edit 385 | - Add tests 386 | - Add GitHub action to run tests 387 | 388 | ### Changed 389 | - Copy password by default instead of printing 390 | 391 | ## [3.0.0] - 2021-11-11 392 | ### Added 393 | - Print keychain in pw rm 394 | 395 | ### Changed 396 | - Change default keychain to login.keychain 397 | 398 | ## [2.3.0] - 2021-10-31 399 | ### Added 400 | - Support -a for pw::get 401 | 402 | ## [2.2.0] - 2021-10-31 403 | ### Added 404 | - Generate password when empty 405 | - Less verbose rm output 406 | 407 | ## [2.1.0] - 2021-10-30 408 | ### Fixed 409 | - Fix potentially removing wrong entry when no account is specified 410 | 411 | ## [2.0.0] - 2021-10-30 412 | ### Added 413 | - Support empty account 414 | - pw ls sorts entries 415 | 416 | ### Changed 417 | - Default account is empty instead of $USER 418 | - Select custom keychain with -k only 419 | 420 | ## [1.3.0] - 2021-10-30 421 | ### Added 422 | - Add pw open 423 | - Add pw -k 424 | - Add pw lock 425 | - Add pw unlock 426 | 427 | ## [1.2.0] - 2021-10-29 428 | ### Added 429 | - Ask before removing entry using pw rm 430 | - Use tab for columns 431 | - pw ls given keychain 432 | - Update readme 433 | 434 | ## [1.1.0] - 2021-10-28 435 | ### Added 436 | - Add -a option to search all user keychains 437 | 438 | ## [1.0.0] - 2021-10-28 439 | ### Added 440 | - Add pw 441 | - Add bee support 442 | - Add install script 443 | - Add readme 444 | 445 | [Unreleased]: https://github.com/sschmid/pw-terminal-password-manager/compare/12.0.0...HEAD 446 | [12.0.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/11.0.0...12.0.0 447 | [11.0.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/10.1.0...11.0.0 448 | [10.1.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/10.0.0...10.1.0 449 | [10.0.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/9.2.3...10.0.0 450 | [9.2.3]: https://github.com/sschmid/pw-terminal-password-manager/compare/9.2.2...9.2.3 451 | [9.2.2]: https://github.com/sschmid/pw-terminal-password-manager/compare/9.2.1...9.2.2 452 | [9.2.1]: https://github.com/sschmid/pw-terminal-password-manager/compare/9.2.0...9.2.1 453 | [9.2.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/9.1.1...9.2.0 454 | [9.1.1]: https://github.com/sschmid/pw-terminal-password-manager/compare/9.1.0...9.1.1 455 | [9.1.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/9.0.0...9.1.0 456 | [9.0.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/8.2.1...9.0.0 457 | [8.2.1]: https://github.com/sschmid/pw-terminal-password-manager/compare/8.2.0...8.2.1 458 | [8.2.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/8.1.0...8.2.0 459 | [8.1.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/8.0.0...8.1.0 460 | [8.0.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/7.0.0...8.0.0 461 | [7.0.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/6.1.2...7.0.0 462 | [6.1.2]: https://github.com/sschmid/pw-terminal-password-manager/compare/6.1.1...6.1.2 463 | [6.1.1]: https://github.com/sschmid/pw-terminal-password-manager/compare/6.1.0...6.1.1 464 | [6.1.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/6.0.0...6.1.0 465 | [6.0.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/5.1.0...6.0.0 466 | [5.1.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/5.0.0...5.1.0 467 | [5.0.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/4.5.1...5.0.0 468 | [4.5.1]: https://github.com/sschmid/pw-terminal-password-manager/compare/4.5.0...4.5.1 469 | [4.5.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/4.4.0...4.5.0 470 | [4.4.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/4.3.0...4.4.0 471 | [4.3.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/4.2.0...4.3.0 472 | [4.2.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/4.1.0...4.2.0 473 | [4.1.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/4.0.0...4.1.0 474 | [4.0.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/3.0.0...4.0.0 475 | [3.0.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/2.3.0...3.0.0 476 | [2.3.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/2.2.0...2.3.0 477 | [2.2.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/2.1.0...2.2.0 478 | [2.1.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/2.0.0...2.1.0 479 | [2.0.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/1.3.0...2.0.0 480 | [1.3.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/1.2.0...1.3.0 481 | [1.2.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/1.1.0...1.2.0 482 | [1.1.0]: https://github.com/sschmid/pw-terminal-password-manager/compare/1.0.0...1.1.0 483 | [1.0.0]: https://github.com/sschmid/pw-terminal-password-manager/releases/tag/1.0.0 484 | -------------------------------------------------------------------------------- /DEPENDENCIES.md: -------------------------------------------------------------------------------- 1 | bash 2 | fzf 3 | gnupg 4 | keepassxc 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2021 - 2025 Simon Schmid 4 | 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔐 `pw` - Terminal Password Manager powered by `fzf` 2 | 3 | `pw` is a command-line password manager unifying trusted password managers 4 | like [macOS Keychain](https://developer.apple.com/documentation/security/keychain_services), 5 | [KeePassXC](https://keepassxc.org) and [GnuPG](https://www.gnupg.org) in a single interface within the terminal. 6 | It combines the security of your favourite password managers with the speed and 7 | simplicity of the [fzf](https://github.com/junegunn/fzf) fuzzy finder and allows 8 | you to interact with [various keychains](#using-multiple-keychains) effortlessly. 9 | 10 | [![CI](https://github.com/sschmid/pw-terminal-password-manager/actions/workflows/ci.yml/badge.svg)](https://github.com/sschmid/pw-terminal-password-manager/actions/workflows/ci.yml) 11 | [![Coverage Status](https://coveralls.io/repos/github/sschmid/pw-terminal-password-manager/badge.svg)](https://coveralls.io/github/sschmid/pw-terminal-password-manager) 12 | [![Latest release](https://img.shields.io/github/release/sschmid/pw-terminal-password-manager.svg)](https://github.com/sschmid/pw-terminal-password-manager/releases) 13 | [![Twitter](https://img.shields.io/twitter/follow/s_schmid)](https://twitter.com/intent/follow?original_referer=https%3A%2F%2Fgithub.com%2Fsschmid%2Fpw&screen_name=s_schmid&tw_p=followbutton) 14 | 15 | # Why `pw`? 16 | 17 | - **Built on Proven Tools:** Instead of reinventing password management, `pw` combines reliable, established tools into one convenient interface. 18 | - **Efficiency:** With the [fzf](https://github.com/junegunn/fzf) fuzzy finder, `pw` allows for rapid and intuitive interaction with your keychains - nice! 19 | - **Simplicity:** `pw` is built using simple bash, making it easy to understand, modify, and extend. 20 | - **Extensibility:** Adding plugins for your preferred password managers takes only minutes (see [plugins](plugins)). 21 | - **Clipboard Management:** Automatically clears passwords from the clipboard after a configurable time. 22 | - **Multiple Keychain Support**: Effortlessly manage and switch between [multiple keychains](#using-multiple-keychains) stored in various locations. 23 | 24 | ![pw-fzf](readme/pw-fzf.png) 25 | 26 | # Install and update `pw` 27 | 28 | See [requirements](#requirements) for dependencies. 29 | 30 | ### Install script 31 | 32 | ```bash 33 | sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/sschmid/pw-terminal-password-manager/main/install)" 34 | ``` 35 | 36 | ### Manual install 37 | 38 | ```bash 39 | git clone https://github.com/sschmid/pw-terminal-password-manager /opt/pw 40 | ln -s /opt/pw/src/pw /usr/local/bin/pw 41 | ``` 42 | 43 | ### Update 44 | 45 | ```bash 46 | sudo pw update 47 | ``` 48 | 49 | ### Uninstall 50 | 51 | ```bash 52 | sudo /opt/pw/install --uninstall 53 | ``` 54 | 55 | | | Tested on the following platforms: | | 56 | |---------------------------------------------------------------------------------------------|------------------------------------|-----------------------------------------------------| 57 | | | macOS | | 58 | | | Alpine Linux | [Dockerfile](docker/alpine/Dockerfile) | 59 | | | Arch Linux | [Dockerfile](docker/archlinux/Dockerfile) | 60 | | | Debian | [Dockerfile](docker/debian/Dockerfile) | 61 | | | Fedora | [Dockerfile](docker/fedora/Dockerfile) | 62 | | | openSUSE Tumbleweed | [Dockerfile](docker/opensuse-tumbleweed/Dockerfile) | 63 | | | Ubuntu | [Dockerfile](docker/ubuntu/Dockerfile) | 64 | 65 | # Quickstart 66 | 67 | ```bash 68 | # create a keychain (.keychain-db for macOS Keychain, .kdbx for KeePassXC) 69 | pw init ~/secrets.keychain-db 70 | 71 | # optionally configure keychains in ~/.config/pw/pw.conf so you can access them 72 | # from anywhere, otherwise, pw will discover keychains in the current directory 73 | echo 'keychain = ~/secrets.keychain-db' >> ~/.config/pw/pw.conf 74 | 75 | # add an entry 76 | pw add GitHub sschmid 77 | 78 | # add another entry interactively 79 | pw add 80 | 81 | # copy the password directly by providing the name 82 | pw GitHub 83 | 84 | # or use fzf to select an entry (-p prints the password instead of copying it) 85 | pw -p 86 | ``` 87 | 88 | If you would like to manage your passwords yourself, you can use `pw` with 89 | GnuPG to store encrypted passwords in a directory: 90 | 91 | ```bash 92 | # create a keychain 93 | pw init ~/secrets/ # end with `/` for GnuPG 94 | cd ~/secrets 95 | 96 | # optionally configure keychains in ~/.config/pw/pw.conf so you can access them 97 | # from anywhere, otherwise, pw will discover gpg encrypted passwords in the 98 | # current directory 99 | echo 'keychain = ~/secrets/' >> ~/.config/pw/pw.conf 100 | 101 | # add an entry 102 | # if you haven't configured ~/.config/pw/pw.conf yet, you need to specify the 103 | # keychain once because the directory is empty and pw can't determine the 104 | # keychain type yet 105 | pw -k ~/secrets add GitHub sschmid 106 | 107 | # add another entry interactively 108 | pw add 109 | 110 | # output binary format (default) 111 | pw add GitHub.gpg 112 | 113 | # output ASCII-armored format 114 | pw add GitHub.asc 115 | 116 | # copy the password directly by providing the name 117 | pw GitHub 118 | 119 | # or use fzf to select an entry (-p prints the password instead of copying it) 120 | pw -p 121 | ``` 122 | 123 | # How `pw` works 124 | 125 | `pw` provides a unified interface to interact with various keychains and forwards 126 | commands to the respective password manager using plugins. Plugins are simple 127 | bash scripts that implement the following functions (see [plugins](plugins)): 128 | 129 | - `init` 130 | - `add` 131 | - `edit` 132 | - `get` 133 | - `show` 134 | - `rm` 135 | - `ls` 136 | - `open` 137 | - `lock` 138 | - `unlock` 139 | 140 | Password managers may vary in their capabilities, so `pw` provides a 141 | consistent interface by implementing workarounds where necessary. 142 | 143 | Here's an overview of which features are supported by each plugin: 144 | 145 | | Feature | macOS Keychain | KeePassXC | GnuPG | 146 | |--------------------------------------------------------------------------------:|:--------------:|:---------------------------------:|:--------------:| 147 | | Create keychain | ✅ | ✅ | ✅ (directory) | 148 | | Add entry with name and password | ✅ | ✅ | ✅ | 149 | | Add entry with name, account, url, notes and password | ✅ | ✅ | 🔐 | 150 | | Allow multiple entries with the same
name given the account is different | ✅ | ❌ | ❌ | 151 | | Add entry in groups (e.g. Coding/Work) | ❌ | 🔐 | ✅ | 152 | | Edit entry | ✅ | ✅ | ✅ | 153 | | Remove entry | ✅ | ✅ | ✅ | 154 | | List entries | ✅ | ✅ | ✅ | 155 | | Open keychain | ✅ | ✅ | ✅ | 156 | | Lock keychain | ✅ | ℹ️ keychain is never left unlocked | ✅ | 157 | | Unlock keychain | ✅ | ✅ starts interactive session | ✅ | 158 | | Key file support | ❌ | ✅ | ❌ | 159 | | YubiKey support | ❌ | ✅ | ❌ | 160 | | Automatic keychain discovery | ✅ | ✅ | ✅ | 161 | 162 | 163 | ✅: native support by the password manager
164 | 🔐: workaround implemented by pw
165 | ❌: not supported by the password manager 166 |
167 | 168 | # Security Considerations 169 | 170 | > [!IMPORTANT] 171 | > `pw` supports the macOS `security` command and `gpg` through its plugins, 172 | > which may introduce security risks. These risks arise from the behavior of 173 | > these underlying commands, not from `pw` itself. 174 | 175 | ## macOS `security` Command 176 | 177 | Typically, when accessing keychain items added by other applications, the user 178 | is prompted to `allow` or `always allow` access. However, when keychain entries 179 | are added using the `security` command itself, the command is automatically 180 | granted access to those items without future prompts. This can be a security risk, 181 | because other applications can use the `security` command to access these items 182 | without prompting the user. 183 | 184 | `pw` changes this behaviour to reduce security risks by not automatically adding 185 | the `security` command to the keychain's access control list. This way you have 186 | full control over which applications can access your keychain items and decide 187 | whether to allow or deny access on a item-by-item basis. 188 | 189 | See [Plugin specific configuration](#macos-keychain) to change this behaviour. 190 | 191 | If you decide to change this behaviour, consider the following recommendations: 192 | 193 | > [!TIP] 194 | > - Change the keychain settings to require a password after a certain time and 195 | > activate the option to lock the keychain when the computer sleeps. 196 | > - Lock the keychain after each use to secure it. 197 | 198 | ```bash 199 | pw lock 200 | ``` 201 | 202 | Additionally, keychain entries can be listed without requiring a password, even 203 | when the keychain is locked. This can expose metadata about the keychain entries 204 | like the name, account, URL and comments. This cannot be prevented by `pw` and 205 | is a limitation of the macOS Keychain. There are workarounds like encrypting the 206 | keychain and only temporarily decrypting it when needed. 207 | 208 | ## GPG Passphrase Caching 209 | 210 | GPG caches passphrases after use, which can allow access to the private key 211 | without re-entering the passphrase. 212 | 213 | > [!TIP] 214 | > - Shorten the GPG passphrase caching time by adjusting the `gpg-agent` settings. 215 | > - Kill the GPG agent process to clear the passphrase cache. 216 | 217 | ```bash 218 | pw lock # will run 'gpgconf --kill gpg-agent' to kill the GPG agent process 219 | ``` 220 | 221 | Additionally, while GPG encrypts files, the file names can still be listed 222 | without requiring the passphrase, thereby exposing the file names. This cannot 223 | be prevented by `pw` and is a limitation of GPG. There are workarounds like 224 | using a separate encrypted container or using a tool like `tar` to encrypt the 225 | files into a single archive. 226 | 227 | ## KeePassXC 228 | 229 | > [!NOTE] 230 | > KeePassXC, unlike the `security` command and GPG, remains locked when not in 231 | > use and does not have these risks. 232 | 233 | # Security Comparison 234 | 235 | | Security Considerations | macOS Keychain | KeePassXC | GnuPG | 236 | |-------------------------------------------:|:--------------:|:---------:|:-----:| 237 | | Keychain stays unlocked | ⚠️ | ✅ | ⚠️ | 238 | | Metadata exposure while keychain is locked | ⚠️ | ✅ | ⚠️ | 239 | 240 | 241 | ✅: no known security risk
242 | ⚠️: potential security risk, but can be mitigated
243 | 🚨: potential security risk, no mitigation possible 244 |
245 | 246 | # Usage 247 | 248 | In all following examples, `[]` refers to the optional 249 | arguments `name`, `account`, `url`, `notes` in that order. 250 | 251 | When using fzf mode to select an entry, such as when getting, editing, 252 | or removing an entry, you can toggle the entry preview by pressing `?`. 253 | The entry preview shows details like the name, account, url, and notes and 254 | is off by default. 255 | 256 | Press `CTRL-Y` on any entry to copy (or print) the details. 257 | 258 | ## Config file 259 | 260 | The suggested location for the `pw` configuration file is `$XDG_CONFIG_HOME/pw/pw.conf`, 261 | which usually resolves to `~/.config/pw/pw.conf`. `pw` will automatically create 262 | this file with default values if it doesn't exist. 263 | 264 | You can specify a different configuration file using the `-c` option: 265 | 266 | ```bash 267 | pw -c /path/to/config 268 | ``` 269 | 270 | ## Create keychain 271 | 272 | ``` 273 | pw init create keychain 274 | ``` 275 | 276 | Examples: 277 | 278 | ```bash 279 | pw init ~/secrets.keychain-db # macOS Keychain 280 | pw init ~/secrets.kdbx # KeePassXC 281 | pw init ~/secrets/ # GnuPG (end with `/` to create a directory) 282 | 283 | # macos_keychain special behaviour 284 | pw init secrets.keychain-db # will create a keychain in ~/Library/Keychains 285 | pw init "${PWD}/secrets.keychain-db" # will create a keychain in the current directory 286 | ``` 287 | 288 | ## Add entry with name and optional account 289 | 290 | ``` 291 | pw add [] add entry. If no args, interactive mode 292 | ``` 293 | 294 | Examples: 295 | 296 | ```bash 297 | pw add # add interactively 298 | pw add GitHub # add entry with name 299 | pw add Google work@example.com # add entry with name and account 300 | pw add Google personal@example.com 301 | pw add Homepage admin https://example.com # add entry with name, account, url 302 | pw add Coveralls "" https://coveralls.io "login via GitHub" # add entry with name, url, notes 303 | ``` 304 | 305 | If a plugin doesn't support multiple entries with the same name, 306 | you can add the account to the name: 307 | 308 | ```bash 309 | pw add "Google (Work)" work@example.com 310 | pw add "Google (Personal)" personal@example.com 311 | ``` 312 | 313 | ## Add entry in group 314 | 315 | Examples: 316 | 317 | ```bash 318 | pw add Coding/GitHub 319 | pw add Coding/JetBrains 320 | ``` 321 | 322 | ## Edit entry 323 | 324 | ``` 325 | pw edit [] edit entry. If no args, fzf mode 326 | ``` 327 | 328 | Examples: 329 | 330 | ```bash 331 | pw edit # starts fzf to select an entry 332 | pw edit GitHub 333 | ``` 334 | 335 | ## Get entry 336 | 337 | ``` 338 | pw [-p] [] copy (or print) password. If no args, fzf mode 339 | ``` 340 | 341 | Examples: 342 | 343 | ```bash 344 | pw # starts fzf to select an entry 345 | pw GitHub 346 | ``` 347 | 348 | ## Show entry 349 | 350 | ``` 351 | pw show [-p] [] copy (or print) details. If no args, fzf mode 352 | ``` 353 | 354 | Examples: 355 | 356 | ```bash 357 | pw show # starts fzf to select an entry 358 | pw show GitHub 359 | ``` 360 | 361 | ## Remove entry 362 | 363 | ``` 364 | pw rm [] remove entry. If no args, fzf mode 365 | ``` 366 | 367 | Examples: 368 | 369 | ```bash 370 | pw rm # starts fzf to select an entry 371 | pw rm GitHub 372 | ``` 373 | 374 | ## Generate a password 375 | 376 | ``` 377 | pw gen [-p] [] [] generate password with given length and 378 | character class (default: 35 [:graph:]) 379 | ``` 380 | 381 | Examples: 382 | 383 | ```bash 384 | pw gen # equivalent to pw gen 35 '[:graph:]' 385 | pw gen 16 386 | pw gen 24 '[:alnum:]' 387 | pw gen 32 '[:digit:]' 388 | ``` 389 | 390 | ## Automatic keychain discovery 391 | 392 | `pw` automatically searches for keychains in the current directory. This way 393 | you can keep your keychains in the same directory as your project and `pw` will 394 | automatically discover and use them. 395 | 396 | ## Specifying a keychain 397 | 398 | There are multiple ways to specify a keychain: 399 | 400 | ```bash 401 | # specify keychain using -k for the current command (overrides PW_KEYCHAIN) 402 | pw -k secrets.keychain-db 403 | ``` 404 | 405 | ```bash 406 | # specify keychain for the current command 407 | PW_KEYCHAIN=secrets.keychain-db pw 408 | ``` 409 | 410 | ```bash 411 | # export default keychain for the current shell 412 | export PW_KEYCHAIN=secrets.keychain-db 413 | pw 414 | ``` 415 | 416 | ## Using multiple keychains 417 | 418 | `pw` allows you to interact with multiple keychains from different password 419 | managers. This feature is particularly useful when you have keychains stored 420 | in various locations. You can specify different keychains using the 421 | configuration file, which defaults to `~/.config/pw/pw.conf`. 422 | 423 | To use multiple keychains, add your desired keychains to `~/.config/pw/pw.conf`, e.g.: 424 | 425 | ```ini 426 | [keychains] 427 | keychain = secrets.keychain-db 428 | keychain = ~/path/to/myproject.keychain-db 429 | keychain = ~/path/to/keepassxc.kdbx 430 | keychain = ~/path/to/gpg/secrets 431 | ``` 432 | 433 | After configuring your keychains, continue using `pw` as usual. If no keychain 434 | is specified with `-k` or by setting `PW_KEYCHAIN`, `pw` allows you to select 435 | one from your `~/.config/pw/pw.conf` file using the fuzzy finder. 436 | 437 | ![pw-fzf](readme/pw-keychains.png) 438 | 439 | ## Using `pw` in a command or script 440 | Use `pw` to avoid leaking secrets in scripts that you share or commit. 441 | 442 | ```bash 443 | curl -s -H "Authorization: token $(pw -p GITHUB_TOKEN)" https://api.github.com/user 444 | ``` 445 | 446 | ## Provide passwords via `STDIN` 447 | 448 | To avoid password prompts that can interrupt scripts, 449 | you can provide passwords via `STDIN`. 450 | 451 | > [!CAUTION] 452 | > Avoid providing passwords in plain text, because they can be exposed in process 453 | listings, shell history, logs, and through insecure network transmissions, making 454 | them vulnerable to theft or misuse. Instead, use secure methods like environment 455 | variables to protect sensitive information. 456 | 457 | ```bash 458 | echo "${MY_PASSWORD}" | pw init ~/secrets.kdbx 459 | echo "${MY_PASSWORD}" | pw add Google personal@example.com 460 | echo "${MY_PASSWORD}" | pw unlock 461 | ``` 462 | 463 | If your shell supports `STDIN` with here string (like `bash`), you can use it like this: 464 | 465 | ```bash 466 | pw init ~/secrets.kdbx <<< "${MY_PASSWORD}" 467 | pw add Google personal@example.com <<< "${MY_PASSWORD}" 468 | pw unlock <<< "${MY_PASSWORD}" 469 | ``` 470 | 471 | # Customization 472 | 473 | Configure `pw` in `~/.config/pw/pw.conf` with the following options: 474 | 475 | ```ini 476 | [general] 477 | password_length = 35 478 | password_character_class = [:graph:] 479 | clipboard_clear_time = 45 480 | 481 | # pbcopy/pbpaste, xclip, xsel, and wl-copy/wl-paste are supported by default. 482 | # If you're using a different clipboard manager, you can specify it here: 483 | # copy = my-copy-command 484 | # paste = my-paste-command 485 | 486 | [plugins] 487 | plugin = $PW_HOME/plugins/gpg 488 | plugin = $PW_HOME/plugins/keepassxc 489 | plugin = $PW_HOME/plugins/macos_keychain 490 | 491 | [keychains] 492 | keychain = secrets.keychain-db 493 | keychain = ~/path/to/your/gpg/vault 494 | keychain = ~/path/to/your/keychain.kdbx 495 | keychain = ~/path/to/your/keychain.keychain-db 496 | ``` 497 | 498 | Additionally, you can use environment variables to customize `pw`. They will 499 | override the settings in `~/.config/pw/pw.conf`. 500 | 501 | ```bash 502 | # Default keychain used when not specified with -k 503 | # otherwise, ~/.config/pw/pw.conf is used to select a keychain with fzf 504 | export PW_KEYCHAIN=secrets.keychain-db 505 | 506 | # Default length of generated passwords 507 | export PW_GEN_LENGTH=35 508 | 509 | # Default character class for generated passwords 510 | export PW_GEN_CLASS='[:graph:]' 511 | 512 | # Time after which the password is cleared from the clipboard 513 | export PW_CLIP_TIME=45 514 | ``` 515 | 516 | # Plugin specific configuration 517 | 518 | Some plugins support additional configuration options by appending them to the 519 | keychain path after a colon `:`, e.g. `/path/to/keychain:key=value`. 520 | 521 | This syntax can be used everywhere a keychain is specified, e.g.: 522 | 523 | ```bash 524 | pw -k ~/secrets.kdbx:key1=value1,key2=value2 525 | ``` 526 | 527 | In your `~/.config/pw/pw.conf`: 528 | ```bash 529 | ... 530 | keychain = ~/secrets.kdbx:key1=value1,key2=value2 531 | ... 532 | ``` 533 | 534 | ## macOS Keychain 535 | 536 | As mentioned in the [Security Considerations](#security-considerations) section, 537 | `pw` won't automatically add the `security` command to the keychain's access 538 | control list to reduce security risks. If you want to add the `security` command 539 | to the keychain's access control list by default, you can set the environment 540 | variable `PW_MACOS_KEYCHAIN_ACCESS_CONTROL` to `always-allow`: 541 | 542 | ```bash 543 | export PW_MACOS_KEYCHAIN_ACCESS_CONTROL="always-allow" 544 | ``` 545 | 546 | ## KeePassXC 547 | 548 | If you want to use a key file for unlocking the database, 549 | you can specify the path to the key file: 550 | 551 | ```bash 552 | ~/secrets.kdbx:keyfile=/path/to/keyfile 553 | ``` 554 | 555 | If you're using a YubiKey with KeePassXC, you can specify the slot to use: 556 | 557 | ```bash 558 | ~/secrets.kdbx:yubikey=1:23456789 559 | ``` 560 | 561 | ## GnuPG 562 | 563 | To set a different gpg key as the default for encryption, you can specify the key id: 564 | 565 | ```bash 566 | ~/path/to/gpg/secrets:key=634419040D678764 567 | ``` 568 | 569 | You can control the gpg output format by specifying a file extension: 570 | 571 | ```bash 572 | # output binary format (default) 573 | pw add GitHub.gpg 574 | 575 | # output ASCII-armored format 576 | pw add GitHub.asc 577 | ``` 578 | 579 | # Requirements 580 | 581 | Install the following [DEPENDENCIES.md](DEPENDENCIES.md) to use `pw`: 582 | 583 | - `bash` 584 | - `fzf` 585 | - `gnupg` (optional, for GnuPG plugin) 586 | - `keepassxc` (optional, for KeePassXC plugin) 587 | 588 | Make sure to have a clipboard manager installed to copy passwords to the clipboard. 589 | Currently supported clipboard managers are: 590 | - `pbcopy`, `pbpaste` (macOS, built-in) 591 | - `xclip` (Linux) 592 | - `xsel` (Linux) 593 | - `wl-clipboard` (Wayland) 594 | 595 | If you're using a different clipboard manager, 596 | you can specify it in your `~/.config/pw/pw.conf` file. 597 | 598 | ```ini 599 | [general] 600 | copy = my-copy-command 601 | paste = my-paste-command 602 | ``` 603 | 604 | ### macOS 605 | 606 | ```bash 607 | brew install $(cat /opt/pw/DEPENDENCIES.md) 608 | ``` 609 | 610 | ### Alpine Linux 611 | 612 | ```bash 613 | apk add --no-cache $(cat /opt/pw/DEPENDENCIES.md) 614 | ``` 615 | 616 | ### Arch Linux 617 | 618 | ```bash 619 | pacman -Syu --noconfirm && pacman -S --noconfirm --needed $(cat /opt/pw/DEPENDENCIES.md) 620 | ``` 621 | 622 | ### Debian/Ubuntu 623 | 624 | ```bash 625 | apt-get update && apt-get install -y $(cat /opt/pw/DEPENDENCIES.md) 626 | ``` 627 | 628 | ### Fedora 629 | 630 | ```bash 631 | dnf install -y $(cat /opt/pw/DEPENDENCIES.md) 632 | ``` 633 | 634 | ### openSUSE Tumbleweed 635 | 636 | ```bash 637 | zypper --non-interactive install --no-recommends $(cat /opt/pw/DEPENDENCIES.md) 638 | ``` 639 | -------------------------------------------------------------------------------- /docker/alpine/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BASH_VERSION=5.2 2 | FROM bash:${BASH_VERSION} AS base 3 | WORKDIR /opt/pw 4 | COPY DEPENDENCIES.md . 5 | RUN apk add --no-cache \ 6 | $(cat DEPENDENCIES.md) \ 7 | parallel \ 8 | xclip \ 9 | xvfb 10 | 11 | FROM base AS pw 12 | WORKDIR /opt/pw 13 | COPY src src 14 | COPY examples examples 15 | COPY plugins plugins 16 | COPY version.txt . 17 | RUN ln -s /opt/pw/src/pw /usr/local/bin/pw 18 | WORKDIR /root 19 | COPY docker/docker-entrypoint.sh /usr/local/bin/ 20 | ENTRYPOINT ["docker-entrypoint.sh"] 21 | 22 | FROM pw AS test 23 | WORKDIR /opt/pw 24 | COPY test test 25 | RUN test/bats/bin/bats --jobs $(nproc) test 26 | -------------------------------------------------------------------------------- /docker/archlinux/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM archlinux:latest AS base 2 | # FROM --platform=linux/amd64 archlinux:latest AS base 3 | WORKDIR /opt/pw 4 | COPY DEPENDENCIES.md . 5 | RUN pacman -Syu --noconfirm && pacman -S --noconfirm --needed \ 6 | $(cat DEPENDENCIES.md) \ 7 | parallel \ 8 | xclip \ 9 | xorg-server-xvfb \ 10 | && pacman -Scc --noconfirm 11 | 12 | FROM base AS pw 13 | WORKDIR /opt/pw 14 | COPY src src 15 | COPY examples examples 16 | COPY plugins plugins 17 | COPY version.txt . 18 | RUN ln -s /opt/pw/src/pw /usr/local/bin/pw 19 | WORKDIR /root 20 | COPY docker/docker-entrypoint.sh /usr/local/bin/ 21 | ENTRYPOINT ["docker-entrypoint.sh"] 22 | 23 | FROM pw AS test 24 | WORKDIR /opt/pw 25 | COPY test test 26 | RUN test/bats/bin/bats --jobs $(nproc) test 27 | -------------------------------------------------------------------------------- /docker/debian/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:latest AS base 2 | WORKDIR /opt/pw 3 | COPY DEPENDENCIES.md . 4 | RUN apt-get update && apt-get install -y \ 5 | $(cat DEPENDENCIES.md) \ 6 | file \ 7 | parallel \ 8 | xclip \ 9 | xvfb \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | FROM base AS pw 13 | WORKDIR /opt/pw 14 | COPY src src 15 | COPY examples examples 16 | COPY plugins plugins 17 | COPY version.txt . 18 | RUN ln -s /opt/pw/src/pw /usr/local/bin/pw 19 | WORKDIR /root 20 | COPY docker/docker-entrypoint.sh /usr/local/bin/ 21 | ENTRYPOINT ["docker-entrypoint.sh"] 22 | 23 | FROM pw AS test 24 | WORKDIR /opt/pw 25 | COPY test test 26 | RUN test/bats/bin/bats --jobs $(nproc) test 27 | -------------------------------------------------------------------------------- /docker/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Set up virtual display (Xvfb) for clipboard support 5 | export DISPLAY=:99 6 | Xvfb "${DISPLAY}" &>/dev/null & 7 | 8 | exec "$@" 9 | -------------------------------------------------------------------------------- /docker/fedora/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM fedora:latest AS base 2 | WORKDIR /opt/pw 3 | COPY DEPENDENCIES.md . 4 | RUN dnf install -y \ 5 | $(cat DEPENDENCIES.md) \ 6 | awk \ 7 | file \ 8 | parallel \ 9 | procps-ng \ 10 | xclip \ 11 | xorg-x11-server-Xvfb \ 12 | && dnf clean all \ 13 | && rm -rf /var/cache/yum 14 | 15 | FROM base AS pw 16 | WORKDIR /opt/pw 17 | COPY src src 18 | COPY examples examples 19 | COPY plugins plugins 20 | COPY version.txt . 21 | RUN ln -s /opt/pw/src/pw /usr/local/bin/pw 22 | WORKDIR /root 23 | COPY docker/docker-entrypoint.sh /usr/local/bin/ 24 | ENTRYPOINT ["docker-entrypoint.sh"] 25 | 26 | FROM pw AS test 27 | WORKDIR /opt/pw 28 | COPY test test 29 | RUN test/bats/bin/bats --jobs $(nproc) test 30 | -------------------------------------------------------------------------------- /docker/opensuse-tumbleweed/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM opensuse/tumbleweed:latest AS base 2 | WORKDIR /opt/pw 3 | COPY DEPENDENCIES.md . 4 | RUN zypper --non-interactive install --no-recommends \ 5 | $(cat DEPENDENCIES.md) \ 6 | file \ 7 | gawk \ 8 | gnu_parallel \ 9 | xclip \ 10 | xorg-x11-server-Xvfb \ 11 | && zypper clean -a \ 12 | && rm -rf /var/cache/zypp/* 13 | 14 | FROM base AS pw 15 | WORKDIR /opt/pw 16 | COPY src src 17 | COPY examples examples 18 | COPY plugins plugins 19 | COPY version.txt . 20 | RUN ln -s /opt/pw/src/pw /usr/local/bin/pw 21 | WORKDIR /root 22 | COPY docker/docker-entrypoint.sh /usr/local/bin/ 23 | ENTRYPOINT ["docker-entrypoint.sh"] 24 | 25 | FROM pw AS test 26 | WORKDIR /opt/pw 27 | COPY test test 28 | RUN test/bats/bin/bats --jobs $(nproc) test 29 | -------------------------------------------------------------------------------- /docker/ubuntu/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest AS base 2 | WORKDIR /opt/pw 3 | COPY DEPENDENCIES.md . 4 | RUN apt-get update && apt-get install -y \ 5 | $(cat DEPENDENCIES.md) \ 6 | file \ 7 | parallel \ 8 | xclip \ 9 | xvfb \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | FROM base AS pw 13 | WORKDIR /opt/pw 14 | COPY src src 15 | COPY examples examples 16 | COPY plugins plugins 17 | COPY version.txt . 18 | RUN ln -s /opt/pw/src/pw /usr/local/bin/pw 19 | WORKDIR /root 20 | COPY docker/docker-entrypoint.sh /usr/local/bin/ 21 | ENTRYPOINT ["docker-entrypoint.sh"] 22 | 23 | FROM pw AS test 24 | WORKDIR /opt/pw 25 | COPY test test 26 | RUN test/bats/bin/bats --jobs $(nproc) test 27 | -------------------------------------------------------------------------------- /examples/pw.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | password_length = 35 3 | password_character_class = [:graph:] 4 | clipboard_clear_time = 45 5 | 6 | # pbcopy/pbpaste, xclip, xsel, and wl-copy/wl-paste are supported by default. 7 | # If you're using a different clipboard manager, you can specify it here: 8 | # copy = my-copy-command 9 | # paste = my-paste-command 10 | 11 | [plugins] 12 | plugin = $PW_HOME/plugins/gpg 13 | plugin = $PW_HOME/plugins/keepassxc 14 | plugin = $PW_HOME/plugins/macos_keychain 15 | 16 | [keychains] 17 | # Put your keychains here for easy access 18 | # keychain = $HOME/path/to/your/gpg/vault 19 | # keychain = $HOME/path/to/your/keychain.kdbx 20 | # keychain = $HOME/path/to/your/keychain.keychain-db 21 | -------------------------------------------------------------------------------- /install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # To install, run 4 | # bash -c "$(curl -fsSL https://raw.githubusercontent.com/sschmid/pw-terminal-password-manager/main/install)" 5 | 6 | set -e 7 | 8 | INST_DIR="/opt/pw" 9 | BIN_PATH="/usr/local/bin/pw" 10 | 11 | if [[ $1 == "--uninstall" ]]; then 12 | rm -rf "${INST_DIR}" "${BIN_PATH}" 13 | echo "pw has been uninstalled successfully" 14 | exit 15 | fi 16 | 17 | if [[ -d "${INST_DIR}" ]]; then 18 | echo "It seems like pw is already installed at ${INST_DIR}" 19 | echo "Run 'pw update' to update pw to the latest version" 20 | exit 21 | fi 22 | 23 | git clone https://github.com/sschmid/pw-terminal-password-manager "${INST_DIR}" 24 | echo "Linking ${INST_DIR}/src/pw to ${BIN_PATH}" 25 | mkdir -p "$(dirname "${BIN_PATH}")" 26 | ln -sf "${INST_DIR}/src/pw" "${BIN_PATH}" 27 | echo "pw has been installed successfully" 28 | -------------------------------------------------------------------------------- /plugins/gpg/_gpg_decrypt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | "$(dirname "$0")/_require" 4 | 5 | keychain_password="$1" path="${2:-}" 6 | 7 | if [[ -n "${keychain_password}" ]]; then 8 | gpg --quiet --batch \ 9 | --pinentry-mode loopback --passphrase "${keychain_password}" \ 10 | --decrypt ${path:+"${path}"} 11 | else 12 | gpg --quiet --decrypt ${path:+"${path}"} 13 | fi 14 | -------------------------------------------------------------------------------- /plugins/gpg/_gpg_encrypt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | "$(dirname "$0")/_require" 4 | parse_options="$(dirname "$0")/../parse_options" 5 | 6 | options="$1"; shift 7 | 8 | declare -a cmd_options=() 9 | while IFS=$'\t' read -r key value; do 10 | case "${key}" in 11 | key) cmd_options+=("--default-key" "${value}") ;; 12 | esac 13 | done < <("${parse_options}" "${options}") 14 | 15 | gpg --quiet --encrypt "${cmd_options[@]}" --default-recipient-self "$@" 16 | -------------------------------------------------------------------------------- /plugins/gpg/_parse_details: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # KCOV_EXCL_START 5 | # shellcheck disable=SC2016 6 | awk_cmd=' 7 | NR==2 { account=$0 } 8 | NR==3 { url=$0 } 9 | NR>=4 { notes = (notes ? notes "\n" : "") $0 } 10 | END { printf "Name: %s\nAccount: %s\nURL: %s\nNotes:\n%s", name, account, url, notes }' 11 | # KCOV_EXCL_STOP 12 | 13 | awk -v name="$(basename "$1")" "${awk_cmd}" 14 | -------------------------------------------------------------------------------- /plugins/gpg/_require: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | command -v gpg >/dev/null || { 4 | cat << EOF >&2 5 | command not found: gpg 6 | Please make sure that GnuPG is installed and gpg is in your PATH. 7 | EOF 8 | exit 1 9 | } 10 | -------------------------------------------------------------------------------- /plugins/gpg/add: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _gpg_encrypt="$(dirname "$0")/_gpg_encrypt" 4 | 5 | options="$1" keychain_password="$2" keychain="$3" password="$4" name="$5" account="$6" url="$7" notes="$8" 6 | 7 | # shellcheck disable=SC2174 8 | mkdir -m 700 -p "${keychain}" 9 | 10 | mkdir -p "${keychain}/$(dirname "${name}")" 11 | 12 | content="${password} 13 | ${account} 14 | ${url} 15 | ${notes}" 16 | 17 | if [[ "${name##*.}" == "asc" ]] 18 | then "${_gpg_encrypt}" "${options}" --armor --output "${keychain}/${name}" <<< "${content}" 19 | else "${_gpg_encrypt}" "${options}" --output "${keychain}/${name}" <<< "${content}" 20 | fi 21 | -------------------------------------------------------------------------------- /plugins/gpg/edit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _gpg_decrypt="$(dirname "$0")/_gpg_decrypt" 4 | _gpg_encrypt="$(dirname "$0")/_gpg_encrypt" 5 | 6 | options="$1" keychain_password="$2" keychain="$3" password="$4" name="$5" account="$6" url="$7" 7 | 8 | content="$("${_gpg_decrypt}" "${keychain_password}" "${keychain}/${name}" | sed '1d')" 9 | "${_gpg_encrypt}" "${options}" --yes --output "${keychain}/${name}" << EOF 10 | ${password} 11 | ${content} 12 | EOF 13 | -------------------------------------------------------------------------------- /plugins/gpg/fzf_preview: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _parse_details="$(dirname "$0")/_parse_details" 4 | 5 | options="$1" keychain_password="$2" keychain="$3" 6 | 7 | # KCOV_EXCL_START 8 | # shellcheck disable=SC1083 9 | _fzf_preview() { 10 | gpg --quiet --decrypt "${keychain}/"{4} | "${_parse_details}" {4} 11 | } 12 | # KCOV_EXCL_STOP 13 | 14 | declare -p keychain _parse_details 15 | declare -f _fzf_preview 16 | echo "_fzf_preview" 17 | -------------------------------------------------------------------------------- /plugins/gpg/get: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _gpg_decrypt="$(dirname "$0")/_gpg_decrypt" 4 | 5 | options="$1" keychain_password="$2" keychain="$3" name="$4" account="$5" url="$6" 6 | 7 | "${_gpg_decrypt}" "${keychain_password}" "${keychain}/${name}" | sed -n '1p' 8 | -------------------------------------------------------------------------------- /plugins/gpg/hook: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | FILE_TYPE="PGP" 5 | FILE_EXTENSION="/, gpg, asc" 6 | 7 | case "$1" in 8 | discover_keychains) 9 | while read -r path; do 10 | filetype="$(file -b "${path}")" 11 | # .asc 12 | [[ "${filetype}" != "${FILE_TYPE}"* ]] || echo "$(dirname "${path}")/" 13 | # .gpg 14 | [[ "${filetype}" != "data" ]] || echo "$(dirname "${path}")/" 15 | done < <(find "${PWD}" -maxdepth 1 -type f) 16 | ;; 17 | register_with_keychain) 18 | echo "${FILE_TYPE}" 19 | if [[ -d "$2" ]]; then 20 | echo yes 21 | else 22 | echo no 23 | fi 24 | ;; 25 | register_with_extension) 26 | echo "${FILE_TYPE}" 27 | echo "${FILE_EXTENSION}" 28 | if [[ "$2" == */ ]]; then 29 | echo yes 30 | else 31 | echo no 32 | fi 33 | ;; 34 | esac 35 | -------------------------------------------------------------------------------- /plugins/gpg/init: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain="$2" 5 | 6 | # shellcheck disable=SC2174 7 | mkdir -m 700 -p "${keychain}" 8 | -------------------------------------------------------------------------------- /plugins/gpg/keychain_password: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" command="$2" keychain="$3" 5 | 6 | if [[ "${command}" == "get" || "${command}" == "show" || "${command}" == "edit" ]]; then 7 | if [[ -p /dev/stdin ]]; then 8 | IFS= read -r keychain_password 9 | echo "${keychain_password}" 10 | fi 11 | fi 12 | -------------------------------------------------------------------------------- /plugins/gpg/lock: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain="$2" 5 | 6 | gpgconf --kill gpg-agent 7 | -------------------------------------------------------------------------------- /plugins/gpg/ls: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain_password="$2" keychain="$3" format="${4:-default}" 5 | 6 | pushd "${keychain}" >/dev/null || exit 1 7 | list="$(find . -type f ! -name .DS_Store | sort -f)" 8 | popd >/dev/null || exit 1 9 | 10 | case "${format}" in 11 | fzf) awk '{print $0 "\t\t\t" $0}' <<< "${list}" ;; 12 | *) echo "${list}" ;; 13 | esac 14 | -------------------------------------------------------------------------------- /plugins/gpg/open: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain="$2" 5 | 6 | open "${keychain}" 7 | -------------------------------------------------------------------------------- /plugins/gpg/rm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain_password="$2" keychain="$3" name="$4" account="$5" url="$6" 5 | 6 | rm "${keychain}/${name}" 7 | -------------------------------------------------------------------------------- /plugins/gpg/show: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _gpg_decrypt="$(dirname "$0")/_gpg_decrypt" 4 | _parse_details="$(dirname "$0")/_parse_details" 5 | 6 | options="$1" keychain_password="$2" keychain="$3" name="$4" account="$5" url="$6" 7 | 8 | "${_gpg_decrypt}" "${keychain_password}" "${keychain}/${name}" | "${_parse_details}" "${name}" 9 | -------------------------------------------------------------------------------- /plugins/gpg/unlock: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _gpg_decrypt="$(dirname "$0")/_gpg_decrypt" 4 | _gpg_encrypt="$(dirname "$0")/_gpg_encrypt" 5 | 6 | options="$1" keychain="$2" 7 | 8 | if [[ -p /dev/stdin ]] 9 | then IFS= read -r keychain_password 10 | else keychain_password="" 11 | fi 12 | 13 | "${_gpg_encrypt}" "${options}" <<< "" | "${_gpg_decrypt}" "${keychain_password}" >/dev/null 14 | -------------------------------------------------------------------------------- /plugins/keepassxc/_keepassxc_cli_with_options: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | parse_options="$(dirname "$0")/../parse_options" 4 | "$(dirname "$0")/_require" 5 | 6 | options="$1" command="$2"; shift 2 7 | 8 | declare -a cmd_options=() 9 | declare -i quiet=1 10 | while IFS=$'\t' read -r key value; do 11 | case "${key}" in 12 | yubikey) cmd_options+=("--yubikey" "${value}"); quiet=0 ;; 13 | keyfile) cmd_options+=("--key-file" "${value}") ;; 14 | esac 15 | done < <("${parse_options}" "${options}") 16 | 17 | [[ "${command}" == "open" ]] && quiet=0 18 | (( quiet )) && cmd_options+=("--quiet") 19 | 20 | if ! keepassxc-cli "${command}" "${cmd_options[@]}" "$@"; then 21 | echo "keepassxc-cli: Error while running the command '${command}'" >&2 22 | exit 1 23 | fi 24 | -------------------------------------------------------------------------------- /plugins/keepassxc/_require: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | command -v keepassxc-cli >/dev/null || { 4 | cat << EOF >&2 5 | command not found: keepassxc-cli 6 | Please make sure that KeePassXC is installed and keepassxc-cli is in your PATH. 7 | EOF 8 | exit 1 9 | } 10 | -------------------------------------------------------------------------------- /plugins/keepassxc/add: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _keepassxc_cli_with_options="$(dirname "$0")/_keepassxc_cli_with_options" 4 | 5 | options="$1" keychain_password="$2" keychain="$3" password="$4" name="$5" account="$6" url="$7" notes="$8" 6 | 7 | while IFS= read -r -d '/' group; do 8 | path+="${group}/" 9 | "${_keepassxc_cli_with_options}" "${options}" mkdir "${keychain}" "${path::-1}" <<< "${keychain_password}" &>/dev/null || true 10 | done <<< "${name}" 11 | 12 | "${_keepassxc_cli_with_options}" "${options}" add --password-prompt "${keychain}" ${account:+--username "${account}"} ${url:+--url "${url}"} ${notes:+--notes "${notes}"} "${name}" << EOF 13 | ${keychain_password} 14 | ${password} 15 | EOF 16 | -------------------------------------------------------------------------------- /plugins/keepassxc/edit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _keepassxc_cli_with_options="$(dirname "$0")/_keepassxc_cli_with_options" 4 | 5 | options="$1" keychain_password="$2" keychain="$3" password="$4" name="$5" account="$6" url="$7" 6 | 7 | "${_keepassxc_cli_with_options}" "${options}" edit --password-prompt "${keychain}" "${name}" << EOF 8 | ${keychain_password} 9 | ${password} 10 | EOF 11 | -------------------------------------------------------------------------------- /plugins/keepassxc/fzf_preview: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _keepassxc_cli_with_options="$(dirname "$0")/_keepassxc_cli_with_options" 4 | 5 | options="$1" keychain_password="$2" keychain="$3" 6 | 7 | echo "\"${_keepassxc_cli_with_options}\" \"${options}\" show \"${keychain}\" {4} <<< \"${keychain_password}\"" 8 | -------------------------------------------------------------------------------- /plugins/keepassxc/get: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _keepassxc_cli_with_options="$(dirname "$0")/_keepassxc_cli_with_options" 4 | 5 | options="$1" keychain_password="$2" keychain="$3" name="$4" account="$5" url="$6" 6 | 7 | "${_keepassxc_cli_with_options}" "${options}" show --show-protected --attributes password "${keychain}" "${name}" <<< "${keychain_password}" 8 | -------------------------------------------------------------------------------- /plugins/keepassxc/hook: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | FILE_TYPE="Keepass password database 2.x KDBX" 5 | FILE_EXTENSION="kdbx" 6 | 7 | case "$1" in 8 | discover_keychains) 9 | while read -r path; do 10 | [[ "$(file -b "${path}")" != "${FILE_TYPE}" ]] || echo "${path}" 11 | done < <(find "${PWD}" -maxdepth 1 -type f) 12 | ;; 13 | register_with_keychain) 14 | echo "${FILE_TYPE}" 15 | if [[ -f "$2" && "$(file -b "$2")" == "${FILE_TYPE}" ]]; then 16 | echo yes 17 | else 18 | echo no 19 | fi 20 | ;; 21 | register_with_extension) 22 | echo "${FILE_TYPE}" 23 | echo "${FILE_EXTENSION}" 24 | if [[ "$2" == "${FILE_EXTENSION}" ]]; then 25 | echo yes 26 | else 27 | echo no 28 | fi 29 | ;; 30 | esac 31 | -------------------------------------------------------------------------------- /plugins/keepassxc/init: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | parse_options="$(dirname "$0")/../parse_options" 4 | "$(dirname "$0")/_require" 5 | 6 | options="$1" keychain="$2" 7 | 8 | declare -a cmd_options=() 9 | while IFS=$'\t' read -r key value; do 10 | case "${key}" in 11 | keyfile) cmd_options+=("--set-key-file" "${value}") ;; 12 | esac 13 | done < <("${parse_options}" "${options}") 14 | 15 | if [[ -p /dev/stdin ]]; then 16 | IFS= read -r password 17 | keepassxc-cli db-create --quiet "${cmd_options[@]}" --set-password "${keychain}" << EOF 18 | ${password} 19 | ${password} 20 | EOF 21 | else 22 | keepassxc-cli db-create "${cmd_options[@]}" --set-password "${keychain}" 23 | fi 24 | -------------------------------------------------------------------------------- /plugins/keepassxc/keychain_password: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" command="$2" keychain="$3" 5 | 6 | if [[ -p /dev/stdin ]] 7 | then IFS= read -r keychain_password 8 | else IFS= read -rsp "Enter password to unlock ${keychain}:"$'\n' keychain_password 9 | fi 10 | 11 | echo "${keychain_password}" 12 | -------------------------------------------------------------------------------- /plugins/keepassxc/lock: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain="$2" 5 | 6 | echo "not available for keepassxc" 7 | -------------------------------------------------------------------------------- /plugins/keepassxc/ls: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _keepassxc_cli_with_options="$(dirname "$0")/_keepassxc_cli_with_options" 4 | 5 | options="$1" keychain_password="$2" keychain="$3" format="${4:-default}" 6 | 7 | if ! list="$("${_keepassxc_cli_with_options}" "${options}" ls --flatten --recursive "${keychain}" <<< "${keychain_password}" | { grep -v -e '/$' -e 'Recycle Bin/' || true; } | sort -f)" 8 | then 9 | echo "Error while reading the database ${keychain}: Invalid credentials were provided, please try again." >&2 10 | exit 1 11 | fi 12 | 13 | if [[ "${list}" != "[empty]" ]]; then 14 | case "${format}" in 15 | fzf) awk '{print $0 "\t\t\t" $0}' <<< "${list}" ;; 16 | *) echo "${list}" ;; 17 | esac 18 | fi 19 | -------------------------------------------------------------------------------- /plugins/keepassxc/open: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain="$2" 5 | 6 | open -a "KeePassXC" "${keychain}" 7 | -------------------------------------------------------------------------------- /plugins/keepassxc/rm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _keepassxc_cli_with_options="$(dirname "$0")/_keepassxc_cli_with_options" 4 | 5 | options="$1" keychain_password="$2" keychain="$3" name="$4" account="$5" url="$6" 6 | 7 | "${_keepassxc_cli_with_options}" "${options}" rm "${keychain}" "${name}" <<< "${keychain_password}" 8 | -------------------------------------------------------------------------------- /plugins/keepassxc/show: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _keepassxc_cli_with_options="$(dirname "$0")/_keepassxc_cli_with_options" 4 | 5 | options="$1" keychain_password="$2" keychain="$3" name="$4" account="$5" url="$6" 6 | 7 | "${_keepassxc_cli_with_options}" "${options}" show "${keychain}" "${name}" <<< "${keychain_password}" 8 | -------------------------------------------------------------------------------- /plugins/keepassxc/unlock: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _keepassxc_cli_with_options="$(dirname "$0")/_keepassxc_cli_with_options" 4 | 5 | options="$1" keychain="$2" 6 | 7 | "${_keepassxc_cli_with_options}" "${options}" open "${keychain}" 8 | -------------------------------------------------------------------------------- /plugins/macos_keychain/_parse_details: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # KCOV_EXCL_START 5 | # shellcheck disable=SC2016 6 | awk_cmd='BEGIN { FS="=" } 7 | /0x00000007 / { label = ($2 == "") ? "" : substr($2, 2, length($2) - 2) } 8 | /"acct"/ { account = ($2 == "") ? "" : substr($2, 2, length($2) - 2) } 9 | /"icmt"/ { 10 | comments = ($2 == "") ? "" : $2 11 | if (index(comments, "0x") == 1) { 12 | cmd = "echo " substr(comments, 1, index(comments, " ") - 1) " | xxd -r -p" 13 | comments = "" 14 | while ( ( cmd | getline result ) > 0 ) { 15 | comments = comments result "\n" 16 | } 17 | close(cmd) 18 | } else { 19 | comments = substr(comments, 2, length(comments) - 2) 20 | } 21 | } 22 | /"svce"/ { service = ($2 == "") ? "" : substr($2, 2, length($2) - 2);' 23 | # KCOV_EXCL_STOP 24 | 25 | awk "${awk_cmd} $1 }" 26 | -------------------------------------------------------------------------------- /plugins/macos_keychain/add: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain_password="$2" keychain="$3" password="$4" name="$5" account="$6" url="$7" notes="$8" 5 | 6 | [[ "${PW_MACOS_KEYCHAIN_ACCESS_CONTROL:-}" == "always-allow" ]] || access_control="confirm" 7 | 8 | security add-generic-password \ 9 | -l "${name:-"${url}"}" \ 10 | -a "${account}" \ 11 | -s "${url:-"${name}"}" \ 12 | ${notes:+-j "${notes}"} \ 13 | ${access_control:+-T ""} \ 14 | -w "${password}" \ 15 | "${keychain}" 16 | -------------------------------------------------------------------------------- /plugins/macos_keychain/edit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain_password="$2" keychain="$3" password="$4" name="$5" account="$6" url="$7" 5 | 6 | security add-generic-password -U \ 7 | -l "${name:-"${url}"}" \ 8 | -a "${account}" \ 9 | -s "${url:-"${name}"}" \ 10 | -w "${password}" \ 11 | "${keychain}" 12 | -------------------------------------------------------------------------------- /plugins/macos_keychain/fzf_preview: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _parse_details="$(dirname "$0")/_parse_details" 4 | 5 | options="$1" keychain_password="$2" keychain="$3" 6 | 7 | # KCOV_EXCL_START 8 | # shellcheck disable=SC1083 9 | _fzf_preview() { 10 | security find-generic-password -l {4} -a {5} -s {6} "${keychain}" \ 11 | | "${_parse_details}" 'printf "Name: %s\nAccount: %s\nWhere: %s\nComments:\n%s\n", label, account, service, comments' 12 | } 13 | # KCOV_EXCL_STOP 14 | 15 | declare -p keychain _parse_details 16 | declare -f _fzf_preview 17 | echo "_fzf_preview" 18 | -------------------------------------------------------------------------------- /plugins/macos_keychain/get: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain_password="$2" keychain="$3" name="$4" account="$5" url="$6" 5 | 6 | security find-generic-password \ 7 | ${name:+-l "${name}"} \ 8 | ${account:+-a "${account}"} \ 9 | ${url:+-s "${url}"} \ 10 | -w "${keychain}" 11 | -------------------------------------------------------------------------------- /plugins/macos_keychain/hook: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | FILE_TYPE="Mac OS X Keychain File" 5 | FILE_EXTENSION="keychain-db" 6 | 7 | case "$1" in 8 | discover_keychains) 9 | while read -r path; do 10 | [[ "$(file -b "${path}")" != "${FILE_TYPE}" ]] || echo "${path}" 11 | done < <(find "${PWD}" -maxdepth 1 -type f) 12 | ;; 13 | register_with_keychain) 14 | echo "${FILE_TYPE}" 15 | if [[ -f "$2" && "$(file -b "$2")" == "${FILE_TYPE}" ]]; then 16 | echo yes 17 | elif [[ -f "${HOME}/Library/Keychains/$2" ]]; then 18 | echo yes 19 | else 20 | echo no 21 | fi 22 | ;; 23 | register_with_extension) 24 | echo "${FILE_TYPE}" 25 | echo "${FILE_EXTENSION}" 26 | if [[ "$2" == "${FILE_EXTENSION}" ]]; then 27 | echo yes 28 | else 29 | echo no 30 | fi 31 | ;; 32 | esac 33 | -------------------------------------------------------------------------------- /plugins/macos_keychain/init: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain="$2" 5 | 6 | if [[ -p /dev/stdin ]]; then 7 | IFS= read -r password 8 | security create-keychain -p "${password}" "${keychain}" 9 | else 10 | security create-keychain "${keychain}" 11 | fi 12 | -------------------------------------------------------------------------------- /plugins/macos_keychain/keychain_password: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # this plugin does not require a cached keychain password 3 | -------------------------------------------------------------------------------- /plugins/macos_keychain/lock: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain="$2" 5 | 6 | security lock-keychain "${keychain}" 7 | -------------------------------------------------------------------------------- /plugins/macos_keychain/ls: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _parse_details="$(dirname "$0")/_parse_details" 4 | 5 | options="$1" keychain_password="$2" keychain="$3" format="${4:-default}" 6 | 7 | case "${format}" in 8 | fzf) printf_format='printf "%-24s\t%-24s\t%s\t%s\t%s\t%s\n", label, account, service, label, account, service' ;; 9 | *) printf_format='printf "%-24s\t%-24s\t%s\n", label, account, service' ;; 10 | esac 11 | 12 | security dump-keychain "${keychain}" | "${_parse_details}" "${printf_format}" | sort -f 13 | -------------------------------------------------------------------------------- /plugins/macos_keychain/open: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain="$2" 5 | 6 | if [[ -f "${keychain}" ]]; then 7 | open -a "Keychain Access" "${keychain}" 8 | elif [[ -f "${HOME}/Library/Keychains/${keychain}" ]]; then 9 | open -a "Keychain Access" "${HOME}/Library/Keychains/${keychain}" 10 | fi 11 | -------------------------------------------------------------------------------- /plugins/macos_keychain/rm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain_password="$2" keychain="$3" name="$4" account="$5" url="$6" 5 | 6 | security delete-generic-password \ 7 | ${name:+-l "${name}"} \ 8 | ${account:+-a "${account}"} \ 9 | ${url:+-s "${url}"} \ 10 | "${keychain}" >/dev/null 11 | -------------------------------------------------------------------------------- /plugins/macos_keychain/show: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | _parse_details="$(dirname "$0")/_parse_details" 4 | 5 | options="$1" keychain_password="$2" keychain="$3" name="$4" account="$5" url="$6" 6 | 7 | security find-generic-password \ 8 | ${name:+-l "${name}"} \ 9 | ${account:+-a "${account}"} \ 10 | ${url:+-s "${url}"} \ 11 | "${keychain}" \ 12 | | "${_parse_details}" 'printf "Name: %s\nAccount: %s\nWhere: %s\nComments:\n%s\n", label, account, service, comments' 13 | -------------------------------------------------------------------------------- /plugins/macos_keychain/unlock: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain="$2" 5 | 6 | if [[ -p /dev/stdin ]]; then 7 | IFS= read -r password 8 | security unlock-keychain -p "${password}" "${keychain}" 9 | else 10 | security unlock-keychain "${keychain}" 11 | fi 12 | -------------------------------------------------------------------------------- /plugins/parse_options: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=, 4 | for pair in $1; do 5 | printf "%s\t%s\n" "${pair%%=*}" "${pair#*=}" 6 | done 7 | -------------------------------------------------------------------------------- /readme/pw-fzf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sschmid/pw-terminal-password-manager/0d12aed8537917180d63dc0e46391f66e6e95140/readme/pw-fzf.png -------------------------------------------------------------------------------- /readme/pw-keychains.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sschmid/pw-terminal-password-manager/0d12aed8537917180d63dc0e46391f66e6e95140/readme/pw-keychains.png -------------------------------------------------------------------------------- /src/copy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | if [[ -v PW_COPY ]]; then 6 | eval "${PW_COPY}" 7 | exit 8 | fi 9 | 10 | if command -v pbcopy &>/dev/null; then pbcopy 11 | elif command -v xclip &>/dev/null; then xclip -selection clipboard 12 | elif command -v xsel &>/dev/null; then xsel --clipboard --input 13 | elif command -v wl-copy &>/dev/null; then wl-copy 14 | else 15 | echo "No clipboard tool found!" >&2 16 | echo "Supported tools: pbcopy, xclip, xsel, wl-copy" >&2 17 | exit 1 18 | fi 19 | -------------------------------------------------------------------------------- /src/migrations/pwconf-12.0.0: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # added May 2025 3 | # shellcheck disable=SC2174 4 | set -euo pipefail 5 | IFS=$'\n\t' 6 | 7 | config_home="${XDG_CONFIG_HOME:-"${HOME}/.config"}" 8 | old_pw_config="${config_home}/pw/config" 9 | new_pw_config="${config_home}/pw/pw.conf" 10 | mig_pw_config="${new_pw_config}.mig" 11 | 12 | if [[ -f "${old_pw_config}" ]] ; then 13 | if (( ! PW_YES )); then 14 | IFS= read -rp "pw 12.0.0 introduced a new config format and moved ${old_pw_config} to ${new_pw_config}. Would you like to automatically upgrade and move that file? (y / N): " answer >&2 15 | [[ "${answer}" == [yY] ]] || exit 1 16 | fi 17 | 18 | mv "${old_pw_config}" "${new_pw_config}.bak" 19 | 20 | rm -f "${mig_pw_config}" 21 | touch "${mig_pw_config}" 22 | 23 | migrate() { 24 | local line section="" 25 | while IFS= read -r line; do 26 | line="$(pw::trim <<< "${line}")" 27 | [[ -z "${line}" ]] && echo >> "${mig_pw_config}" && continue 28 | [[ "${line}" == "#"* || "${line}" == ";"* ]] && echo "${line}" >> "${mig_pw_config}" && continue 29 | 30 | case "${line}" in 31 | "[config]") section="general" ; echo "[general]" >> "${mig_pw_config}" ;; 32 | "[plugins]") section="plugins" ; echo "${line}" >> "${mig_pw_config}" ;; 33 | "[keychains]") section="keychains" ; echo "${line}" >> "${mig_pw_config}" ;; 34 | \[*\]) section="" ; echo "${line}" >> "${mig_pw_config}" ;; 35 | *) 36 | if [[ "${section}" == "plugins" ]] 37 | then echo "plugin = ${line}" >> "${mig_pw_config}" 38 | elif [[ "${section}" == "keychains" ]] 39 | then echo "keychain = ${line}" >> "${mig_pw_config}" 40 | else echo "${line}" >> "${mig_pw_config}" 41 | fi ;; 42 | esac 43 | done < "${new_pw_config}.bak" 44 | } 45 | 46 | migrate 47 | mv "${mig_pw_config}" "${new_pw_config}" 48 | fi 49 | -------------------------------------------------------------------------------- /src/migrations/pwrc-10.0.0: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # added Oct 2024 3 | set -euo pipefail 4 | IFS=$'\n\t' 5 | 6 | pw_rc="$1" 7 | 8 | if ! grep -qE '\[plugins\]|\[keychains\]' "${pw_rc}" >/dev/null ; then 9 | if (( ! PW_YES )); then 10 | IFS= read -rp "pw 10.0.0 introduced a new .pwrc format. Would you like to automatically upgrade your .pwrc file? (y / N): " answer >&2 11 | [[ "${answer}" == [yY] ]] || exit 1 12 | fi 13 | 14 | mapfile -t keychains < "${pw_rc}" 15 | 16 | cat << EOF > "${pw_rc}" 17 | [config] 18 | password_length = 35 19 | password_character_class = [:graph:] 20 | clipboard_clear_time = 45 21 | 22 | [plugins] 23 | \$PW_HOME/plugins/gpg 24 | \$PW_HOME/plugins/keepassxc 25 | \$PW_HOME/plugins/macos_keychain 26 | 27 | [keychains] 28 | EOF 29 | 30 | printf "\t%s\n" "${keychains[@]}" >> "${pw_rc}" 31 | fi 32 | -------------------------------------------------------------------------------- /src/migrations/pwrc-11.0.0: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # added May 2025 3 | # shellcheck disable=SC2174 4 | set -euo pipefail 5 | IFS=$'\n\t' 6 | 7 | pw_rc="$1" 8 | 9 | config_home="${XDG_CONFIG_HOME:-"${HOME}/.config"}" 10 | new_pw_config="${config_home}/pw/config" 11 | 12 | if (( ! PW_YES )); then 13 | IFS= read -rp "pw 11.0.0 moved ${pw_rc} to ${new_pw_config}. Would you like to automatically move that file? (y / N): " answer >&2 14 | [[ "${answer}" == [yY] ]] || exit 1 15 | fi 16 | 17 | mkdir -m 700 -p "${config_home}" 18 | mkdir -m 700 -p "${config_home}/pw" 19 | mv "${pw_rc}" "${config_home}/pw/config" 20 | -------------------------------------------------------------------------------- /src/migrations/pwrc-9.0.0: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # added Oct 2024 3 | set -euo pipefail 4 | IFS=$'\n\t' 5 | 6 | pw_rc="$1" 7 | 8 | if grep "PW_KEYCHAINS" "${pw_rc}" >/dev/null ; then 9 | if (( ! PW_YES )); then 10 | IFS= read -rp "pw 9.0.0 introduced a new .pwrc format. Would you like to automatically upgrade your .pwrc file? (y / N): " answer >&2 11 | [[ "${answer}" == [yY] ]] || exit 1 12 | fi 13 | 14 | # shellcheck disable=SC1090 15 | source "${pw_rc}" 16 | echo "${PW_KEYCHAINS[*]}" > "${pw_rc}" 17 | fi 18 | -------------------------------------------------------------------------------- /src/paste: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | if [[ -v PW_PASTE ]]; then 6 | eval "${PW_PASTE}" 7 | exit 8 | fi 9 | 10 | if command -v pbpaste &>/dev/null; then pbpaste 11 | elif command -v xclip &>/dev/null; then xclip -selection clipboard -o 12 | elif command -v xsel &>/dev/null; then xsel --clipboard --output 13 | elif command -v wl-paste &>/dev/null; then wl-paste 14 | else 15 | echo "No clipboard tool found!" >&2 16 | echo "Supported tools: pbpaste, xclip, xsel, wl-paste" >&2 17 | exit 1 18 | fi 19 | -------------------------------------------------------------------------------- /src/pw: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 🔐 pw - Terminal Password Manager 3 | set -euo pipefail 4 | IFS=$'\n\t' 5 | 6 | # make fzf preview use bash 7 | SHELL="$(type -p bash)" 8 | export SHELL 9 | 10 | PW_HOME="${BASH_SOURCE[0]}" 11 | while [[ -L "${PW_HOME}" ]]; do 12 | PW_HOME="$(readlink "${PW_HOME}")" 13 | done 14 | PW_HOME="$(cd "$(dirname "${PW_HOME}")/.." && pwd)" 15 | declare -rx PW_HOME 16 | declare -rx PW_CONFIG_HOME="${XDG_CONFIG_HOME:-"${HOME}/.config"}" 17 | 18 | pw::help() { 19 | cat << EOF 20 | 🔐 pw $(cat "${PW_HOME}/version.txt") - Terminal Password Manager 21 | 22 | usage: pw [-p] [-k ] [] [] 23 | 24 | options: 25 | -h show usage 26 | -p print instead of copy to clipboard 27 | -k use given keychain 28 | 29 | args: [] [] [] [] 30 | 31 | commands: 32 | [-p] [] copy (or print) password. If no args, fzf mode 33 | init create keychain 34 | add [] add entry. If no args, interactive mode 35 | edit [] edit entry. If no args, fzf mode 36 | show [-p] [] copy (or print) details. If no args, fzf mode 37 | rm [] remove entry. If no args, fzf mode 38 | ls list all entries 39 | gen [-p] [] [] generate password with given length and character class (default: 35 [:graph:]) 40 | open open keychain in native gui 41 | lock lock keychain 42 | unlock unlock keychain 43 | update update pw 44 | 45 | customization: 46 | PW_KEYCHAIN keychain to use when not specified with -k 47 | PW_GEN_LENGTH default length of generated passwords (default: 35) 48 | PW_GEN_CLASS default character class for generated passwords (default: [:graph:]) 49 | PW_CLIP_TIME time in seconds after which the password is cleared from the clipboard (default: 45) 50 | EOF 51 | } 52 | 53 | pw::trim() { 54 | local line 55 | IFS= read -r line || line="" 56 | line="${line#"${line%%[![:space:]]*}"}" 57 | line="${line%"${line##*[![:space:]]}"}" 58 | printf '%s' "${line}" 59 | } 60 | 61 | export -f pw::trim 62 | 63 | pw::parse_config() { 64 | [[ -z "$1" || -f "$1" ]] || pw::exit "pw: config file not found: $1" 65 | 66 | local config="${1:-"${PW_CONFIG_HOME}/pw/pw.conf"}" 67 | declare -ag PW_PLUGINS PW_KEYCHAINS 68 | 69 | # Migrate configs 70 | local pwrc="${PW_RC:-"${HOME}/.pwrc"}" 71 | if [[ -f "${pwrc}" ]]; then 72 | "${PW_HOME}/src/migrations/pwrc-9.0.0" "${pwrc}" 73 | "${PW_HOME}/src/migrations/pwrc-10.0.0" "${pwrc}" 74 | "${PW_HOME}/src/migrations/pwrc-11.0.0" "${pwrc}" 75 | fi 76 | 77 | "${PW_HOME}/src/migrations/pwconf-12.0.0" 78 | 79 | # shellcheck disable=SC2174 80 | if [[ ! -f "${config}" ]]; then 81 | mkdir -m 700 -p "${PW_CONFIG_HOME}" 82 | mkdir -m 700 -p "${PW_CONFIG_HOME}/pw" 83 | cp "${PW_HOME}/examples/pw.conf" "${config}" 84 | fi 85 | 86 | local line section key value 87 | while IFS= read -r line; do 88 | line="$(pw::trim <<< "${line}")" 89 | [[ -z ${line} ]] && continue 90 | [[ "${line}" == "#"* || "${line}" == ";"* ]] && continue 91 | # shellcheck disable=SC2016 92 | case "${line}" in 93 | "[general]") section="general" ;; 94 | "[plugins]") section="plugins" ;; 95 | "[keychains]") section="keychains" ;; 96 | \[*\]) section="" ;; 97 | *) 98 | key="$(pw::trim <<< "${line%%=*}")" 99 | value="$(pw::trim <<< "${line#*=}")" 100 | [[ -z ${value} ]] && continue 101 | if [[ "${section}" == "general" ]]; then 102 | case "${key}" in 103 | password_length) 104 | [[ -v PW_GEN_LENGTH ]] && continue 105 | local -i PW_GEN_LENGTH="${value}" ;; 106 | password_character_class) 107 | [[ -v PW_GEN_CLASS ]] && continue 108 | local PW_GEN_CLASS="${value}" ;; 109 | clipboard_clear_time) 110 | [[ -v PW_CLIP_TIME ]] && continue 111 | local -i PW_CLIP_TIME="${value}" ;; 112 | copy) 113 | [[ -v PW_COPY ]] && continue 114 | declare -rgx PW_COPY="${value}" ;; 115 | paste) 116 | [[ -v PW_PASTE ]] && continue 117 | declare -rgx PW_PASTE="${value}" ;; 118 | esac 119 | else 120 | value="${value/'~'/"${HOME}"}" 121 | value="${value/'$HOME'/"${HOME}"}" 122 | value="${value/'${HOME}'/"${HOME}"}" 123 | if [[ "${section}" == "plugins" ]]; then 124 | case "${key}" in 125 | plugin) 126 | value="${value//'$PW_HOME'/"${PW_HOME}"}" 127 | value="${value//'${PW_HOME}'/"${PW_HOME}"}" 128 | PW_PLUGINS+=("${value}") ;; 129 | esac 130 | elif [[ "${section}" == "keychains" ]]; then 131 | case "${key}" in 132 | keychain) 133 | [[ -z ${value} ]] && continue 134 | PW_KEYCHAINS+=("${value}") ;; 135 | esac 136 | fi 137 | fi 138 | ;; 139 | esac 140 | done < "${config}" 141 | 142 | declare -irgx PW_GEN_LENGTH="${PW_GEN_LENGTH:-35}" 143 | declare -rgx PW_GEN_CLASS="${PW_GEN_CLASS:-"[:graph:]"}" 144 | declare -irgx PW_CLIP_TIME="${PW_CLIP_TIME:-45}" 145 | } 146 | 147 | pw::set_keychain() { 148 | if [[ "$1" == *:* ]]; then 149 | PW_KEYCHAIN="${1%%:*}" 150 | PW_KEYCHAIN_OPTIONS="${1#*:}" 151 | else 152 | PW_KEYCHAIN="$1" 153 | PW_KEYCHAIN_OPTIONS="" 154 | fi 155 | 156 | [[ -n "${PW_KEYCHAIN}" ]] || pw::exit "pw: no keychain was set!" \ 157 | "Set a keychain with the -k option or provide a list of default keychains in ${PW_CONFIG_HOME}/pw/pw.conf." 158 | } 159 | 160 | pw::select_keychain() { 161 | if [[ -v PW_KEYCHAIN ]]; then 162 | pw::set_keychain "${PW_KEYCHAIN}" 163 | else 164 | local plugin keychains 165 | for plugin in "${PW_PLUGINS[@]}"; do 166 | keychains="$("${plugin}/hook" "discover_keychains")" 167 | [[ -z "${keychains}" ]] || PW_KEYCHAINS+=("${keychains}") 168 | done 169 | 170 | mapfile -t PW_KEYCHAINS < <(awk '!line[$0]++' <<< "${PW_KEYCHAINS[*]}" | sort -f) 171 | 172 | if (( ${#PW_KEYCHAINS[@]} == 1 )); then 173 | pw::set_keychain "${PW_KEYCHAINS[0]}" 174 | else 175 | local keychain 176 | keychain="$(fzf --prompt "keychain> " --layout reverse <<< "${PW_KEYCHAINS[*]}")" 177 | pw::set_keychain "${keychain}" 178 | fi 179 | fi 180 | 181 | local -a plugins=() types=() 182 | local plugin file_type register 183 | for plugin in "${PW_PLUGINS[@]}"; do 184 | while true; do 185 | read -r file_type 186 | read -r register 187 | types+=("${file_type}") 188 | [[ "${register}" == no ]] || plugins+=("${plugin}") 189 | break 190 | done < <("${plugin}/hook" "register_with_keychain" "${PW_KEYCHAIN}") 191 | done 192 | 193 | local -i n=${#plugins[@]} 194 | if (( n == 1 )); then 195 | PW_PLUGIN="${plugins[0]}" 196 | elif (( n > 1 )); then 197 | pw::exit "pw: Multiple plugins found for ${PW_KEYCHAIN}" "${plugins[*]}" 198 | else 199 | if [[ -f "${PW_KEYCHAIN}" ]]; then 200 | pw::exit "Could not detect plugin for ${PW_KEYCHAIN}" \ 201 | "Supported file types are:" "${types[*]}" 202 | else 203 | pw::exit "pw: ${PW_KEYCHAIN}: No such file or directory" 204 | fi 205 | fi 206 | } 207 | 208 | pw::infer_plugin() { 209 | pw::set_keychain "$1" 210 | local -a plugins=() types=() exts=() 211 | local plugin file_type file_extension register 212 | local -l extension="${PW_KEYCHAIN#*.}" 213 | for plugin in "${PW_PLUGINS[@]}"; do 214 | while true; do 215 | read -r file_type 216 | read -r file_extension 217 | read -r register 218 | types+=("${file_type}") 219 | exts+=("${file_extension}") 220 | [[ "${register}" == no ]] || plugins+=("${plugin}") 221 | break 222 | done < <("${plugin}/hook" "register_with_extension" "${extension}") 223 | done 224 | 225 | local -i n=${#plugins[@]} 226 | if (( n == 1 )); then 227 | PW_PLUGIN="${plugins[0]}" 228 | elif (( n > 1 )); then 229 | pw::exit "pw: Multiple plugins found for ${PW_KEYCHAIN}" "${plugins[*]}" 230 | else 231 | pw::exit "Could not detect plugin for ${PW_KEYCHAIN}" \ 232 | "Supported extensions are:" \ 233 | "$(for i in "${!exts[@]}"; do printf "%-13s - %s\n" "${exts[$i]}" "${types[$i]}"; done)" 234 | fi 235 | } 236 | 237 | declare -ix PW_PRINT=0 238 | 239 | pw::output() { 240 | if (( PW_PRINT )); then 241 | echo "$1" 242 | else 243 | local pname 244 | local -i pid 245 | pname="pw-$(id -u)" 246 | pid=$(pgrep -f "^${pname}" 2>/dev/null || true) 247 | if (( pid != 0 )); then 248 | kill -9 ${pid} &>/dev/null || true 249 | wait ${pid} &>/dev/null || true 250 | fi 251 | "${PW_HOME}/src/copy" <<< "$1" 252 | ( ( exec -a "${pname}" bash <<< "trap 'kill %1' TERM; sleep ${PW_CLIP_TIME} & wait " ) 253 | [[ "$("${PW_HOME}/src/paste")" == "$1" ]] && "${PW_HOME}/src/copy" <<< "" 254 | ) &>/dev/null & disown 255 | fi 256 | } 257 | 258 | export -f pw::output 259 | 260 | pw::gen() { 261 | local -i length=${1:-${PW_GEN_LENGTH}} 262 | local password="" class="${2:-"${PW_GEN_CLASS}"}" 263 | 264 | local -i block_size=$(( length / 32 )) 265 | (( block_size == 0 )) && block_size=1 266 | 267 | # Fix character classes for BusyBox tr 268 | [[ "${class}" == "[:graph:]" ]] && class="[:alnum:][:punct:]" 269 | [[ "${class}" == "[:print:]" ]] && class="[:alnum:][:punct:][:space:]" 270 | 271 | while (( "${#password}" != length )); do 272 | password+=$(dd if=/dev/urandom bs=${block_size} count=1 2>/dev/null | LC_CTYPE=C LC_ALL=C tr -dc "${class}" | head -c $(( length - ${#password} ))) 273 | done 274 | pw::output "${password}" 275 | } 276 | 277 | pw::update() { 278 | local branch="${1:-main}" 279 | pushd "${PW_HOME}" >/dev/null || exit 1 280 | git switch "${branch}" 281 | git pull 282 | popd >/dev/null || exit 1 283 | } 284 | 285 | # 286 | # BEGIN plugin 287 | # 288 | 289 | PW_NAME="" 290 | PW_ACCOUNT="" 291 | PW_URL="" 292 | PW_NOTES="" 293 | declare -i PW_FZF=0 294 | 295 | pw::plugin() { 296 | local cmd="$1"; shift 297 | "${PW_PLUGIN}/${cmd}" "${PW_KEYCHAIN_OPTIONS}" "$@" 298 | } 299 | 300 | pw::prompt_password() { 301 | local password 302 | if [[ -p /dev/stdin ]]; then 303 | IFS= read -r password 304 | else 305 | IFS= read -rsp "Enter password for '${PW_NAME}' (leave empty to generate password):"$'\n' password 306 | if [[ -n "${password}" ]]; then 307 | local retype 308 | IFS= read -rsp "Retype password for '${PW_NAME}':"$'\n' retype 309 | if [[ "${retype}" != "${password}" ]]; then 310 | pw::exit "Error: the entered passwords do not match." 311 | fi 312 | else 313 | PW_PRINT=1 password="$(pw::gen)" 314 | fi 315 | fi 316 | echo "${password}" 317 | } 318 | 319 | pw::init() { 320 | [[ -e "${PW_KEYCHAIN}" ]] && pw::exit "pw: ${PW_KEYCHAIN} already exists." 321 | pw::plugin init "${PW_KEYCHAIN}" 322 | } 323 | 324 | pw::add() { 325 | local keychain_password 326 | keychain_password="$(pw::plugin keychain_password "add" "${PW_KEYCHAIN}")" 327 | if (( $# )); then 328 | pw::select_item_with_prompt "add" "${keychain_password}" "$@" 329 | else 330 | IFS= read -rp "Title: " PW_NAME 331 | IFS= read -rp "Username: " PW_ACCOUNT 332 | IFS= read -rp "URL: " PW_URL 333 | echo "Notes: Enter multi-line input (end with Ctrl+D):" 334 | PW_NOTES=$(cat) 335 | fi 336 | 337 | local password 338 | password="$(pw::prompt_password)" 339 | pw::plugin add "${keychain_password}" "${PW_KEYCHAIN}" "${password}" \ 340 | "${PW_NAME}" "${PW_ACCOUNT}" "${PW_URL}" "${PW_NOTES}" 341 | } 342 | 343 | pw::edit() { 344 | local keychain_password 345 | keychain_password="$(pw::plugin keychain_password "edit" "${PW_KEYCHAIN}")" 346 | pw::select_item_with_prompt "edit" "${keychain_password}" "$@" 347 | local password 348 | password="$(pw::prompt_password)" 349 | pw::plugin edit "${keychain_password}" "${PW_KEYCHAIN}" "${password}" \ 350 | "${PW_NAME}" "${PW_ACCOUNT}" "${PW_URL}" 351 | } 352 | 353 | pw::get() { 354 | local keychain_password 355 | keychain_password="$(pw::plugin keychain_password "get" "${PW_KEYCHAIN}")" 356 | if (( PW_PRINT )) 357 | then pw::select_item_with_prompt "print" "${keychain_password}" "$@" 358 | else pw::select_item_with_prompt "copy" "${keychain_password}" "$@" 359 | fi 360 | local password 361 | password="$(pw::plugin get "${keychain_password}" "${PW_KEYCHAIN}" "${PW_NAME}" "${PW_ACCOUNT}" "${PW_URL}")" 362 | pw::output "${password}" 363 | } 364 | 365 | pw::show() { 366 | local keychain_password 367 | keychain_password="$(pw::plugin keychain_password "show" "${PW_KEYCHAIN}")" 368 | if (( PW_PRINT )) 369 | then pw::select_item_with_prompt "print details" "${keychain_password}" "$@" 370 | else pw::select_item_with_prompt "copy details" "${keychain_password}" "$@" 371 | fi 372 | local details 373 | details="$(pw::plugin show "${keychain_password}" "${PW_KEYCHAIN}" "${PW_NAME}" "${PW_ACCOUNT}" "${PW_URL}")" 374 | pw::output "${details}" 375 | } 376 | 377 | pw::rm() { 378 | local keychain_password 379 | keychain_password="$(pw::plugin keychain_password "rm" "${PW_KEYCHAIN}")" 380 | local -i remove=1 381 | pw::select_item_with_prompt "remove" "${keychain_password}" "$@" 382 | if (( PW_FZF )); then 383 | local answer 384 | IFS= read -rp "Do you really want to remove ${PW_NAME:+"'${PW_NAME}' "}${PW_ACCOUNT:+"'${PW_ACCOUNT}' "}from '${PW_KEYCHAIN}'? (y / N): " answer 385 | [[ "${answer}" == [yY] ]] || remove=0 386 | fi 387 | (( ! remove )) || pw::plugin rm "${keychain_password}" "${PW_KEYCHAIN}" "${PW_NAME}" "${PW_ACCOUNT}" "${PW_URL}" 388 | } 389 | 390 | pw::ls() { 391 | local keychain_password 392 | keychain_password="$(pw::plugin keychain_password "ls" "${PW_KEYCHAIN}")" 393 | pw::plugin ls "${keychain_password}" "${PW_KEYCHAIN}" "$@" 394 | } 395 | 396 | pw::select_item_with_prompt() { 397 | local fzf_prompt="$1" keychain_password="$2"; shift 2 398 | if (( $# )); then 399 | PW_NAME="$1" 400 | PW_ACCOUNT="${2:-}" 401 | PW_URL="${3:-}" 402 | # shellcheck disable=SC2034 403 | PW_NOTES="${4:-}" 404 | PW_FZF=0 405 | else 406 | # pretty printed values including whitespace for column formatting: 407 | # 1:name, 2:account, 3:url 408 | # actual values are stored in: 409 | # 4:name, 5:account, 6:url 410 | # fzf will use the pretty printed values 1..3 411 | # variables are set with the actual values 4..6 412 | local items item preview yank yank_action 413 | items="$(pw::plugin ls "${keychain_password}" "${PW_KEYCHAIN}" "fzf")" 414 | preview="$(pw::plugin fzf_preview "${keychain_password}" "${PW_KEYCHAIN}")" 415 | yank="pw::output \"\$(${preview})\"" 416 | (( PW_PRINT )) && yank_action="print" || yank_action="copy" 417 | 418 | # KCOV_EXCL_START 419 | item="$(fzf --prompt "${fzf_prompt}> " --layout reverse \ 420 | --delimiter '\t' --with-nth 1..3 --nth 1..3 \ 421 | --preview "${preview}" --preview-window hidden \ 422 | --header "?: toggle preview, CTRL-Y: ${yank_action} details" \ 423 | --bind "ctrl-y:execute(${yank})+abort" \ 424 | --bind '?:toggle-preview' <<< "${items}")" 425 | # KCOV_EXCL_STOP 426 | 427 | PW_NAME="$(awk -F '\t' '{print $4}' <<< "${item}")" 428 | PW_ACCOUNT="$(awk -F '\t' '{print $5}' <<< "${item}")" 429 | PW_URL="$(awk -F '\t' '{print $6}' <<< "${item}")" 430 | [[ -n "${PW_NAME}" || -n "${PW_ACCOUNT}" || -n "${PW_URL}" ]] || exit 1 431 | PW_FZF=1 432 | fi 433 | } 434 | 435 | # 436 | # END plugin 437 | # 438 | 439 | pw::exit() { 440 | printf "%s\n" "$@" >&2 441 | exit 1 442 | } 443 | 444 | pw::require_bash_version() { 445 | if [[ "$(printf '%s\n' "${BASH_VERSION}" "4.2" | sort -V | head -n 1)" != "4.2" ]]; then 446 | pw::exit "pw requires bash-4.2 or later. Installed: ${BASH_VERSION}" \ 447 | "Please install a newer version of bash." 448 | fi 449 | } 450 | 451 | pw::require_fzf() { 452 | command -v fzf >/dev/null || pw::exit "pw requires fzf. Please install fzf: https://github.com/junegunn/fzf" 453 | } 454 | 455 | declare -ix PW_YES=0 456 | 457 | main() { 458 | pw::require_bash_version 459 | pw::require_fzf 460 | 461 | local config="" options 462 | while getopts ":hypk:c:" options; do 463 | case "${options}" in 464 | h) pw::help; return ;; 465 | y) PW_YES=1 ;; 466 | p) PW_PRINT=1 ;; 467 | k) PW_KEYCHAIN="${OPTARG}" ;; 468 | c) config="${OPTARG}" ;; 469 | *) pw::exit "Invalid option: -${OPTARG}" ;; 470 | esac 471 | done 472 | shift $(( OPTIND - 1 )) 473 | 474 | pw::parse_config "${config}" 475 | 476 | if (( $# )); then 477 | case "$1" in 478 | init) shift; pw::infer_plugin "$@"; pw::init ;; 479 | add) shift; pw::select_keychain ; pw::add "$@" ;; 480 | edit) shift; pw::select_keychain ; pw::edit "$@" ;; 481 | show) shift; pw::select_keychain ; pw::show "$@" ;; 482 | rm) shift; pw::select_keychain ; pw::rm "$@" ;; 483 | ls) shift; pw::select_keychain ; pw::ls "$@" ;; 484 | gen) shift; pw::gen "$@" ;; 485 | open) shift; pw::select_keychain ; pw::plugin open "${PW_KEYCHAIN}" ;; 486 | lock) shift; pw::select_keychain ; pw::plugin lock "${PW_KEYCHAIN}" ;; 487 | unlock) shift; pw::select_keychain ; pw::plugin unlock "${PW_KEYCHAIN}" ;; 488 | update) shift; pw::update "$@" ;; 489 | *) pw::select_keychain ; pw::get "$@" ;; 490 | esac 491 | else 492 | pw::select_keychain ; pw::get 493 | fi 494 | } 495 | 496 | [[ "${BASH_SOURCE[0]}" != "$0" ]] || main "$@" 497 | -------------------------------------------------------------------------------- /test/coverage: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | [[ ! -f test/bats/bin/bats ]] && git submodule update --init --recursive 4 | kcov \ 5 | --dump-summary \ 6 | --bash-parser="$(which bash)" \ 7 | --include-path=src,test \ 8 | --exclude-path=test/bats,test/test_helper \ 9 | --exclude-line='done <,: #' \ 10 | --exclude-region='# KCOV_EXCL_START:# KCOV_EXCL_STOP' \ 11 | coverage \ 12 | test/bats/bin/bats "${@:-test}" 13 | -------------------------------------------------------------------------------- /test/fixtures/plugins/collision/hook: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | FILE_TYPE="Test Collision" 5 | FILE_EXTENSION="collision" 6 | 7 | case "$1" in 8 | discover_keychains) ;; 9 | register_with_keychain) 10 | echo "${FILE_TYPE}" 11 | if [[ -v PW_TEST_PLUGIN_COLLISION ]]; then 12 | echo yes 13 | else 14 | echo no 15 | fi 16 | ;; 17 | register_with_extension) 18 | echo "${FILE_TYPE}" 19 | echo "${FILE_EXTENSION}" 20 | if [[ -v PW_TEST_PLUGIN_COLLISION ]]; then 21 | echo yes 22 | else 23 | echo no 24 | fi 25 | ;; 26 | esac 27 | -------------------------------------------------------------------------------- /test/fixtures/plugins/test/add: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain_password="$2" keychain="$3" password="$4" name="$5" account="$6" url="$7" notes="$8" 5 | 6 | echo "test add <${options}> <${keychain_password}> <${keychain}> <${password}> <${name}> <${account}> <${url}> <${notes}>" 7 | -------------------------------------------------------------------------------- /test/fixtures/plugins/test/edit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain_password="$2" keychain="$3" password="$4" name="$5" account="$6" url="$7" 5 | 6 | echo "test edit <${options}> <${keychain_password}> <${keychain}> <${password}> <${name}> <${account}> <${url}>" 7 | -------------------------------------------------------------------------------- /test/fixtures/plugins/test/fzf_preview: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain_password="$2" keychain="$3" 5 | 6 | # shellcheck disable=SC2016 7 | echo '_=""; echo "${_:1:-1}"; echo "test fzf_preview <{4}> <{5}> <{6}> <${SHELL}>"' 8 | -------------------------------------------------------------------------------- /test/fixtures/plugins/test/get: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain_password="$2" keychain="$3" name="$4" account="$5" url="$6" 5 | 6 | echo "test get <${options}> <${keychain_password}> <${keychain}> <${name}> <${account}> <${url}>" 7 | -------------------------------------------------------------------------------- /test/fixtures/plugins/test/hook: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | FILE_TYPE="Test" 5 | FILE_EXTENSION="test" 6 | 7 | case "$1" in 8 | discover_keychains) 9 | if [[ -v PW_TEST_PLUGIN_DISCOVER_DUPLICATE ]]; then 10 | echo "duplicate discovered keychain.test" 11 | echo "duplicate discovered keychain.test" 12 | echo "duplicate discovered keychain.test" 13 | fi 14 | ;; 15 | register_with_keychain) 16 | echo "${FILE_TYPE}" 17 | if [[ $2 == *"keychain.test"* ]]; then 18 | echo yes 19 | else 20 | echo no 21 | fi 22 | ;; 23 | register_with_extension) 24 | echo "${FILE_TYPE}" 25 | echo "${FILE_EXTENSION}" 26 | if [[ "$2" == "${FILE_EXTENSION}" ]]; then 27 | echo yes 28 | else 29 | echo no 30 | fi 31 | ;; 32 | esac 33 | -------------------------------------------------------------------------------- /test/fixtures/plugins/test/init: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain="$2" 5 | 6 | echo "test init <${options}> <${keychain}>" 7 | -------------------------------------------------------------------------------- /test/fixtures/plugins/test/keychain_password: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # this plugin does not require a cached keychain password 3 | 4 | [[ ! -v PW_TEST_PLUGIN_KEYCHAIN_PASSWORD ]] || echo " keychain password " 5 | -------------------------------------------------------------------------------- /test/fixtures/plugins/test/lock: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain="$2" 5 | 6 | echo "test lock <${options}> <${keychain}>" 7 | -------------------------------------------------------------------------------- /test/fixtures/plugins/test/ls: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain_password="$2" keychain="$3" format="${4:-default}" 5 | 6 | [[ -v PW_TEST_PLUGIN_FAIL ]] && exit 1 7 | 8 | if [[ -v PW_TEST_PLUGIN_LS ]]; then 9 | echo -e "name 1\taccount 1\turl 1\tname 1\taccount 1\turl 1" 10 | echo -e "name 2\taccount 2\turl 2\tname 2\taccount 2\turl 2" 11 | echo -e "name 3\taccount 3\turl 3\tname 3\taccount 3\turl 3" 12 | else 13 | echo "test ls <${options}> <${keychain_password}> <${keychain}> <${format}>" 14 | fi 15 | -------------------------------------------------------------------------------- /test/fixtures/plugins/test/open: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain="$2" 5 | 6 | echo "test open <${options}> <${keychain}>" 7 | -------------------------------------------------------------------------------- /test/fixtures/plugins/test/rm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain_password="$2" keychain="$3" name="$4" account="$5" url="$6" 5 | 6 | echo "test rm <${options}> <${keychain_password}> <${keychain}> <${name}> <${account}> <${url}>" 7 | -------------------------------------------------------------------------------- /test/fixtures/plugins/test/show: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain_password="$2" keychain="$3" name="$4" account="$5" url="$6" 5 | 6 | echo "test show <${options}> <${keychain_password}> <${keychain}> <${name}> <${account}> <${url}>" 7 | -------------------------------------------------------------------------------- /test/fixtures/plugins/test/unlock: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | options="$1" keychain="$2" 5 | 6 | echo "test unlock <${options}> <${keychain}>" 7 | -------------------------------------------------------------------------------- /test/fixtures/pw_test_1.key: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sschmid/pw-terminal-password-manager/0d12aed8537917180d63dc0e46391f66e6e95140/test/fixtures/pw_test_1.key -------------------------------------------------------------------------------- /test/fixtures/pw_test_2.key: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sschmid/pw-terminal-password-manager/0d12aed8537917180d63dc0e46391f66e6e95140/test/fixtures/pw_test_2.key -------------------------------------------------------------------------------- /test/gpg-setup-teardown.bats: -------------------------------------------------------------------------------- 1 | setup_file() { 2 | export BATS_NO_PARALLELIZE_WITHIN_FILE=true 3 | export GNUPGHOME="${BATS_FILE_TMPDIR}/.gnupg" 4 | gpg --batch --pinentry-mode loopback --passphrase pw_test_password \ 5 | --import "${BATS_TEST_DIRNAME}/fixtures/pw_test_1.key" 6 | gpgconf --kill gpg-agent 7 | } 8 | 9 | setup() { 10 | load 'gpg' 11 | _setup 12 | # shellcheck disable=SC2016 13 | _config_append_with_plugin '$PW_HOME/plugins/gpg' 14 | } 15 | 16 | teardown() { 17 | gpgconf --kill gpg-agent 18 | } 19 | 20 | @test "creates keychain" { 21 | assert_dir_not_exists "${PW_KEYCHAIN}" 22 | run pw init "${PW_KEYCHAIN}" 23 | assert_success 24 | assert_dir_exists "${PW_KEYCHAIN}" 25 | 26 | run ls -ld "${PW_KEYCHAIN}" 27 | assert_success 28 | assert_output --partial "drwx------" 29 | } 30 | 31 | @test "deletes keychain" { 32 | pw init "${PW_KEYCHAIN}" 33 | run _delete_keychain 34 | assert_success 35 | assert_dir_not_exists "${PW_KEYCHAIN}" 36 | } 37 | 38 | @test "uses test key" { 39 | run gpg -K 40 | assert_success 41 | assert_output --partial "8F1F7B428DC46AD4AD2E5123691ED007F1E410B0" 42 | } 43 | -------------------------------------------------------------------------------- /test/gpg.bash: -------------------------------------------------------------------------------- 1 | _setup() { 2 | load 'test-helper' 3 | _common_setup 4 | export PW_KEYCHAIN="${BATS_TEST_TMPDIR}/pw gpg test/" 5 | } 6 | 7 | _delete_keychain() { 8 | rm -rf "${PW_KEYCHAIN}" 9 | } 10 | 11 | # sec ed25519/691ED007F1E410B0 2024-09-12 [C] 12 | # 8F1F7B428DC46AD4AD2E5123691ED007F1E410B0 13 | # uid [ unknown] pw_test_1 14 | # ssb cv25519/8593E03F5A33D9AC 2024-09-12 [E] 15 | 16 | # sec ed25519/5956BBFD659D6C4C 2024-09-12 [C] 17 | # 2F07F8722CE9FEF50DF247D25956BBFD659D6C4C 18 | # uid [ unknown] pw_test_2 19 | # ssb cv25519/634419040D678764 2024-09-12 [E] 20 | 21 | # password: pw_test_password 22 | -------------------------------------------------------------------------------- /test/gpg.bats: -------------------------------------------------------------------------------- 1 | # shellcheck disable=SC2030,SC2031 2 | setup_file() { 3 | export BATS_NO_PARALLELIZE_WITHIN_FILE=true 4 | export GNUPGHOME="${BATS_FILE_TMPDIR}/.gnupg" 5 | gpg --batch --pinentry-mode loopback --passphrase pw_test_password \ 6 | --import "${BATS_TEST_DIRNAME}/fixtures/pw_test_1.key" 7 | gpg --batch --pinentry-mode loopback --passphrase pw_test_password \ 8 | --import "${BATS_TEST_DIRNAME}/fixtures/pw_test_2.key" 9 | gpgconf --kill gpg-agent 10 | } 11 | 12 | setup() { 13 | load 'gpg' 14 | _setup 15 | _set_config_with_copy_paste 16 | # shellcheck disable=SC2016 17 | _config_append_with_plugin '$PW_HOME/plugins/gpg' 18 | KEYCHAIN_TEST_PASSWORD="pw_test_password" 19 | pw init "${PW_KEYCHAIN}" 20 | } 21 | 22 | teardown() { 23 | _delete_keychain 24 | gpgconf --kill gpg-agent 25 | } 26 | 27 | ################################################################################ 28 | # helpers 29 | ################################################################################ 30 | 31 | # shellcheck disable=SC2009 32 | _ps() { 33 | case "${OSTYPE}" in 34 | darwin*) ps -A | grep "gpg-agent --homedir ${GNUPGHOME}" | grep -v grep ;; 35 | linux*) ps -A | grep "gpg-agent" | grep -v grep ;; 36 | *) echo "Unsupported OS: ${OSTYPE}"; return 1 ;; 37 | esac 38 | } 39 | 40 | _gpg_decrypt() { 41 | gpg --quiet --batch --pinentry-mode loopback --passphrase "${KEYCHAIN_TEST_PASSWORD}" \ 42 | --decrypt "${PW_KEYCHAIN}/$1" | sed -n "$2" 43 | } 44 | 45 | ################################################################################ 46 | # assertions 47 | ################################################################################ 48 | 49 | assert_item_not_exists_output() { 50 | cat << EOF | assert_output - 51 | gpg: can't open '${PW_KEYCHAIN}/$1': No such file or directory 52 | gpg: decrypt_message failed: No such file or directory 53 | EOF 54 | } 55 | 56 | assert_item_already_exists_output() { 57 | assert_output "gpg: [stdin]: encryption failed: File exists" 58 | } 59 | 60 | assert_removes_item_output() { 61 | refute_output 62 | } 63 | 64 | assert_rm_not_found_output() { 65 | case "${OSTYPE}" in 66 | darwin*) assert_output "rm: ${PW_KEYCHAIN}/$1: No such file or directory" ;; 67 | linux-musl*) assert_output "rm: can't remove '${PW_KEYCHAIN}/$1': No such file or directory" ;; 68 | linux*) assert_output "rm: cannot remove '${PW_KEYCHAIN}/$1': No such file or directory" ;; 69 | *) echo "Unsupported OS: ${OSTYPE}"; return 1 ;; 70 | esac 71 | } 72 | 73 | assert_username() { 74 | run _gpg_decrypt "$1" 2p 75 | assert_success 76 | if (( $# == 2 )) 77 | then assert_output "$2" 78 | else refute_output 79 | fi 80 | } 81 | 82 | assert_url() { 83 | run _gpg_decrypt "$1" 3p 84 | assert_success 85 | if (( $# == 2 )) 86 | then assert_output "$2" 87 | else refute_output 88 | fi 89 | } 90 | 91 | assert_notes() { 92 | # shellcheck disable=SC2016 93 | run _gpg_decrypt "$1" '4,$p' 94 | assert_success 95 | if (( $# == 2 )) 96 | then assert_output "$2" 97 | else refute_output 98 | fi 99 | } 100 | 101 | assert_keyid() { 102 | local keychain_password="$1" path="$2" key_id="$3" 103 | run gpg --batch --pinentry-mode loopback --passphrase "${keychain_password}" \ 104 | --list-packets "${path}" 105 | assert_success 106 | assert_output --partial "keyid ${key_id}" 107 | } 108 | 109 | ################################################################################ 110 | # keychain password 111 | ################################################################################ 112 | 113 | @test "reads keychain password from stdin" { 114 | run "${PROJECT_ROOT}/plugins/gpg/keychain_password" "" "get" "${PW_KEYCHAIN}" <<< "stdin test" 115 | assert_success 116 | assert_output "stdin test" 117 | } 118 | 119 | ################################################################################ 120 | # init 121 | ################################################################################ 122 | 123 | @test "init fails when keychain already exists" { 124 | assert_init_already_exists 125 | } 126 | 127 | ################################################################################ 128 | # get 129 | ################################################################################ 130 | 131 | @test "doesn't have item" { 132 | assert_item_not_exists "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 133 | } 134 | 135 | ################################################################################ 136 | # add 137 | ################################################################################ 138 | 139 | @test "adds item with name" { 140 | assert_adds_item "${PW_1}" "${NAME_A}" 141 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 142 | assert_username "${NAME_A}" 143 | assert_url "${NAME_A}" 144 | assert_notes "${NAME_A}" 145 | } 146 | 147 | @test "adds item with name and account" { 148 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 149 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 150 | assert_username "${NAME_A}" "${ACCOUNT_A}" 151 | } 152 | 153 | @test "adds item with name and url" { 154 | assert_adds_item "${PW_1}" "${NAME_A}" "" "${URL_A}" 155 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 156 | assert_url "${NAME_A}" "${URL_A}" 157 | } 158 | 159 | @test "adds item with name and notes" { 160 | assert_adds_item "${PW_1}" "${NAME_A}" "" "" "${MULTI_LINE_NOTES}" 161 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 162 | assert_notes "${NAME_A}" "${MULTI_LINE_NOTES}" 163 | } 164 | 165 | @test "adds item in subfolder" { 166 | assert_adds_item "${PW_1}" "group/${NAME_A}" 167 | assert_item_exists "${PW_1}" "group/${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 168 | } 169 | 170 | @test "adds item in subfolder multiple levels deep" { 171 | assert_adds_item "${PW_1}" "group1/group2/group3/${NAME_A}" 172 | assert_item_exists "${PW_1}" "group1/group2/group3/${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 173 | } 174 | 175 | @test "adds item with .gpg extension" { 176 | assert_adds_item "${PW_1}" "${NAME_A}.gpg" 177 | assert_item_exists "${PW_1}" "${NAME_A}.gpg" <<< "${KEYCHAIN_TEST_PASSWORD}" 178 | run file -b "${PW_KEYCHAIN}/${NAME_A}.gpg" 179 | assert_output "data" 180 | } 181 | 182 | @test "adds item with .asc extension" { 183 | assert_adds_item "${PW_1}" "${NAME_A}.asc" 184 | assert_item_exists "${PW_1}" "${NAME_A}.asc" <<< "${KEYCHAIN_TEST_PASSWORD}" 185 | run file -b "${PW_KEYCHAIN}/${NAME_A}.asc" 186 | assert_output --partial "PGP message Public-Key Encrypted Session Key" 187 | } 188 | 189 | @test "adds item with key id" { 190 | local keychain="${PW_KEYCHAIN}" 191 | local key_id="8593E03F5A33D9AC" 192 | PW_KEYCHAIN="${keychain}:key=${key_id}" 193 | assert_adds_item "${PW_1}" "${NAME_A}" 194 | assert_keyid "${KEYCHAIN_TEST_PASSWORD}" "${keychain}/${NAME_A}" ${key_id} 195 | } 196 | 197 | ################################################################################ 198 | # add another 199 | ################################################################################ 200 | 201 | @test "adds item with different name" { 202 | assert_adds_item "${PW_1}" "${NAME_A}" 203 | assert_adds_item "${PW_2}" "${NAME_B}" 204 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 205 | assert_item_exists "${PW_2}" "${NAME_B}" <<< "${KEYCHAIN_TEST_PASSWORD}" 206 | } 207 | 208 | ################################################################################ 209 | # add duplicate 210 | ################################################################################ 211 | 212 | # bats test_tags=tag:manual_test 213 | @test "prompts for new filename when adding item with existing name" { 214 | _skip_manual_test "new filename: '${PW_KEYCHAIN}/new_name'" 215 | assert_adds_item "${PW_1}" "${NAME_A}" 216 | run pw add "${NAME_A}" <<< "${PW_2}" 217 | assert_success 218 | assert_file_exists "${PW_KEYCHAIN}/new_name" 219 | } 220 | 221 | ################################################################################ 222 | # show 223 | ################################################################################ 224 | 225 | @test "shows no item details" { 226 | assert_adds_item "${PW_1}" "${NAME_A}" 227 | run pw -p show "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 228 | assert_success 229 | assert_line --index 0 "Name: ${NAME_A}" 230 | assert_line --index 1 "Account: " 231 | assert_line --index 2 "URL: " 232 | assert_line --index 3 "Notes:" 233 | } 234 | 235 | @test "shows item details" { 236 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" 237 | run pw -p show "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 238 | assert_success 239 | cat << EOF | assert_output - 240 | Name: ${NAME_A} 241 | Account: ${ACCOUNT_A} 242 | URL: ${URL_A} 243 | Notes: 244 | ${MULTI_LINE_NOTES} 245 | EOF 246 | } 247 | 248 | @test "shows item details in group" { 249 | assert_adds_item "${PW_1}" "group/${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" 250 | run pw -p show "group/${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 251 | assert_success 252 | cat << EOF | assert_output - 253 | Name: ${NAME_A} 254 | Account: ${ACCOUNT_A} 255 | URL: ${URL_A} 256 | Notes: 257 | ${MULTI_LINE_NOTES} 258 | EOF 259 | } 260 | 261 | ################################################################################ 262 | # rm 263 | ################################################################################ 264 | 265 | @test "removes item" { 266 | assert_adds_item "${PW_1}" "${NAME_A}" 267 | assert_adds_item "${PW_2}" "${NAME_B}" 268 | assert_removes_item "${NAME_A}" 269 | assert_item_not_exists "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 270 | assert_item_exists "${PW_2}" "${NAME_B}" <<< "${KEYCHAIN_TEST_PASSWORD}" 271 | } 272 | 273 | ################################################################################ 274 | # rm non existing item 275 | ################################################################################ 276 | 277 | @test "fails when deleting non existing item" { 278 | assert_rm_not_found "${NAME_A}" 279 | } 280 | 281 | ################################################################################ 282 | # edit 283 | ################################################################################ 284 | 285 | @test "edits item" { 286 | assert_adds_item "${PW_1}" "${NAME_A}" 287 | assert_edits_item_with_keychain_password "${PW_2}" "${NAME_A}" 288 | assert_item_exists "${PW_2}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 289 | } 290 | 291 | @test "edits item and keeps account, url and notes" { 292 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" 293 | assert_edits_item_with_keychain_password "${PW_2}" "${NAME_A}" 294 | assert_item_exists "${PW_2}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 295 | assert_username "${NAME_A}" "${ACCOUNT_A}" 296 | assert_url "${NAME_A}" "${URL_A}" 297 | assert_notes "${NAME_A}" "${MULTI_LINE_NOTES}" 298 | } 299 | 300 | # shellcheck disable=SC2034 301 | @test "edits item with key id" { 302 | local keychain="${PW_KEYCHAIN}" 303 | PW_KEYCHAIN="${keychain}:key=634419040D678764" 304 | assert_adds_item "${PW_1}" "${NAME_A}" 305 | 306 | local key_id="8593E03F5A33D9AC" 307 | PW_KEYCHAIN="${keychain}:key=${key_id}" 308 | assert_edits_item_with_keychain_password "${PW_2}" "${NAME_A}" 309 | assert_keyid "${KEYCHAIN_TEST_PASSWORD}" "${keychain}/${NAME_A}" ${key_id} 310 | } 311 | 312 | ################################################################################ 313 | # edit non existing item 314 | ################################################################################ 315 | 316 | @test "fails when editing non existing item" { 317 | run pw edit "${NAME_A}" << EOF 318 | ${KEYCHAIN_TEST_PASSWORD} 319 | ${PW_2} 320 | EOF 321 | assert_failure 322 | assert_item_not_exists_output "${NAME_A}" 323 | assert_item_not_exists "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 324 | } 325 | 326 | ################################################################################ 327 | # list item 328 | ################################################################################ 329 | 330 | @test "lists no items" { 331 | run pw ls 332 | assert_success 333 | refute_output 334 | } 335 | 336 | @test "lists sorted items" { 337 | assert_adds_item "${PW_2}" "${NAME_B}" 338 | assert_adds_item "${PW_1}" "${NAME_A}" 339 | run pw ls 340 | assert_success 341 | cat << EOF | assert_output - 342 | ./${NAME_A} 343 | ./${NAME_B} 344 | EOF 345 | } 346 | 347 | @test "filters .DS_Store" { 348 | touch "${PW_KEYCHAIN}/.DS_Store" 349 | run pw ls 350 | assert_success 351 | refute_output 352 | } 353 | 354 | @test "lists sorted items with fzf format" { 355 | assert_adds_item "${PW_2}" "${NAME_B}" 356 | assert_adds_item "${PW_1}" "${NAME_A}" 357 | run pw ls fzf 358 | assert_success 359 | cat << EOF | assert_output - 360 | ./${NAME_A} ./${NAME_A} 361 | ./${NAME_B} ./${NAME_B} 362 | EOF 363 | } 364 | 365 | ################################################################################ 366 | # open 367 | ################################################################################ 368 | 369 | @test "opens keychain" { 370 | _skip_when_not_macos 371 | run pw open 372 | assert_success 373 | refute_output 374 | } 375 | 376 | ################################################################################ 377 | # lock 378 | ################################################################################ 379 | 380 | @test "unlocks keychain" { 381 | run _ps 382 | assert_failure 383 | refute_output 384 | 385 | run pw unlock <<< "${KEYCHAIN_TEST_PASSWORD}" 386 | assert_success 387 | refute_output 388 | 389 | run _ps 390 | assert_success 391 | assert_output 392 | } 393 | 394 | # bats test_tags=tag:manual_test 395 | @test "unlocks keychain and prompts keychain password" { 396 | _skip_manual_test "pw_test_password - Press enter to continue ..." 397 | read -rsp "Press enter to continue ..." 398 | 399 | run _ps 400 | assert_failure 401 | refute_output 402 | 403 | run pw unlock 404 | assert_success 405 | refute_output 406 | 407 | run _ps 408 | assert_success 409 | assert_output 410 | } 411 | 412 | ################################################################################ 413 | # fzf preview 414 | ################################################################################ 415 | 416 | @test "shows fzf preview" { 417 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" 418 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 419 | 420 | local cmd 421 | cmd="$("${PROJECT_ROOT}/plugins/gpg/fzf_preview" "" "" "${PW_KEYCHAIN}")" 422 | cmd=${cmd//\{4\}/"\"${NAME_A}\""} 423 | 424 | run eval "${cmd}" 425 | assert_success 426 | cat << EOF | assert_output - 427 | Name: ${NAME_A} 428 | Account: ${ACCOUNT_A} 429 | URL: ${URL_A} 430 | Notes: 431 | ${MULTI_LINE_NOTES} 432 | EOF 433 | } 434 | 435 | @test "shows fzf preview of item in group" { 436 | assert_adds_item "${PW_1}" "group/${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" 437 | assert_item_exists "${PW_1}" "group/${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 438 | 439 | local cmd 440 | cmd="$("${PROJECT_ROOT}/plugins/gpg/fzf_preview" "" "" "${PW_KEYCHAIN}")" 441 | cmd=${cmd//\{4\}/"\"group/${NAME_A}\""} 442 | 443 | run eval "${cmd}" 444 | assert_success 445 | cat << EOF | assert_output - 446 | Name: ${NAME_A} 447 | Account: ${ACCOUNT_A} 448 | URL: ${URL_A} 449 | Notes: 450 | ${MULTI_LINE_NOTES} 451 | EOF 452 | } 453 | 454 | # bats test_tags=tag:manual_test 455 | @test "yanks item to clipboard" { 456 | _skip_manual_test "yank 'NAME A' to clipboard" 457 | read -rsp "Press enter to continue ..." 458 | # fzf strips leading and trailing whitespace, so don't use variables here 459 | assert_adds_item "${PW_1}" "NAME A" "ACCOUNT A" "URL A" "${MULTI_LINE_NOTES}" 460 | assert_item_exists "${PW_1}" "NAME A" <<< "${KEYCHAIN_TEST_PASSWORD}" 461 | 462 | run pw unlock <<< "${KEYCHAIN_TEST_PASSWORD}" 463 | assert_success 464 | 465 | export PW_CLIP_TIME=1 466 | bats_require_minimum_version 1.5.0 467 | run -130 pw 468 | 469 | run _paste 470 | assert_success 471 | cat << EOF | assert_output - 472 | Name: NAME A 473 | Account: ACCOUNT A 474 | URL: URL A 475 | Notes: 476 | ${MULTI_LINE_NOTES} 477 | EOF 478 | } 479 | 480 | ################################################################################ 481 | # discover 482 | ################################################################################ 483 | 484 | @test "discovers no keychains" { 485 | run "${PROJECT_ROOT}/plugins/gpg/hook" "discover_keychains" 486 | assert_success 487 | refute_output 488 | } 489 | 490 | @test "discovers .gpg" { 491 | assert_adds_item "${PW_1}" "${NAME_A}.gpg" 492 | assert_item_exists "${PW_1}" "${NAME_A}.gpg" <<< "${KEYCHAIN_TEST_PASSWORD}" 493 | 494 | cd "${PW_KEYCHAIN}" 495 | run "${PROJECT_ROOT}/plugins/gpg/hook" "discover_keychains" 496 | assert_success 497 | assert_output "${PW_KEYCHAIN}" 498 | } 499 | 500 | @test "discovers .asc" { 501 | assert_adds_item "${PW_1}" "${NAME_A}.asc" 502 | assert_item_exists "${PW_1}" "${NAME_A}.asc" <<< "${KEYCHAIN_TEST_PASSWORD}" 503 | 504 | cd "${PW_KEYCHAIN}" 505 | run "${PROJECT_ROOT}/plugins/gpg/hook" "discover_keychains" 506 | assert_success 507 | assert_output "${PW_KEYCHAIN}" 508 | } 509 | -------------------------------------------------------------------------------- /test/keepassxc-setup-teardown.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load 'keepassxc' 3 | _setup 4 | # shellcheck disable=SC2016 5 | _config_append_with_plugin '$PW_HOME/plugins/keepassxc' 6 | } 7 | 8 | @test "creates keychain" { 9 | assert_file_not_exists "${PW_KEYCHAIN}" 10 | run pw init "${PW_KEYCHAIN}" <<< "${KEYCHAIN_TEST_PASSWORD}" 11 | assert_success 12 | assert_file_exists "${PW_KEYCHAIN}" 13 | } 14 | 15 | @test "deletes keychain" { 16 | pw init "${PW_KEYCHAIN}" <<< "${KEYCHAIN_TEST_PASSWORD}" 17 | run _delete_keychain 18 | assert_success 19 | assert_file_not_exists "${PW_KEYCHAIN}" 20 | } 21 | -------------------------------------------------------------------------------- /test/keepassxc.bash: -------------------------------------------------------------------------------- 1 | _setup() { 2 | load 'test-helper' 3 | _common_setup 4 | export PW_KEYCHAIN="${BATS_TEST_TMPDIR}/pw keepassxc test.kdbx" 5 | } 6 | 7 | _delete_keychain() { 8 | rm -f "${PW_KEYCHAIN}" 9 | } 10 | -------------------------------------------------------------------------------- /test/keepassxc.bats: -------------------------------------------------------------------------------- 1 | # shellcheck disable=SC2030,SC2031 2 | setup() { 3 | load 'keepassxc' 4 | _setup 5 | _set_config_with_copy_paste 6 | # shellcheck disable=SC2016 7 | _config_append_with_plugin '$PW_HOME/plugins/keepassxc' 8 | pw init "${PW_KEYCHAIN}" <<< "${KEYCHAIN_TEST_PASSWORD}" 9 | } 10 | 11 | teardown() { 12 | _delete_keychain 13 | } 14 | 15 | ################################################################################ 16 | # helpers 17 | ################################################################################ 18 | 19 | # shellcheck disable=SC2034 20 | _init_with_key_file() { 21 | local keyfile="${BATS_TEST_TMPDIR}/pw keepassxc test_keyfile" 22 | echo "pw keepassxc test_keyfile" > "${keyfile}" 23 | PW_KEYCHAIN="${BATS_TEST_TMPDIR}/pw keepassxc test_with_keyfile.kdbx:keyfile=${keyfile}" 24 | pw init "${PW_KEYCHAIN}" <<< "${KEYCHAIN_TEST_PASSWORD}" 25 | } 26 | 27 | _set_keychain() { 28 | if [[ "$1" == *:* ]]; then 29 | PW_KEYCHAIN="${1%%:*}" 30 | PW_KEYCHAIN_OPTIONS="${1#*:}" 31 | else 32 | PW_KEYCHAIN="$1" 33 | fi 34 | } 35 | 36 | ################################################################################ 37 | # assertions 38 | ################################################################################ 39 | 40 | assert_item_not_exists_output() { 41 | cat << EOF | assert_output - 42 | Could not find entry with path $1. 43 | keepassxc-cli: Error while running the command '${2:-show}' 44 | EOF 45 | } 46 | 47 | assert_item_already_exists_output() { 48 | cat << EOF | assert_output - 49 | Could not create entry with path $1. 50 | keepassxc-cli: Error while running the command 'add' 51 | EOF 52 | } 53 | 54 | assert_removes_item_output() { 55 | refute_output 56 | } 57 | 58 | assert_rm_not_found_output() { 59 | cat << EOF | assert_output - 60 | Entry $1 not found. 61 | keepassxc-cli: Error while running the command 'rm' 62 | EOF 63 | } 64 | 65 | assert_username() { 66 | run keepassxc-cli show -qsa username "${PW_KEYCHAIN}" "$1" <<< "${KEYCHAIN_TEST_PASSWORD}" 67 | assert_success 68 | if (( $# == 2 )) 69 | then assert_output "$2" 70 | else refute_output 71 | fi 72 | } 73 | 74 | assert_url() { 75 | run keepassxc-cli show -qsa url "${PW_KEYCHAIN}" "$1" <<< "${KEYCHAIN_TEST_PASSWORD}" 76 | assert_success 77 | if (( $# == 2 )) 78 | then assert_output "$2" 79 | else refute_output 80 | fi 81 | } 82 | 83 | assert_notes() { 84 | run keepassxc-cli show -qsa notes "${PW_KEYCHAIN}" "$1" <<< "${KEYCHAIN_TEST_PASSWORD}" 85 | assert_success 86 | if (( $# == 2 )) 87 | then assert_output "$2" 88 | else refute_output 89 | fi 90 | } 91 | 92 | assert_item_recycled() { 93 | local password="$1"; shift 94 | run pw -p "/Recycle Bin/$1" 95 | assert_success 96 | assert_output "${password}" 97 | } 98 | 99 | ################################################################################ 100 | # keychain password 101 | ################################################################################ 102 | 103 | @test "reads keychain password from stdin" { 104 | run "${PROJECT_ROOT}/plugins/keepassxc/keychain_password" "" "" "${PW_KEYCHAIN}" <<< "stdin test" 105 | assert_success 106 | assert_output "stdin test" 107 | } 108 | 109 | # bats test_tags=tag:manual_test 110 | @test "prompts keychain password when no stdin" { 111 | _skip_manual_test "'test'" 112 | run "${PROJECT_ROOT}/plugins/keepassxc/keychain_password" "" "" "${PW_KEYCHAIN}" 113 | assert_success 114 | cat << EOF | assert_output - 115 | Enter password to unlock ${PW_KEYCHAIN}: 116 | test 117 | EOF 118 | } 119 | 120 | ################################################################################ 121 | # init 122 | ################################################################################ 123 | 124 | @test "init fails when keychain already exists" { 125 | assert_init_already_exists <<< "${KEYCHAIN_TEST_PASSWORD}" 126 | } 127 | 128 | # bats test_tags=tag:manual_test 129 | @test "inits keychain and prompts keychain password" { 130 | _skip_manual_test "'test' twice" 131 | PW_KEYCHAIN="${BATS_TEST_TMPDIR}/manual pw keepassxc test.kdbx" 132 | run pw init "${PW_KEYCHAIN}" 133 | assert_success 134 | assert_file_exists "${PW_KEYCHAIN}" 135 | } 136 | 137 | ################################################################################ 138 | # get 139 | ################################################################################ 140 | 141 | @test "doesn't have item" { 142 | assert_item_not_exists "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 143 | } 144 | 145 | @test "get with key-file" { 146 | _init_with_key_file 147 | assert_item_not_exists "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 148 | } 149 | 150 | ################################################################################ 151 | # add 152 | ################################################################################ 153 | 154 | @test "adds item with name" { 155 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" 156 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 157 | assert_username "${NAME_A}" 158 | assert_url "${NAME_A}" 159 | assert_notes "${NAME_A}" 160 | } 161 | 162 | @test "adds item with name and account" { 163 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 164 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 165 | assert_username "${NAME_A}" "${ACCOUNT_A}" 166 | } 167 | 168 | @test "adds item with name and url" { 169 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" "" "${URL_A}" 170 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 171 | assert_url "${NAME_A}" "${URL_A}" 172 | } 173 | 174 | @test "adds item with name and notes" { 175 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" "" "" "${MULTI_LINE_NOTES}" 176 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 177 | assert_notes "${NAME_A}" "${MULTI_LINE_NOTES}" 178 | } 179 | 180 | @test "adds item in subfolder" { 181 | assert_adds_item_with_keychain_password "${PW_1}" "group/${NAME_A}" 182 | assert_item_exists "${PW_1}" "group/${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 183 | } 184 | 185 | @test "adds item in subfolder multiple levels deep" { 186 | assert_adds_item_with_keychain_password "${PW_1}" "group1/group2/group3/${NAME_A}" 187 | assert_item_exists "${PW_1}" "group1/group2/group3/${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 188 | } 189 | 190 | @test "adds item with key-file" { 191 | _init_with_key_file 192 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" 193 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 194 | } 195 | 196 | # bats test_tags=tag:manual_test 197 | @test "prompts keychain password" { 198 | _skip_manual_test "'${KEYCHAIN_TEST_PASSWORD}'" 199 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" 200 | 201 | run pw ls 202 | assert_success 203 | cat << EOF | assert_output - 204 | Enter password to unlock ${PW_KEYCHAIN}: 205 | ${NAME_A} 206 | EOF 207 | } 208 | 209 | ################################################################################ 210 | # add another 211 | ################################################################################ 212 | 213 | @test "adds item with different name" { 214 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" 215 | assert_adds_item_with_keychain_password "${PW_2}" "${NAME_B}" 216 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 217 | assert_item_exists "${PW_2}" "${NAME_B}" <<< "${KEYCHAIN_TEST_PASSWORD}" 218 | } 219 | 220 | ################################################################################ 221 | # add duplicate 222 | ################################################################################ 223 | 224 | @test "fails when adding item with existing name" { 225 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" 226 | assert_item_already_exists_with_keychain_password "${PW_2}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 227 | } 228 | 229 | ################################################################################ 230 | # show 231 | ################################################################################ 232 | 233 | @test "shows no item details" { 234 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" 235 | run pw -p show "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 236 | assert_success 237 | assert_line --index 0 "Title: ${NAME_A}" 238 | assert_line --index 1 "UserName: " 239 | assert_line --index 2 "Password: PROTECTED" 240 | assert_line --index 3 "URL: " 241 | assert_line --index 4 "Notes: " 242 | } 243 | 244 | @test "shows item details" { 245 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" 246 | run pw -p show "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 247 | assert_success 248 | cat << EOF | assert_output --partial - 249 | Title: ${NAME_A} 250 | UserName: ${ACCOUNT_A} 251 | Password: PROTECTED 252 | URL: ${URL_A} 253 | Notes: ${MULTI_LINE_NOTES} 254 | EOF 255 | } 256 | 257 | @test "shows item details in group" { 258 | assert_adds_item_with_keychain_password "${PW_1}" "group/${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" 259 | run pw -p show "group/${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 260 | assert_success 261 | cat << EOF | assert_output --partial - 262 | Title: ${NAME_A} 263 | UserName: ${ACCOUNT_A} 264 | Password: PROTECTED 265 | URL: ${URL_A} 266 | Notes: ${MULTI_LINE_NOTES} 267 | EOF 268 | } 269 | 270 | ################################################################################ 271 | # rm 272 | ################################################################################ 273 | 274 | @test "removes item" { 275 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" 276 | assert_adds_item_with_keychain_password "${PW_2}" "${NAME_B}" 277 | assert_removes_item "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 278 | assert_item_recycled "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 279 | assert_item_exists "${PW_2}" "${NAME_B}" <<< "${KEYCHAIN_TEST_PASSWORD}" 280 | } 281 | 282 | @test "removes item in subfolder multiple levels deep" { 283 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" 284 | assert_adds_item_with_keychain_password "${PW_2}" "group1/${NAME_A}" 285 | assert_adds_item_with_keychain_password "${PW_3}" "group1/group2/${NAME_A}" 286 | 287 | assert_removes_item "group1/${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 288 | assert_item_recycled "${PW_2}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 289 | 290 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 291 | assert_item_exists "${PW_3}" "group1/group2/${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 292 | } 293 | 294 | @test "removes item with key-file" { 295 | _init_with_key_file 296 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" 297 | assert_removes_item "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 298 | } 299 | 300 | ################################################################################ 301 | # rm non existing item 302 | ################################################################################ 303 | 304 | @test "fails when deleting non existing item" { 305 | assert_rm_not_found "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 306 | } 307 | 308 | ################################################################################ 309 | # edit 310 | ################################################################################ 311 | 312 | @test "edits item" { 313 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" 314 | assert_edits_item_with_keychain_password "${PW_2}" "${NAME_A}" 315 | assert_item_exists "${PW_2}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 316 | } 317 | 318 | @test "edits item and keeps account, url and notes" { 319 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" 320 | assert_edits_item_with_keychain_password "${PW_2}" "${NAME_A}" 321 | assert_item_exists "${PW_2}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 322 | assert_username "${NAME_A}" "${ACCOUNT_A}" 323 | assert_url "${NAME_A}" "${URL_A}" 324 | assert_notes "${NAME_A}" "${MULTI_LINE_NOTES}" 325 | } 326 | 327 | @test "edits item with key-file" { 328 | _init_with_key_file 329 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" 330 | assert_edits_item_with_keychain_password "${PW_2}" "${NAME_A}" 331 | } 332 | 333 | ################################################################################ 334 | # edit non existing item 335 | ################################################################################ 336 | 337 | @test "fails when editing non existing item" { 338 | run pw edit "${NAME_A}" << EOF 339 | ${KEYCHAIN_TEST_PASSWORD} 340 | ${PW_2} 341 | EOF 342 | assert_failure 343 | assert_item_not_exists_output "${NAME_A}" "edit" 344 | assert_item_not_exists "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 345 | } 346 | 347 | ################################################################################ 348 | # list item 349 | ################################################################################ 350 | 351 | @test "lists no items" { 352 | run pw ls <<< "${KEYCHAIN_TEST_PASSWORD}" 353 | assert_success 354 | refute_output 355 | } 356 | 357 | @test "lists sorted items" { 358 | assert_adds_item_with_keychain_password "${PW_2}" "${NAME_B}" "${ACCOUNT_A}" 359 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 360 | run pw ls <<< "${KEYCHAIN_TEST_PASSWORD}" 361 | assert_success 362 | cat << EOF | assert_output - 363 | ${NAME_A} 364 | ${NAME_B} 365 | EOF 366 | } 367 | 368 | @test "filters Recycle Bin/" { 369 | assert_adds_item_with_keychain_password "${PW_2}" "${NAME_B}" "${ACCOUNT_A}" 370 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 371 | run pw rm "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 372 | run pw ls <<< "${KEYCHAIN_TEST_PASSWORD}" 373 | assert_success 374 | cat << EOF | assert_output - 375 | ${NAME_B} 376 | EOF 377 | } 378 | 379 | @test "lists no items after filtering" { 380 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 381 | run pw rm "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 382 | run pw ls <<< "${KEYCHAIN_TEST_PASSWORD}" 383 | assert_success 384 | refute_output 385 | } 386 | 387 | @test "lists no items when wrong keychain password" { 388 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 389 | run pw ls <<< "wrong" 390 | assert_failure 391 | cat << EOF | assert_output - 392 | keepassxc-cli: Error while running the command 'ls' 393 | Error while reading the database ${PW_KEYCHAIN}: Invalid credentials were provided, please try again. 394 | EOF 395 | } 396 | 397 | @test "lists sorted items with key-file" { 398 | _init_with_key_file 399 | assert_adds_item_with_keychain_password "${PW_2}" "${NAME_B}" "${ACCOUNT_A}" 400 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 401 | run pw ls <<< "${KEYCHAIN_TEST_PASSWORD}" 402 | assert_success 403 | cat << EOF | assert_output - 404 | ${NAME_A} 405 | ${NAME_B} 406 | EOF 407 | } 408 | 409 | @test "lists sorted items with fzf format" { 410 | assert_adds_item_with_keychain_password "${PW_2}" "${NAME_B}" "${ACCOUNT_A}" 411 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 412 | run pw ls fzf <<< "${KEYCHAIN_TEST_PASSWORD}" 413 | assert_success 414 | cat << EOF | assert_output - 415 | ${NAME_A} ${NAME_A} 416 | ${NAME_B} ${NAME_B} 417 | EOF 418 | } 419 | 420 | ################################################################################ 421 | # lock 422 | ################################################################################ 423 | 424 | @test "lock not implemented" { 425 | run pw lock 426 | assert_success 427 | assert_output "not available for keepassxc" 428 | } 429 | 430 | ################################################################################ 431 | # fzf preview 432 | ################################################################################ 433 | 434 | @test "shows fzf preview" { 435 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" 436 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 437 | 438 | local cmd 439 | cmd="$("${PROJECT_ROOT}/plugins/keepassxc/fzf_preview" "" "${KEYCHAIN_TEST_PASSWORD}" "${PW_KEYCHAIN}")" 440 | cmd=${cmd//\{4\}/"\"${NAME_A}\""} 441 | 442 | run eval "${cmd}" 443 | assert_success 444 | cat << EOF | assert_output --partial - 445 | Title: ${NAME_A} 446 | UserName: ${ACCOUNT_A} 447 | Password: PROTECTED 448 | URL: ${URL_A} 449 | Notes: ${MULTI_LINE_NOTES} 450 | EOF 451 | } 452 | 453 | @test "shows fzf preview with key-file" { 454 | _init_with_key_file 455 | assert_adds_item_with_keychain_password "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" 456 | assert_item_exists "${PW_1}" "${NAME_A}" <<< "${KEYCHAIN_TEST_PASSWORD}" 457 | 458 | _set_keychain "${PW_KEYCHAIN}" 459 | local cmd 460 | cmd="$("${PROJECT_ROOT}/plugins/keepassxc/fzf_preview" "${PW_KEYCHAIN_OPTIONS}" "${KEYCHAIN_TEST_PASSWORD}" "${PW_KEYCHAIN}")" 461 | cmd=${cmd//\{4\}/"\"${NAME_A}\""} 462 | 463 | run eval "${cmd}" 464 | assert_success 465 | cat << EOF | assert_output --partial - 466 | Title: ${NAME_A} 467 | UserName: ${ACCOUNT_A} 468 | Password: PROTECTED 469 | URL: ${URL_A} 470 | Notes: ${MULTI_LINE_NOTES} 471 | EOF 472 | } 473 | 474 | # bats test_tags=tag:manual_test 475 | @test "yanks item to clipboard" { 476 | _skip_manual_test "yank 'NAME A' to clipboard" 477 | read -rsp "Press enter to continue ..." 478 | # fzf strips leading and trailing whitespace, so don't use variables here 479 | assert_adds_item_with_keychain_password "${PW_1}" "NAME A" "ACCOUNT A" "URL A" "${MULTI_LINE_NOTES}" 480 | assert_item_exists "${PW_1}" "NAME A" <<< "${KEYCHAIN_TEST_PASSWORD}" 481 | 482 | export PW_CLIP_TIME=1 483 | bats_require_minimum_version 1.5.0 484 | run -130 pw <<< "${KEYCHAIN_TEST_PASSWORD}" 485 | 486 | run _paste 487 | assert_success 488 | cat << EOF | assert_output --partial - 489 | Title: NAME A 490 | UserName: ACCOUNT A 491 | Password: PROTECTED 492 | URL: URL A 493 | Notes: ${MULTI_LINE_NOTES} 494 | EOF 495 | } 496 | 497 | ################################################################################ 498 | # discover 499 | ################################################################################ 500 | 501 | @test "discovers no keychains" { 502 | run "${PROJECT_ROOT}/plugins/keepassxc/hook" "discover_keychains" 503 | assert_success 504 | refute_output 505 | } 506 | 507 | @test "discovers keychains" { 508 | cd "${BATS_TEST_TMPDIR}" 509 | run "${PROJECT_ROOT}/plugins/keepassxc/hook" "discover_keychains" 510 | assert_success 511 | assert_output "${PW_KEYCHAIN}" 512 | } 513 | -------------------------------------------------------------------------------- /test/macos_keychain-setup-teardown.bats: -------------------------------------------------------------------------------- 1 | if [[ "$OSTYPE" == "darwin"* ]]; then 2 | 3 | setup() { 4 | load 'macos_keychain' 5 | _setup 6 | # shellcheck disable=SC2016 7 | _config_append_with_plugin '$PW_HOME/plugins/macos_keychain' 8 | } 9 | 10 | @test "creates keychain" { 11 | assert_file_not_exists "${PW_KEYCHAIN}" 12 | run pw init "${PW_KEYCHAIN}" <<< "${KEYCHAIN_TEST_PASSWORD}" 13 | assert_success 14 | assert_file_exists "${PW_KEYCHAIN}" 15 | } 16 | 17 | @test "doesn't create keychain in ~/Library/Keychains" { 18 | pw init "${PW_KEYCHAIN}" <<< "${KEYCHAIN_TEST_PASSWORD}" 19 | run ls ~/Library/Keychains 20 | refute_output --partial "$(basename "${PW_KEYCHAIN}")" 21 | } 22 | 23 | @test "deletes keychain" { 24 | pw init "${PW_KEYCHAIN}" <<< "${KEYCHAIN_TEST_PASSWORD}" 25 | run _delete_keychain 26 | assert_success 27 | assert_file_not_exists "${PW_KEYCHAIN}" 28 | } 29 | 30 | fi 31 | -------------------------------------------------------------------------------- /test/macos_keychain.bash: -------------------------------------------------------------------------------- 1 | _setup() { 2 | load 'test-helper' 3 | _common_setup 4 | export PW_KEYCHAIN="${BATS_TEST_TMPDIR}/pw macos_keychain test.keychain-db" 5 | } 6 | 7 | _delete_keychain() { 8 | security delete-keychain "${PW_KEYCHAIN}" 9 | } 10 | -------------------------------------------------------------------------------- /test/macos_keychain.bats: -------------------------------------------------------------------------------- 1 | # shellcheck disable=SC2030,SC2031 2 | 3 | if [[ "$OSTYPE" == "darwin"* ]]; then 4 | 5 | setup() { 6 | load 'macos_keychain' 7 | _setup 8 | _set_config_with_copy_paste 9 | # shellcheck disable=SC2016 10 | _config_append_with_plugin '$PW_HOME/plugins/macos_keychain' 11 | pw init "${PW_KEYCHAIN}" <<< "${KEYCHAIN_TEST_PASSWORD}" 12 | export PW_MACOS_KEYCHAIN_ACCESS_CONTROL="always-allow" 13 | } 14 | 15 | teardown() { 16 | _delete_keychain 17 | } 18 | 19 | ################################################################################ 20 | # assertions 21 | ################################################################################ 22 | 23 | assert_item_not_exists_output() { 24 | assert_output "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain." 25 | } 26 | 27 | assert_item_already_exists_output() { 28 | assert_output "security: SecKeychainItemCreateFromContent (${PW_KEYCHAIN}): The specified item already exists in the keychain." 29 | } 30 | 31 | assert_removes_item_output() { 32 | assert_output "password has been deleted." 33 | } 34 | 35 | assert_rm_not_found_output() { 36 | assert_output "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain." 37 | } 38 | 39 | ################################################################################ 40 | # keychain password 41 | ################################################################################ 42 | 43 | @test "doesn't have a cached keychain password" { 44 | run "${PROJECT_ROOT}/plugins/macos_keychain/keychain_password" "" "" "${PW_KEYCHAIN}" <<< "stdin test" 45 | assert_success 46 | refute_output 47 | } 48 | 49 | ################################################################################ 50 | # init 51 | ################################################################################ 52 | 53 | @test "init fails when keychain already exists" { 54 | assert_init_already_exists <<< "${KEYCHAIN_TEST_PASSWORD}" 55 | } 56 | 57 | # bats test_tags=tag:manual_test 58 | @test "inits keychain and prompts keychain password" { 59 | _skip_manual_test "'test' twice" 60 | PW_KEYCHAIN="${BATS_TEST_TMPDIR}/manual pw macos_keychain test.keychain-db" 61 | run pw init "${PW_KEYCHAIN}" 62 | assert_success 63 | assert_file_exists "${PW_KEYCHAIN}" 64 | } 65 | 66 | ################################################################################ 67 | # get 68 | ################################################################################ 69 | 70 | @test "doesn't have item with name" { 71 | assert_item_not_exists "${NAME_A}" 72 | } 73 | 74 | @test "doesn't have item with account" { 75 | assert_item_not_exists "" "${ACCOUNT_A}" 76 | } 77 | 78 | @test "doesn't have item with name and account" { 79 | assert_item_not_exists "${NAME_A}" "${ACCOUNT_A}" 80 | } 81 | 82 | ################################################################################ 83 | # add 84 | ################################################################################ 85 | 86 | @test "adds item with name" { 87 | assert_adds_item "${PW_1}" "${NAME_A}" 88 | assert_item_exists "${PW_1}" "${NAME_A}" 89 | } 90 | 91 | @test "adds item with account" { 92 | assert_adds_item "${PW_1}" "" "${ACCOUNT_A}" 93 | assert_item_exists "${PW_1}" "" "${ACCOUNT_A}" 94 | } 95 | 96 | @test "adds item with name and account" { 97 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 98 | assert_item_exists "${PW_1}" "${NAME_A}" 99 | assert_item_exists "${PW_1}" "" "${ACCOUNT_A}" 100 | assert_item_exists "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 101 | } 102 | 103 | @test "adds item with name and url" { 104 | assert_adds_item "${PW_1}" "${NAME_A}" "" "${URL_A}" 105 | assert_item_exists "${PW_1}" "${NAME_A}" 106 | assert_item_exists "${PW_1}" "" "" "${URL_A}" 107 | assert_item_exists "${PW_1}" "${NAME_A}" "" "${URL_A}" 108 | } 109 | 110 | @test "label swizzling: name-only is label and service" { 111 | assert_adds_item "${PW_1}" "${NAME_A}" 112 | assert_item_exists "${PW_1}" "${NAME_A}" 113 | 114 | run security find-generic-password -l "${NAME_A}" -w "${PW_KEYCHAIN}" 115 | assert_success 116 | assert_output "${PW_1}" 117 | 118 | run security find-generic-password -s "${NAME_A}" -w "${PW_KEYCHAIN}" 119 | assert_success 120 | assert_output "${PW_1}" 121 | } 122 | 123 | @test "label swizzling: url-only is label and service" { 124 | assert_adds_item "${PW_1}" "" "" "${URL_A}" 125 | assert_item_exists "${PW_1}" "" "" "${URL_A}" 126 | 127 | run security find-generic-password -s "${URL_A}" -w "${PW_KEYCHAIN}" 128 | assert_success 129 | assert_output "${PW_1}" 130 | 131 | run security find-generic-password -l "${URL_A}" -w "${PW_KEYCHAIN}" 132 | assert_success 133 | assert_output "${PW_1}" 134 | } 135 | 136 | @test "label swizzling: name and url are label and service" { 137 | assert_adds_item "${PW_1}" "${NAME_A}" "" "${URL_A}" 138 | 139 | run security find-generic-password -l "${NAME_A}" -w "${PW_KEYCHAIN}" 140 | assert_success 141 | assert_output "${PW_1}" 142 | 143 | run security find-generic-password -s "${URL_A}" -w "${PW_KEYCHAIN}" 144 | assert_success 145 | assert_output "${PW_1}" 146 | } 147 | 148 | @test "adds item with name and single line notes" { 149 | assert_adds_item "${PW_1}" "${NAME_A}" "" "" "${SINGLE_LINE_NOTES}" 150 | assert_item_exists "${PW_1}" "${NAME_A}" 151 | 152 | # shellcheck disable=SC2317 153 | _get_note() { 154 | local comments 155 | comments="$(security find-generic-password -j "${SINGLE_LINE_NOTES}" -g "${PW_KEYCHAIN}" 2>&1 \ 156 | | awk 'BEGIN { FS="=" } /"icmt"/ { print ($2 == "") ? "" : $2 }')" 157 | echo "${comments:1:-1}" 158 | } 159 | 160 | run _get_note 161 | assert_success 162 | cat << EOF | assert_output - 163 | ${SINGLE_LINE_NOTES} 164 | EOF 165 | } 166 | 167 | @test "adds item with name and multiline notes" { 168 | assert_adds_item "${PW_1}" "${NAME_A}" "" "" "${MULTI_LINE_NOTES}" 169 | assert_item_exists "${PW_1}" "${NAME_A}" 170 | 171 | _get_note() { 172 | security find-generic-password -j "${MULTI_LINE_NOTES}" -g "${PW_KEYCHAIN}" 2>&1 \ 173 | | awk 'BEGIN { FS="=" } /"icmt"/ { print ($2 == "") ? "" : $2 }' \ 174 | | xxd -r -p 175 | } 176 | 177 | run _get_note 178 | assert_success 179 | cat << EOF | assert_output - 180 | ${MULTI_LINE_NOTES} 181 | EOF 182 | } 183 | 184 | ################################################################################ 185 | # add another 186 | ################################################################################ 187 | 188 | @test "adds item with different name" { 189 | assert_adds_item "${PW_1}" "${NAME_A}" 190 | assert_adds_item "${PW_2}" "${NAME_B}" 191 | assert_item_exists "${PW_1}" "${NAME_A}" 192 | assert_item_exists "${PW_2}" "${NAME_B}" 193 | } 194 | 195 | @test "adds item with different account" { 196 | assert_adds_item "${PW_1}" "" "${ACCOUNT_A}" 197 | assert_adds_item "${PW_2}" "" "${ACCOUNT_B}" 198 | assert_item_exists "${PW_1}" "" "${ACCOUNT_A}" 199 | assert_item_exists "${PW_2}" "" "${ACCOUNT_B}" 200 | } 201 | 202 | @test "adds item with different name and same account" { 203 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 204 | assert_adds_item "${PW_2}" "${NAME_B}" "${ACCOUNT_A}" 205 | 206 | assert_item_exists "${PW_1}" "${NAME_A}" 207 | assert_item_exists "${PW_2}" "${NAME_B}" 208 | 209 | assert_item_exists "${PW_1}" "" "${ACCOUNT_A}" 210 | 211 | assert_item_exists "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 212 | assert_item_exists "${PW_2}" "${NAME_B}" "${ACCOUNT_A}" 213 | } 214 | 215 | @test "adds item with same name and different account" { 216 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 217 | assert_adds_item "${PW_2}" "${NAME_A}" "${ACCOUNT_B}" 218 | 219 | assert_item_exists "${PW_1}" "${NAME_A}" 220 | 221 | assert_item_exists "${PW_1}" "" "${ACCOUNT_A}" 222 | assert_item_exists "${PW_2}" "" "${ACCOUNT_B}" 223 | 224 | assert_item_exists "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 225 | assert_item_exists "${PW_2}" "${NAME_A}" "${ACCOUNT_B}" 226 | } 227 | 228 | @test "adds item with different url" { 229 | assert_adds_item "${PW_1}" "${NAME_A}" "" "${URL_A}" 230 | assert_adds_item "${PW_2}" "${NAME_A}" "" "${URL_B}" 231 | assert_item_exists "${PW_1}" "" "" "${URL_A}" 232 | assert_item_exists "${PW_2}" "" "" "${URL_B}" 233 | } 234 | 235 | ################################################################################ 236 | # add duplicate 237 | ################################################################################ 238 | 239 | @test "fails when adding item with existing name" { 240 | assert_adds_item "${PW_1}" "${NAME_A}" 241 | assert_item_already_exists "${PW_2}" "${NAME_A}" 242 | } 243 | 244 | @test "fails when adding item with existing account" { 245 | assert_adds_item "${PW_1}" "" "${ACCOUNT_A}" 246 | assert_item_already_exists "${PW_2}" "" "${ACCOUNT_A}" 247 | } 248 | 249 | @test "fails when adding item with existing name and account" { 250 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 251 | assert_item_already_exists "${PW_2}" "${NAME_A}" "${ACCOUNT_A}" 252 | } 253 | 254 | ################################################################################ 255 | # show 256 | ################################################################################ 257 | 258 | @test "shows no item details even when locked" { 259 | assert_adds_item "${PW_1}" "${NAME_A}" 260 | pw lock 261 | 262 | run pw -p show "${NAME_A}" 263 | assert_success 264 | assert_line --index 0 "Name: ${NAME_A}" 265 | assert_line --index 1 "Account: " 266 | assert_line --index 2 "Where: ${NAME_A}" 267 | assert_line --index 3 "Comments:" 268 | } 269 | 270 | @test "shows item details even when locked" { 271 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" 272 | pw lock 273 | 274 | run pw -p show "${NAME_A}" 275 | assert_success 276 | cat << EOF | assert_output - 277 | Name: ${NAME_A} 278 | Account: ${ACCOUNT_A} 279 | Where: ${URL_A} 280 | Comments: 281 | ${MULTI_LINE_NOTES} 282 | EOF 283 | } 284 | 285 | ################################################################################ 286 | # rm 287 | ################################################################################ 288 | 289 | @test "removes item with name" { 290 | assert_adds_item "${PW_1}" "${NAME_A}" 291 | assert_adds_item "${PW_2}" "${NAME_B}" 292 | assert_removes_item "${NAME_A}" 293 | assert_item_not_exists "${NAME_A}" 294 | assert_item_exists "${PW_2}" "${NAME_B}" 295 | } 296 | 297 | @test "removes item with account" { 298 | assert_adds_item "${PW_1}" "" "${ACCOUNT_A}" 299 | assert_adds_item "${PW_2}" "" "${ACCOUNT_B}" 300 | assert_removes_item "" "${ACCOUNT_A}" 301 | assert_item_not_exists "" "${ACCOUNT_A}" 302 | assert_item_exists "${PW_2}" "" "${ACCOUNT_B}" 303 | } 304 | 305 | @test "removes item with name and account" { 306 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 307 | assert_adds_item "${PW_2}" "${NAME_B}" "${ACCOUNT_A}" 308 | assert_adds_item "${PW_3}" "${NAME_A}" "${ACCOUNT_B}" 309 | assert_removes_item "${NAME_A}" "${ACCOUNT_A}" 310 | assert_item_not_exists "${NAME_A}" "${ACCOUNT_A}" 311 | assert_item_exists "${PW_2}" "${NAME_B}" "${ACCOUNT_A}" 312 | assert_item_exists "${PW_3}" "${NAME_A}" "${ACCOUNT_B}" 313 | } 314 | 315 | @test "removes item with url" { 316 | assert_adds_item "${PW_1}" "${NAME_A}" "" "${URL_A}" 317 | assert_adds_item "${PW_2}" "${NAME_B}" "" "${URL_B}" 318 | assert_removes_item "" "" "${URL_A}" 319 | assert_item_not_exists "" "" "${URL_A}" 320 | assert_item_exists "${PW_2}" "" "" "${URL_B}" 321 | } 322 | 323 | @test "removes item with name and url" { 324 | assert_adds_item "${PW_1}" "${NAME_A}" "" "${URL_A}" 325 | assert_adds_item "${PW_2}" "${NAME_B}" "" "${URL_B}" 326 | assert_removes_item "${NAME_A}" "" "${URL_A}" 327 | assert_item_not_exists "${NAME_A}" 328 | assert_item_exists "${PW_2}" "${NAME_B}" 329 | } 330 | 331 | ################################################################################ 332 | # rm non existing item 333 | ################################################################################ 334 | 335 | @test "fails when deleting non existing item with name" { 336 | assert_rm_not_found "${NAME_A}" 337 | } 338 | 339 | @test "fails when deleting non existing item with account" { 340 | assert_rm_not_found "" "${ACCOUNT_A}" 341 | } 342 | 343 | @test "fails when deleting non existing item with name and account" { 344 | assert_rm_not_found "${NAME_A}" "${ACCOUNT_A}" 345 | } 346 | 347 | ################################################################################ 348 | # edit 349 | ################################################################################ 350 | 351 | @test "edits item with name" { 352 | assert_adds_item "${PW_1}" "${NAME_A}" 353 | assert_edits_item "${PW_2}" "${NAME_A}" 354 | assert_item_exists "${PW_2}" "${NAME_A}" 355 | } 356 | 357 | @test "edits item with account" { 358 | assert_adds_item "${PW_1}" "" "${ACCOUNT_A}" 359 | assert_edits_item "${PW_2}" "" "${ACCOUNT_A}" 360 | assert_item_exists "${PW_2}" "" "${ACCOUNT_A}" 361 | } 362 | 363 | @test "edits item with name and account" { 364 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" 365 | assert_edits_item "${PW_2}" "${NAME_A}" "${ACCOUNT_A}" 366 | assert_item_exists "${PW_2}" "${NAME_A}" "${ACCOUNT_A}" 367 | } 368 | 369 | @test "edits item with url" { 370 | assert_adds_item "${PW_1}" "${NAME_A}" "" "${URL_A}" 371 | assert_edits_item "${PW_2}" "" "" "${URL_A}" 372 | assert_item_exists "${PW_2}" "" "" "${URL_A}" 373 | } 374 | 375 | @test "edits item with name and url" { 376 | assert_adds_item "${PW_1}" "${NAME_A}" "" "${URL_A}" 377 | assert_edits_item "${PW_2}" "${NAME_A}" "" "${URL_A}" 378 | assert_item_exists "${PW_2}" "${NAME_A}" "" "${URL_A}" 379 | } 380 | 381 | ################################################################################ 382 | # edit non existing item 383 | ################################################################################ 384 | 385 | @test "adds item when editing non existing item with name" { 386 | assert_edits_item "${PW_2}" "${NAME_A}" 387 | assert_item_exists "${PW_2}" "${NAME_A}" 388 | } 389 | 390 | @test "adds item when editing non existing item with account" { 391 | assert_edits_item "${PW_2}" "" "${ACCOUNT_A}" 392 | assert_item_exists "${PW_2}" "" "${ACCOUNT_A}" 393 | } 394 | 395 | @test "adds item when editing non existing item with name and account" { 396 | assert_edits_item "${PW_2}" "${NAME_A}" "${ACCOUNT_A}" 397 | assert_item_exists "${PW_2}" "${NAME_A}" "${ACCOUNT_A}" 398 | } 399 | 400 | @test "adds item when editing non existing item with url" { 401 | assert_edits_item "${PW_2}" "" "" "${URL_A}" 402 | assert_item_exists "${PW_2}" "" "" "${URL_A}" 403 | } 404 | 405 | @test "adds item when editing non existing item with name and url" { 406 | assert_edits_item "${PW_2}" "${NAME_A}" "" "${URL_A}" 407 | assert_item_exists "${PW_2}" "${NAME_A}" "" "${URL_A}" 408 | } 409 | 410 | ################################################################################ 411 | # list item 412 | ################################################################################ 413 | 414 | @test "lists no items" { 415 | run pw ls 416 | assert_success 417 | refute_output 418 | } 419 | 420 | @test "lists sorted items" { 421 | assert_adds_item "${PW_2}" "${NAME_B}" "${ACCOUNT_B}" "${URL_B}" 422 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" 423 | run pw ls 424 | assert_success 425 | cat << EOF | assert_output - 426 | ${NAME_A} ${ACCOUNT_A} ${URL_A} 427 | ${NAME_B} ${ACCOUNT_B} ${URL_B} 428 | EOF 429 | } 430 | 431 | @test "ls handles name" { 432 | assert_adds_item "${PW_1}" "" "${ACCOUNT_A}" 433 | run pw ls 434 | assert_success 435 | assert_output " ${ACCOUNT_A} " 436 | } 437 | 438 | @test "ls handles account" { 439 | assert_adds_item "${PW_1}" "${NAME_A}" 440 | run pw ls 441 | assert_success 442 | assert_output "${NAME_A} ${NAME_A}" 443 | } 444 | 445 | @test "ls handles = in name" { 446 | assert_adds_item "${PW_1}" "te=st" 447 | run pw ls 448 | assert_success 449 | assert_output "te=st te=st" 450 | } 451 | 452 | @test "lists sorted items with fzf format" { 453 | assert_adds_item "${PW_2}" "${NAME_B}" "${ACCOUNT_B}" "${URL_B}" 454 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" 455 | run pw ls fzf 456 | assert_success 457 | cat << EOF | assert_output - 458 | ${NAME_A} ${ACCOUNT_A} ${URL_A} ${NAME_A} ${ACCOUNT_A} ${URL_A} 459 | ${NAME_B} ${ACCOUNT_B} ${URL_B} ${NAME_B} ${ACCOUNT_B} ${URL_B} 460 | EOF 461 | } 462 | 463 | ################################################################################ 464 | # lock 465 | ################################################################################ 466 | 467 | @test "unlocks keychain" { 468 | run security show-keychain-info "${PW_KEYCHAIN}" 469 | assert_success 470 | assert_output "Keychain \"${PW_KEYCHAIN}\" lock-on-sleep timeout=300s" 471 | 472 | run pw lock 473 | assert_success 474 | refute_output 475 | 476 | run pw unlock <<< "${KEYCHAIN_TEST_PASSWORD}" 477 | assert_success 478 | refute_output 479 | 480 | run security show-keychain-info "${PW_KEYCHAIN}" 481 | assert_success 482 | assert_output "Keychain \"${PW_KEYCHAIN}\" lock-on-sleep timeout=300s" 483 | } 484 | 485 | # bats test_tags=tag:manual_test 486 | @test "unlocks keychain and prompts keychain password" { 487 | _skip_manual_test "'${KEYCHAIN_TEST_PASSWORD}'" 488 | 489 | run pw lock 490 | assert_success 491 | refute_output 492 | 493 | run pw unlock 494 | assert_success 495 | refute_output 496 | 497 | run security show-keychain-info "${PW_KEYCHAIN}" 498 | assert_success 499 | assert_output "Keychain \"${PW_KEYCHAIN}\" lock-on-sleep timeout=300s" 500 | } 501 | 502 | ################################################################################ 503 | # fzf preview 504 | ################################################################################ 505 | 506 | @test "shows fzf preview for single line notes even when locked" { 507 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${SINGLE_LINE_NOTES}" 508 | assert_item_exists "${PW_1}" "${NAME_A}" 509 | pw lock 510 | 511 | local cmd 512 | cmd="$("${PROJECT_ROOT}/plugins/macos_keychain/fzf_preview" "" "" "${PW_KEYCHAIN}")" 513 | cmd=${cmd//\{4\}/"\"${NAME_A}\""} 514 | cmd=${cmd//\{5\}/"\"${ACCOUNT_A}\""} 515 | cmd=${cmd//\{6\}/"\"${URL_A}\""} 516 | 517 | run eval "${cmd}" 518 | assert_success 519 | cat << EOF | assert_output - 520 | Name: ${NAME_A} 521 | Account: ${ACCOUNT_A} 522 | Where: ${URL_A} 523 | Comments: 524 | ${SINGLE_LINE_NOTES} 525 | EOF 526 | } 527 | 528 | @test "shows fzf preview for multiline notes" { 529 | assert_adds_item "${PW_1}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" 530 | assert_item_exists "${PW_1}" "${NAME_A}" 531 | 532 | local cmd 533 | cmd="$("${PROJECT_ROOT}/plugins/macos_keychain/fzf_preview" "" "" "${PW_KEYCHAIN}")" 534 | cmd=${cmd//\{4\}/"\"${NAME_A}\""} 535 | cmd=${cmd//\{5\}/"\"${ACCOUNT_A}\""} 536 | cmd=${cmd//\{6\}/"\"${URL_A}\""} 537 | 538 | run eval "${cmd}" 539 | assert_success 540 | cat << EOF | assert_output - 541 | Name: ${NAME_A} 542 | Account: ${ACCOUNT_A} 543 | Where: ${URL_A} 544 | Comments: 545 | ${MULTI_LINE_NOTES} 546 | EOF 547 | } 548 | 549 | # bats test_tags=tag:manual_test 550 | @test "yanks item to clipboard" { 551 | _skip_manual_test "yank 'NAME A' to clipboard" 552 | read -rsp "Press enter to continue ..." 553 | # fzf strips leading and trailing whitespace, so don't use variables here 554 | assert_adds_item "${PW_1}" "NAME A" "ACCOUNT A" "URL A" "${MULTI_LINE_NOTES}" 555 | assert_item_exists "${PW_1}" "NAME A" 556 | 557 | export PW_CLIP_TIME=1 558 | bats_require_minimum_version 1.5.0 559 | run -130 pw 560 | 561 | run _paste 562 | assert_success 563 | cat << EOF | assert_output - 564 | Name: NAME A 565 | Account: ACCOUNT A 566 | Where: URL A 567 | Comments: 568 | ${MULTI_LINE_NOTES} 569 | EOF 570 | } 571 | 572 | ################################################################################ 573 | # discover 574 | ################################################################################ 575 | 576 | @test "discovers no keychains" { 577 | run "${PROJECT_ROOT}/plugins/macos_keychain/hook" "discover_keychains" 578 | assert_success 579 | refute_output 580 | } 581 | 582 | @test "discovers keychains" { 583 | cd "${BATS_TEST_TMPDIR}" 584 | run "${PROJECT_ROOT}/plugins/macos_keychain/hook" "discover_keychains" 585 | assert_success 586 | assert_output "${PW_KEYCHAIN}" 587 | } 588 | 589 | fi 590 | -------------------------------------------------------------------------------- /test/pw-clip.bats: -------------------------------------------------------------------------------- 1 | export BATS_NO_PARALLELIZE_WITHIN_FILE=true 2 | 3 | setup() { 4 | load 'pw' 5 | _setup 6 | _set_config_with_copy_paste 7 | _config_append_with_test_plugins 8 | export PW_KEYCHAIN="${BATS_TEST_TMPDIR}/test keychain.test" 9 | export PW_CLIP_TIME=1 10 | } 11 | 12 | _wait() { sleep 2; } 13 | 14 | @test "copies item password" { 15 | run pw "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" 16 | assert_success 17 | run _paste 18 | assert_success 19 | assert_output "test get <> <> <${PW_KEYCHAIN}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}>" 20 | } 21 | 22 | @test "clears clipboard after copying item password" { 23 | run pw "${NAME_A}" 24 | assert_success 25 | _wait 26 | run _paste 27 | refute_output 28 | } 29 | 30 | @test "doesn't clear clipboard after copying item password when changed" { 31 | run pw "${NAME_A}" 32 | assert_success 33 | echo -n "after" | _copy 34 | _wait 35 | run _paste 36 | assert_output "after" 37 | } 38 | 39 | @test "copies item details" { 40 | run pw show "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" 41 | assert_success 42 | run _paste 43 | assert_success 44 | assert_output "test show <> <> <${PW_KEYCHAIN}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}>" 45 | } 46 | 47 | @test "clears clipboard after copying item details" { 48 | run pw show "${NAME_A}" 49 | assert_success 50 | _wait 51 | run _paste 52 | refute_output 53 | } 54 | 55 | @test "doesn't clear clipboard after copying item details when changed" { 56 | run pw show "${NAME_A}" 57 | assert_success 58 | echo -n "after" | _copy 59 | _wait 60 | run _paste 61 | assert_output "after" 62 | } 63 | 64 | @test "generates and copies password" { 65 | export PW_GEN_LENGTH=5 66 | export PW_GEN_CLASS="1" 67 | run pw gen 68 | assert_success 69 | refute_output 70 | run _paste 71 | assert_output "11111" 72 | } 73 | 74 | @test "clears clipboard after generating password" { 75 | run pw gen 76 | _wait 77 | run _paste 78 | refute_output 79 | } 80 | 81 | @test "doesn't clear clipboard after generating password when changed" { 82 | run pw gen 83 | echo -n "after" | _copy 84 | _wait 85 | run _paste 86 | assert_output "after" 87 | } 88 | 89 | @test "env vars override config" { 90 | export PW_COPY="cat > ${BATS_TEST_TMPDIR}/my_clipboard" 91 | export PW_PASTE="cat ${BATS_TEST_TMPDIR}/my_clipboard" 92 | run pw "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" 93 | assert_success 94 | 95 | run cat "${BATS_TEST_TMPDIR}/my_clipboard" 96 | assert_success 97 | assert_output "test get <> <> <${PW_KEYCHAIN}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}>" 98 | } 99 | -------------------------------------------------------------------------------- /test/pw-keychain.bats: -------------------------------------------------------------------------------- 1 | # shellcheck disable=SC2030,SC2031 2 | setup() { 3 | load 'pw' 4 | _setup 5 | _config_append_with_test_plugins 6 | TEST_KEYCHAIN="test keychain.test" 7 | KEYCHAIN_OPTIONS="key1=value1,key2=value2" 8 | } 9 | 10 | @test "picks single keychain" { 11 | _config_append_keychains "${TEST_KEYCHAIN}" 12 | run pw ls 13 | assert_success 14 | assert_output "test ls <> <> <${TEST_KEYCHAIN}> " 15 | } 16 | 17 | @test "picks single keychain and separates options" { 18 | _config_append_keychains "${TEST_KEYCHAIN}:${KEYCHAIN_OPTIONS}" 19 | run pw ls 20 | assert_success 21 | assert_output "test ls <${KEYCHAIN_OPTIONS}> <> <${TEST_KEYCHAIN}> " 22 | } 23 | 24 | @test "removes duplicates" { 25 | _config_append_keychains "${TEST_KEYCHAIN}" "${TEST_KEYCHAIN}" 26 | run pw ls 27 | assert_success 28 | assert_output "test ls <> <> <${TEST_KEYCHAIN}> " 29 | } 30 | 31 | @test "ignores empty lines" { 32 | _config_append_keychains_with_key "" "keychain = ${TEST_KEYCHAIN}" "" 33 | run pw ls 34 | assert_success 35 | assert_output "test ls <> <> <${TEST_KEYCHAIN}> " 36 | } 37 | 38 | @test "ignores empty keychain" { 39 | _config_append_keychains "" "${TEST_KEYCHAIN}" "" 40 | run pw ls 41 | assert_success 42 | assert_output "test ls <> <> <${TEST_KEYCHAIN}> " 43 | } 44 | 45 | @test "ignores wrong keychain key" { 46 | _config_append_keychains_with_key "unknown = ${TEST_KEYCHAIN}" "keychain = ${TEST_KEYCHAIN}" 47 | run pw ls 48 | assert_success 49 | assert_output "test ls <> <> <${TEST_KEYCHAIN}> " 50 | } 51 | 52 | @test "ignores keychain*" { 53 | _config_append_keychains_with_key "keychainX = invalid" "keychain = ${TEST_KEYCHAIN}" 54 | run pw ls 55 | assert_success 56 | assert_output "test ls <> <> <${TEST_KEYCHAIN}> " 57 | } 58 | 59 | @test "prioritizes PW_KEYCHAIN over PW_KEYCHAINS" { 60 | _config_append_keychains "${TEST_KEYCHAIN}" 61 | export PW_KEYCHAIN="other test keychain.test" 62 | run pw ls 63 | assert_success 64 | assert_output "test ls <> <> " 65 | } 66 | 67 | @test "prioritizes PW_KEYCHAIN over PW_KEYCHAINS and separates options" { 68 | _config_append_keychains "${TEST_KEYCHAIN}" 69 | export PW_KEYCHAIN="other test keychain.test:${KEYCHAIN_OPTIONS}" 70 | run pw ls 71 | assert_success 72 | assert_output "test ls <${KEYCHAIN_OPTIONS}> <> " 73 | } 74 | 75 | @test "prioritizes pw -k over PW_KEYCHAINS" { 76 | _config_append_keychains "${TEST_KEYCHAIN}" 77 | run pw -k "other test keychain.test" ls 78 | assert_success 79 | assert_output "test ls <> <> " 80 | } 81 | 82 | @test "prioritizes pw -k over PW_KEYCHAINS and separates options" { 83 | _config_append_keychains "${TEST_KEYCHAIN}" 84 | run pw -k "other test keychain.test:${KEYCHAIN_OPTIONS}" ls 85 | assert_success 86 | assert_output "test ls <${KEYCHAIN_OPTIONS}> <> " 87 | } 88 | 89 | @test "replace ~ with real HOME" { 90 | # shellcheck disable=SC2088 91 | _config_append_keychains "~/${TEST_KEYCHAIN}" 92 | run pw ls 93 | assert_success 94 | assert_output "test ls <> <> <${HOME}/${TEST_KEYCHAIN}> " 95 | } 96 | 97 | @test "replace \$HOME with real HOME" { 98 | _config_append_keychains "\$HOME/${TEST_KEYCHAIN}" 99 | run pw ls 100 | assert_success 101 | assert_output "test ls <> <> <${HOME}/${TEST_KEYCHAIN}> " 102 | } 103 | 104 | @test "replace \${HOME} with real HOME" { 105 | _config_append_keychains "\${HOME}/${TEST_KEYCHAIN}" 106 | run pw ls 107 | assert_success 108 | assert_output "test ls <> <> <${HOME}/${TEST_KEYCHAIN}> " 109 | } 110 | 111 | @test "ignores comments with #" { 112 | _config_append_keychains_with_key "# comment" "keychain = ${TEST_KEYCHAIN}" 113 | run pw ls 114 | assert_success 115 | assert_output "test ls <> <> " 116 | } 117 | 118 | @test "ignores comments with ;" { 119 | _config_append_keychains_with_key "; comment" "keychain = ${TEST_KEYCHAIN}" 120 | run pw ls 121 | assert_success 122 | assert_output "test ls <> <> " 123 | } 124 | 125 | @test "ignores comments with indentation" { 126 | _config_append_keychains_with_key " # comment" "keychain = ${TEST_KEYCHAIN}" 127 | run pw ls 128 | assert_success 129 | assert_output "test ls <> <> " 130 | } 131 | 132 | @test "trims indentation" { 133 | _config_append_keychains " ${TEST_KEYCHAIN}" 134 | run pw ls 135 | assert_success 136 | assert_output "test ls <> <> " 137 | } 138 | 139 | # bats test_tags=tag:manual_test 140 | @test "selects keychain with fzf" { 141 | _skip_manual_test "'b keychain.test' using fzf (Press enter to continue ...)" 142 | read -rsp "Press enter to continue ..." 143 | 144 | _config_append_keychains "a keychain.test" "b keychain.test" 145 | run pw ls 146 | assert_success 147 | assert_output "test ls <> <> " 148 | } 149 | 150 | @test "fails when PW_KEYCHAIN is empty" { 151 | export PW_KEYCHAIN="" 152 | run pw ls 153 | assert_failure 154 | cat << EOF | assert_output - 155 | pw: no keychain was set! 156 | Set a keychain with the -k option or provide a list of default keychains in ${XDG_CONFIG_HOME:-"${HOME}/.config"}/pw/pw.conf. 157 | EOF 158 | } 159 | 160 | @test "fails when no keychains are discovered" { 161 | run pw ls 162 | assert_failure 163 | cat << EOF | assert_output - 164 | pw: no keychain was set! 165 | Set a keychain with the -k option or provide a list of default keychains in ${XDG_CONFIG_HOME:-"${HOME}/.config"}/pw/pw.conf. 166 | EOF 167 | } 168 | 169 | @test "discovers keychains without duplicates" { 170 | export PW_TEST_PLUGIN_DISCOVER_DUPLICATE=1 171 | run pw ls 172 | assert_success 173 | assert_output "test ls <> <> " 174 | } 175 | 176 | @test "fails when keychain does not exist" { 177 | export PW_KEYCHAIN="does not exist.keychain" 178 | run pw ls 179 | assert_failure 180 | assert_output "pw: ${PW_KEYCHAIN}: No such file or directory" 181 | } 182 | 183 | @test "prints supported file types" { 184 | export PW_KEYCHAIN="${BATS_TEST_TMPDIR}/test keychain.fake" 185 | touch "${PW_KEYCHAIN}" 186 | run pw ls 187 | assert_failure 188 | cat << EOF | assert_output - 189 | Could not detect plugin for ${PW_KEYCHAIN} 190 | Supported file types are: 191 | Test Collision 192 | Test 193 | EOF 194 | } 195 | 196 | @test "prints supported extensions" { 197 | run pw init "test keychain.fake" 198 | assert_failure 199 | cat << EOF | assert_output - 200 | Could not detect plugin for test keychain.fake 201 | Supported extensions are: 202 | collision - Test Collision 203 | test - Test 204 | EOF 205 | } 206 | 207 | @test "fails when multiple plugins match with file type" { 208 | export PW_TEST_PLUGIN_COLLISION=1 209 | _config_append_keychains "${TEST_KEYCHAIN}" 210 | run pw ls 211 | assert_failure 212 | cat << EOF | assert_output - 213 | pw: Multiple plugins found for ${TEST_KEYCHAIN} 214 | ${BATS_TEST_DIRNAME}/fixtures/plugins/collision 215 | ${BATS_TEST_DIRNAME}/fixtures/plugins/test 216 | EOF 217 | } 218 | 219 | @test "fails when multiple plugins match with file extension" { 220 | export PW_TEST_PLUGIN_COLLISION=1 221 | run pw init "${TEST_KEYCHAIN}" 222 | assert_failure 223 | cat << EOF | assert_output - 224 | pw: Multiple plugins found for ${TEST_KEYCHAIN} 225 | ${BATS_TEST_DIRNAME}/fixtures/plugins/collision 226 | ${BATS_TEST_DIRNAME}/fixtures/plugins/test 227 | EOF 228 | } 229 | -------------------------------------------------------------------------------- /test/pw-migrations.bats: -------------------------------------------------------------------------------- 1 | # shellcheck disable=SC2030,SC2031 2 | setup() { 3 | load 'pw' 4 | _setup 5 | 6 | # PW_RC is depricated. Keep for migration tests. 7 | export PW_RC="${BATS_TEST_TMPDIR}/pwrc" 8 | 9 | # .config/pw/config is depricated. Keep for migration tests. 10 | PW_CONFIG_11="${XDG_CONFIG_HOME}/pw/config" 11 | 12 | keychain="${BATS_TEST_TMPDIR}/test keychain.test" 13 | touch "${keychain}" 14 | } 15 | 16 | _set_pwrc_before_9_0_0() { 17 | # using PW_KEYCHAINS array format 18 | echo "PW_KEYCHAINS=('${keychain}')" > "${PW_RC}" 19 | } 20 | 21 | _set_pwrc_9_0_0() { 22 | # using keychain path format 23 | echo "${keychain}" > "${PW_RC}" 24 | } 25 | 26 | _set_pwrc_10_0_0() { 27 | # using ini-like format 28 | cat << EOF > "${PW_RC}" 29 | [config] 30 | password_length = 35 31 | password_character_class = [:graph:] 32 | clipboard_clear_time = 45 33 | 34 | [plugins] 35 | \$PW_HOME/plugins/gpg 36 | \$PW_HOME/plugins/keepassxc 37 | \$PW_HOME/plugins/macos_keychain 38 | 39 | [keychains] 40 | ${keychain} 41 | EOF 42 | } 43 | 44 | _set_pw_config_11_0_0() { 45 | # moved to ~/.config/pw/config 46 | cat << EOF > "${PW_CONFIG_11}" 47 | [config] 48 | password_length = 35 49 | password_character_class = [:graph:] 50 | clipboard_clear_time = 45 51 | 52 | [plugins] 53 | \$PW_HOME/plugins/gpg 54 | \$PW_HOME/plugins/keepassxc 55 | \$PW_HOME/plugins/macos_keychain 56 | 57 | [keychains] 58 | ${keychain} 59 | EOF 60 | } 61 | 62 | _set_pw_config_12_0_0() { 63 | # moved to ~/.config/pw/pw.conf 64 | cat << EOF > "${PW_CONFIG}" 65 | [general] 66 | password_length = 35 67 | password_character_class = [:graph:] 68 | clipboard_clear_time = 45 69 | 70 | [plugins] 71 | plugin = \$PW_HOME/plugins/gpg 72 | plugin = \$PW_HOME/plugins/keepassxc 73 | plugin = \$PW_HOME/plugins/macos_keychain 74 | 75 | [keychains] 76 | keychain = ${keychain} 77 | EOF 78 | } 79 | 80 | assert_latest_config() { 81 | run pw -y ls 82 | assert_failure 83 | 84 | run cat "${PW_CONFIG}" 85 | assert_success 86 | cat << EOF | assert_output - 87 | [general] 88 | password_length = 35 89 | password_character_class = [:graph:] 90 | clipboard_clear_time = 45 91 | 92 | [plugins] 93 | plugin = \$PW_HOME/plugins/gpg 94 | plugin = \$PW_HOME/plugins/keepassxc 95 | plugin = \$PW_HOME/plugins/macos_keychain 96 | 97 | [keychains] 98 | keychain = ${keychain} 99 | EOF 100 | } 101 | 102 | @test "migrates pwrc from <9.0.0 to latest config" { 103 | _set_pwrc_before_9_0_0 104 | assert_latest_config 105 | } 106 | 107 | @test "migrates pwrc from <10.0.0 to latest config" { 108 | _set_pwrc_9_0_0 109 | assert_latest_config 110 | } 111 | 112 | @test "migrates pwrc from <11.0.0 to latest config" { 113 | _set_pwrc_10_0_0 114 | assert_latest_config 115 | } 116 | 117 | @test "migrates pwrc from <12.0.0 to latest config" { 118 | _set_pw_config_11_0_0 119 | assert_latest_config 120 | } 121 | 122 | @test "ignores latest" { 123 | _set_pw_config_12_0_0 124 | assert_latest_config 125 | } 126 | -------------------------------------------------------------------------------- /test/pw-plugins.bats: -------------------------------------------------------------------------------- 1 | # shellcheck disable=SC2030,SC2031 2 | setup() { 3 | load 'pw' 4 | _setup 5 | _config_append_with_test_plugins 6 | export PW_KEYCHAIN="${BATS_TEST_TMPDIR}/test keychain.test" 7 | KEYCHAIN_OPTIONS="key1=value1,key2=value2" 8 | KEYCHAIN_PASSWORD=" keychain password " 9 | } 10 | 11 | ################################################################################ 12 | # init 13 | ################################################################################ 14 | 15 | @test "inits keychain" { 16 | run pw init "new keychain.test" 17 | assert_success 18 | assert_output "test init <> " 19 | } 20 | 21 | @test "inits keychain and separates options" { 22 | run pw init "new keychain.test:${KEYCHAIN_OPTIONS}" 23 | assert_success 24 | assert_output "test init <${KEYCHAIN_OPTIONS}> " 25 | } 26 | 27 | @test "inits keychain with uppercase extension" { 28 | run pw init "new keychain.TEST" 29 | assert_success 30 | assert_output "test init <> " 31 | } 32 | 33 | @test "init fails when keychain already exists" { 34 | local keychain="${BATS_TEST_TMPDIR}/new keychain.test" 35 | touch "${keychain}" 36 | 37 | run pw init "${keychain}" 38 | assert_failure 39 | assert_output "pw: ${keychain} already exists." 40 | } 41 | 42 | ################################################################################ 43 | # get 44 | ################################################################################ 45 | 46 | @test "prints item password" { 47 | run pw -p -k "${PW_KEYCHAIN}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" 48 | assert_success 49 | assert_output "test get <> <> <${PW_KEYCHAIN}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}>" 50 | } 51 | 52 | @test "prints item password with -pk" { 53 | run pw -pk "${PW_KEYCHAIN}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" 54 | assert_success 55 | assert_output "test get <> <> <${PW_KEYCHAIN}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}>" 56 | } 57 | 58 | @test "prints item password with options and keychain password" { 59 | export PW_TEST_PLUGIN_KEYCHAIN_PASSWORD=1 60 | run pw -pk "${PW_KEYCHAIN}:${KEYCHAIN_OPTIONS}" "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" 61 | assert_success 62 | assert_output "test get <${KEYCHAIN_OPTIONS}> <${KEYCHAIN_PASSWORD}> <${PW_KEYCHAIN}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}>" 63 | } 64 | 65 | ################################################################################ 66 | # add 67 | ################################################################################ 68 | 69 | @test "adds item" { 70 | run pw add "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" <<< "${PW_1}" 71 | assert_success 72 | assert_output "test add <> <> <${PW_KEYCHAIN}> <${PW_1}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}> <${MULTI_LINE_NOTES}>" 73 | } 74 | 75 | @test "adds item with options and keychain password" { 76 | export PW_TEST_PLUGIN_KEYCHAIN_PASSWORD=1 77 | run pw -k "${PW_KEYCHAIN}:${KEYCHAIN_OPTIONS}" add "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" "${MULTI_LINE_NOTES}" <<< "${PW_1}" 78 | assert_success 79 | assert_output "test add <${KEYCHAIN_OPTIONS}> <${KEYCHAIN_PASSWORD}> <${PW_KEYCHAIN}> <${PW_1}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}> <${MULTI_LINE_NOTES}>" 80 | } 81 | 82 | # bats test_tags=tag:manual_test 83 | @test "prompts password when no stdin" { 84 | _skip_manual_test "' test password ' twice (with leading whitespace)" 85 | run pw add "${NAME_A}" 86 | assert_success 87 | cat << EOF | assert_output - 88 | Enter password for '${NAME_A}' (leave empty to generate password): 89 | Retype password for '${NAME_A}': 90 | test add <> <> <${PW_KEYCHAIN}> < test password > <${NAME_A}> <> <> <> 91 | EOF 92 | } 93 | 94 | # bats test_tags=tag:manual_test 95 | @test "add prompts password and fails if retyped password does not match" { 96 | _skip_manual_test "'test 1' and 'test 2'" 97 | run pw add "${NAME_A}" 98 | assert_failure 99 | cat << EOF | assert_output - 100 | Enter password for '${NAME_A}' (leave empty to generate password): 101 | Retype password for '${NAME_A}': 102 | Error: the entered passwords do not match. 103 | EOF 104 | } 105 | 106 | # bats test_tags=tag:manual_test 107 | @test "generates password when empty" { 108 | _skip_manual_test "nothing" 109 | export PW_GEN_LENGTH=5 110 | export PW_GEN_CLASS="1" 111 | run pw -p add "${NAME_A}" 112 | assert_success 113 | cat << EOF | assert_output - 114 | Enter password for '${NAME_A}' (leave empty to generate password): 115 | test add <> <> <${PW_KEYCHAIN}> <11111> <${NAME_A}> <> <> <> 116 | EOF 117 | } 118 | 119 | # bats test_tags=tag:manual_test 120 | @test "adds item interactively" { 121 | _skip_manual_test "name, account, url, notes (end with Ctrl+D), then pass, pass" 122 | run pw add 123 | assert_success 124 | cat << EOF | assert_output - 125 | Title: Username: URL: Notes: Enter multi-line input (end with Ctrl+D): 126 | Enter password for 'name' (leave empty to generate password): 127 | Retype password for 'name': 128 | test add <> <> <${PW_KEYCHAIN}> 129 | EOF 130 | } 131 | 132 | ################################################################################ 133 | # show 134 | ################################################################################ 135 | 136 | @test "prints item details" { 137 | run pw -p show "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" 138 | assert_success 139 | assert_output "test show <> <> <${PW_KEYCHAIN}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}>" 140 | } 141 | 142 | @test "prints item details with options and keychain password" { 143 | export PW_TEST_PLUGIN_KEYCHAIN_PASSWORD=1 144 | run pw -pk "${PW_KEYCHAIN}:${KEYCHAIN_OPTIONS}" show "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" 145 | assert_success 146 | assert_output "test show <${KEYCHAIN_OPTIONS}> <${KEYCHAIN_PASSWORD}> <${PW_KEYCHAIN}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}>" 147 | } 148 | 149 | ################################################################################ 150 | # rm 151 | ################################################################################ 152 | 153 | @test "removes item" { 154 | run pw rm "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" 155 | assert_success 156 | assert_output "test rm <> <> <${PW_KEYCHAIN}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}>" 157 | } 158 | 159 | @test "removes item with options and keychain password" { 160 | export PW_TEST_PLUGIN_KEYCHAIN_PASSWORD=1 161 | run pw -k "${PW_KEYCHAIN}:${KEYCHAIN_OPTIONS}" rm "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" 162 | assert_success 163 | assert_output "test rm <${KEYCHAIN_OPTIONS}> <${KEYCHAIN_PASSWORD}> <${PW_KEYCHAIN}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}>" 164 | } 165 | 166 | # bats test_tags=tag:manual_test 167 | @test "removes item interactively" { 168 | _skip_manual_test "select 'name 2', then enter 'y'" 169 | export PW_TEST_PLUGIN_LS=1 170 | read -rsp "Press enter to continue ..." 171 | run pw rm 172 | assert_success 173 | cat << EOF | assert_output - 174 | Do you really want to remove 'name 2' 'account 2' from '${PW_KEYCHAIN}'? (y / N): test rm <> <> <${PW_KEYCHAIN}> 175 | EOF 176 | } 177 | 178 | ################################################################################ 179 | # edit 180 | ################################################################################ 181 | 182 | @test "edits item" { 183 | run pw edit "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" <<< "${PW_2}" 184 | assert_success 185 | assert_output "test edit <> <> <${PW_KEYCHAIN}> <${PW_2}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}>" 186 | } 187 | 188 | @test "edits item with options and keychain password" { 189 | export PW_TEST_PLUGIN_KEYCHAIN_PASSWORD=1 190 | run pw -k "${PW_KEYCHAIN}:${KEYCHAIN_OPTIONS}" edit "${NAME_A}" "${ACCOUNT_A}" "${URL_A}" <<< "${PW_2}" 191 | assert_success 192 | assert_output "test edit <${KEYCHAIN_OPTIONS}> <${KEYCHAIN_PASSWORD}> <${PW_KEYCHAIN}> <${PW_2}> <${NAME_A}> <${ACCOUNT_A}> <${URL_A}>" 193 | } 194 | 195 | # bats test_tags=tag:manual_test 196 | @test "edit prompts password and fails if retyped password does not match" { 197 | _skip_manual_test "'test 1' and 'test 2'" 198 | run pw edit "${NAME_A}" 199 | assert_failure 200 | cat << EOF | assert_output - 201 | Enter password for '${NAME_A}' (leave empty to generate password): 202 | Retype password for '${NAME_A}': 203 | Error: the entered passwords do not match. 204 | EOF 205 | } 206 | 207 | ################################################################################ 208 | # list item 209 | ################################################################################ 210 | 211 | @test "lists items" { 212 | run pw ls 213 | assert_success 214 | assert_output "test ls <> <> <${PW_KEYCHAIN}> " 215 | } 216 | 217 | @test "lists items with options, keychain password and format" { 218 | export PW_TEST_PLUGIN_KEYCHAIN_PASSWORD=1 219 | run pw -k "${PW_KEYCHAIN}:${KEYCHAIN_OPTIONS}" ls fzf 220 | assert_success 221 | assert_output "test ls <${KEYCHAIN_OPTIONS}> <${KEYCHAIN_PASSWORD}> <${PW_KEYCHAIN}> " 222 | } 223 | 224 | @test "fails when ls fails" { 225 | export PW_TEST_PLUGIN_FAIL=1 226 | run pw 227 | assert_failure 228 | refute_output 229 | } 230 | 231 | ################################################################################ 232 | # lock 233 | ################################################################################ 234 | 235 | @test "locks keychain" { 236 | run pw lock 237 | assert_success 238 | assert_output "test lock <> <${PW_KEYCHAIN}>" 239 | } 240 | 241 | @test "locks keychain with options" { 242 | run pw -k "${PW_KEYCHAIN}:${KEYCHAIN_OPTIONS}" lock 243 | assert_success 244 | assert_output "test lock <${KEYCHAIN_OPTIONS}> <${PW_KEYCHAIN}>" 245 | } 246 | 247 | @test "unlocks keychain" { 248 | run pw unlock 249 | assert_success 250 | assert_output "test unlock <> <${PW_KEYCHAIN}>" 251 | } 252 | 253 | @test "unlocks keychain with options" { 254 | run pw -k "${PW_KEYCHAIN}:${KEYCHAIN_OPTIONS}" unlock 255 | assert_success 256 | assert_output "test unlock <${KEYCHAIN_OPTIONS}> <${PW_KEYCHAIN}>" 257 | } 258 | 259 | @test "opens keychain" { 260 | run pw open 261 | assert_success 262 | assert_output "test open <> <${PW_KEYCHAIN}>" 263 | } 264 | 265 | @test "opens keychain with options" { 266 | run pw -k "${PW_KEYCHAIN}:${KEYCHAIN_OPTIONS}" open 267 | assert_success 268 | assert_output "test open <${KEYCHAIN_OPTIONS}> <${PW_KEYCHAIN}>" 269 | } 270 | 271 | ################################################################################ 272 | # fzf preview 273 | ################################################################################ 274 | 275 | # bats test_tags=tag:manual_test 276 | @test "runs fzf preview in bash" { 277 | _skip_manual_test "activate preview and select 'name 2'. Preview should look fine with no errors." 278 | export PW_TEST_PLUGIN_LS=1 279 | read -rsp "Press enter to continue ..." 280 | run pw -p 281 | assert_success 282 | assert_output "test get <> <> <${PW_KEYCHAIN}> " 283 | } 284 | -------------------------------------------------------------------------------- /test/pw.bash: -------------------------------------------------------------------------------- 1 | _setup() { 2 | load 'test-helper' 3 | _common_setup 4 | } 5 | -------------------------------------------------------------------------------- /test/pw.bats: -------------------------------------------------------------------------------- 1 | # shellcheck disable=SC2030,SC2031 2 | setup() { 3 | load 'pw' 4 | _setup 5 | } 6 | 7 | assert_pw_home() { 8 | assert_output --partial "🔐 pw $(cat "${PROJECT_ROOT}/version.txt") - Terminal Password Manager" 9 | } 10 | 11 | @test "prints help" { 12 | run pw -h 13 | assert_success 14 | assert_output --partial "usage: pw" 15 | } 16 | 17 | @test "resolves pw home" { 18 | run pw -h 19 | assert_success 20 | assert_pw_home 21 | } 22 | 23 | @test "resolves pw home and follows symlink" { 24 | ln -s "${PROJECT_ROOT}/src/pw" "${BATS_TEST_TMPDIR}/pw" 25 | run "${BATS_TEST_TMPDIR}/pw" -h 26 | assert_success 27 | assert_pw_home 28 | } 29 | 30 | @test "resolves pw home and follows multiple symlinks" { 31 | mkdir "${BATS_TEST_TMPDIR}"/{src,bin} 32 | ln -s "${PROJECT_ROOT}/src/pw" "${BATS_TEST_TMPDIR}/src/pw" 33 | ln -s "${BATS_TEST_TMPDIR}/src/pw" "${BATS_TEST_TMPDIR}/bin/pw" 34 | run "${BATS_TEST_TMPDIR}/bin/pw" -h 35 | assert_success 36 | assert_pw_home 37 | } 38 | 39 | @test "doesn't source config" { 40 | _config_append_keychains " test keychain " 41 | echo 'echo "# test config sourced"' >> "${PW_CONFIG}" 42 | run pw -h 43 | refute_output --partial "# test config sourced" 44 | } 45 | 46 | @test "doesn't create default config when not accessed" { 47 | run pw -h 48 | assert_file_not_exists "${PW_CONFIG}" 49 | } 50 | 51 | @test "creates default config" { 52 | run pw ls 53 | assert_file_exists "${PW_CONFIG}" 54 | } 55 | 56 | @test "doesn't create custom config" { 57 | run pw -c "${BATS_TEST_TMPDIR}/myconfig" ls 58 | assert_file_not_exists "${PW_CONFIG}" 59 | } 60 | 61 | @test "uses custom config" { 62 | export PW_KEYCHAIN="${BATS_TEST_TMPDIR}/test keychain.test" 63 | _config_append_with_test_plugins "${BATS_TEST_TMPDIR}/myconfig" 64 | run pw -c "${BATS_TEST_TMPDIR}/myconfig" ls 65 | assert_success 66 | assert_output "test ls <> <> <${PW_KEYCHAIN}> " 67 | } 68 | 69 | @test "exits when invalid option" { 70 | run pw -x -h 71 | assert_failure 72 | assert_output "Invalid option: -x" 73 | } 74 | 75 | @test "generates and prints password" { 76 | export PW_GEN_LENGTH=1 77 | export PW_GEN_CLASS="1" 78 | run pw -p gen 79 | assert_success 80 | assert_output "1" 81 | } 82 | 83 | @test "generates password with specified length" { 84 | export PW_GEN_LENGTH=2 85 | export PW_GEN_CLASS="1" 86 | run pw -p gen 1 87 | assert_success 88 | assert_output "1" 89 | } 90 | 91 | @test "generates password with specified character class" { 92 | export PW_GEN_LENGTH=2 93 | export PW_GEN_CLASS="1" 94 | run pw -p gen 1 "2" 95 | assert_success 96 | assert_output "2" 97 | } 98 | 99 | # @test "BusyBox: replaces [:graph:] with [:alnum:][:punct:]" { 100 | # export PW_GEN_LENGTH=64 101 | # export PW_GEN_CLASS="[:graph:]" 102 | # run pw -p gen 103 | # assert_success 104 | # assert_output "check manually" 105 | # } 106 | 107 | # @test "alpine: replaces [:print:] with [:alnum:][:punct:][:space:]" { 108 | # export PW_GEN_LENGTH=64 109 | # export PW_GEN_CLASS="[:print:]" 110 | # run pw -p gen 111 | # assert_success 112 | # assert_output "check manually" 113 | # } 114 | -------------------------------------------------------------------------------- /test/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | [[ ! -f test/bats/bin/bats ]] && git submodule update --init --recursive 4 | 5 | declare -rx TEST_BASH_VERSION="${TEST_BASH_VERSION:-"5.2"}" 6 | 7 | run_tests() { 8 | DOCKER_BUILDKIT=1 docker build --target test -t "sschmid/pw/test/$1" -f "docker/$1/Dockerfile" . 9 | } 10 | 11 | cmd_options=() 12 | no_shellcheck=0 13 | while getopts ":ac:j:mp:" options; do 14 | case "${options}" in 15 | a) 16 | run_tests alpine 17 | run_tests archlinux 18 | run_tests debian 19 | run_tests fedora 20 | run_tests opensuse-tumbleweed 21 | run_tests ubuntu 22 | ;; 23 | c) 24 | case "${OPTARG}" in 25 | no-shellcheck) no_shellcheck=1 ;; 26 | esac 27 | ;; 28 | j) 29 | cmd_options+=("--jobs" "${OPTARG}") 30 | ;; 31 | m) 32 | export PW_TEST_RUN_MANUAL_TESTS=1 33 | cmd_options+=("--tap") 34 | ;; 35 | p) 36 | run_tests "${OPTARG}" 37 | exit 38 | ;; 39 | *) 40 | echo "Invalid option: -${OPTARG}" >&2 41 | exit 1 42 | ;; 43 | esac 44 | done 45 | shift $(( OPTIND - 1 )) 46 | 47 | test/bats/bin/bats "${cmd_options[@]}" "${@:-test}" 48 | (( no_shellcheck )) || test/shellcheck 49 | -------------------------------------------------------------------------------- /test/shellcheck: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # shellcheck disable=SC2046 3 | shellcheck $( 4 | echo "src/pw" && 5 | find "src" "test/fixtures" -type f -name "*.bash" && 6 | find "test" -maxdepth 1 -type f -name "*.bash" -or -name "*.bats" 7 | ) 8 | -------------------------------------------------------------------------------- /test/test-helper.bash: -------------------------------------------------------------------------------- 1 | # shellcheck disable=SC2034 2 | _common_setup() { 3 | load 'test_helper/bats-support/load.bash' 4 | load 'test_helper/bats-assert/load.bash' 5 | load 'test_helper/bats-file/load.bash' 6 | 7 | export XDG_CONFIG_HOME="${BATS_TEST_TMPDIR}/.config" 8 | PW_CONFIG="${XDG_CONFIG_HOME}/pw/pw.conf" 9 | mkdir -p "${XDG_CONFIG_HOME}/pw" 10 | 11 | PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." &>/dev/null && pwd)" 12 | PATH="${PROJECT_ROOT}/src:${PATH}" 13 | 14 | KEYCHAIN_TEST_PASSWORD=" test password " 15 | NAME_A=" a test name " 16 | NAME_B=" b test name " 17 | ACCOUNT_A=" a test account " 18 | ACCOUNT_B=" b test account " 19 | URL_A=" a test url " 20 | URL_B=" b test url " 21 | SINGLE_LINE_NOTES=" a single line note " 22 | MULTI_LINE_NOTES=" a test note 23 | with multiple lines 24 | and spaces " 25 | PW_1=" 1 test pw " 26 | PW_2=" 2 test pw " 27 | PW_3=" 3 test pw " 28 | } 29 | 30 | _set_config_with_copy_paste() { 31 | cat > "${PW_CONFIG}" << 'EOF' 32 | [general] 33 | copy = cat > "${BATS_TEST_TMPDIR}/test_clipboard" 34 | paste = cat "${BATS_TEST_TMPDIR}/test_clipboard" 35 | EOF 36 | } 37 | 38 | _copy() { 39 | cat > "${BATS_TEST_TMPDIR}/test_clipboard" 40 | } 41 | 42 | _paste() { 43 | cat "${BATS_TEST_TMPDIR}/test_clipboard" 44 | } 45 | 46 | _config_append_with_plugin() { 47 | cat >> "${PW_CONFIG}" << EOF 48 | [plugins] 49 | # unknown key 50 | pluginX = invalid 51 | plugin = $1 52 | EOF 53 | } 54 | 55 | _config_append_with_test_plugins() { 56 | cat >> "${1:-"${PW_CONFIG}"}" << EOF 57 | [plugins] 58 | # unknown key 59 | pluginX = invalid 60 | plugin = ${BATS_TEST_DIRNAME}/fixtures/plugins/collision 61 | plugin = ${BATS_TEST_DIRNAME}/fixtures/plugins/test 62 | EOF 63 | } 64 | 65 | _config_append_keychains() { 66 | echo "[keychains]" >> "${PW_CONFIG}" 67 | printf "keychain = %s\n" "$@" >> "${PW_CONFIG}" 68 | } 69 | 70 | _config_append_keychains_with_key() { 71 | echo "[keychains]" >> "${PW_CONFIG}" 72 | printf "%s\n" "$@" >> "${PW_CONFIG}" 73 | } 74 | 75 | assert_init_already_exists() { 76 | run pw init "${PW_KEYCHAIN}" 77 | assert_failure 78 | assert_output "pw: ${PW_KEYCHAIN} already exists." 79 | } 80 | 81 | assert_item_exists() { 82 | local password="$1"; shift 83 | run pw -p "$@" 84 | assert_success 85 | assert_output "${password}" 86 | } 87 | 88 | assert_item_not_exists() { 89 | run pw "$@" 90 | assert_failure 91 | assert_item_not_exists_output "$@" 92 | } 93 | 94 | assert_adds_item() { 95 | local password="$1"; shift 96 | run pw add "$@" <<< "${password}" 97 | assert_success 98 | refute_output 99 | } 100 | 101 | assert_adds_item_with_keychain_password() { 102 | local password="$1"; shift 103 | run pw add "$@" << EOF 104 | ${KEYCHAIN_TEST_PASSWORD} 105 | ${password} 106 | EOF 107 | assert_success 108 | refute_output 109 | } 110 | 111 | assert_item_already_exists() { 112 | local password="$1"; shift 113 | run pw add "$@" <<< "${password}" 114 | assert_failure 115 | assert_item_already_exists_output "$@" 116 | } 117 | 118 | assert_item_already_exists_with_keychain_password() { 119 | local password="$1"; shift 120 | run pw add "$@" << EOF 121 | ${KEYCHAIN_TEST_PASSWORD} 122 | ${password} 123 | EOF 124 | assert_failure 125 | assert_item_already_exists_output "$@" 126 | } 127 | 128 | assert_removes_item() { 129 | run pw rm "$@" 130 | assert_success 131 | assert_removes_item_output "$@" 132 | } 133 | 134 | assert_rm_not_found() { 135 | run pw rm "$@" 136 | assert_failure 137 | assert_rm_not_found_output "$@" 138 | } 139 | 140 | assert_edits_item() { 141 | local password="$1"; shift 142 | run pw edit "$@" <<< "${password}" 143 | assert_success 144 | refute_output 145 | } 146 | 147 | assert_edits_item_with_keychain_password() { 148 | local password="$1"; shift 149 | run pw edit "$@" << EOF 150 | ${KEYCHAIN_TEST_PASSWORD} 151 | ${password} 152 | EOF 153 | assert_success 154 | refute_output 155 | } 156 | 157 | _skip_when_not_macos() { 158 | [[ "${OSTYPE}" == "darwin"* ]] || skip "Not macOS" 159 | } 160 | 161 | _skip_manual_test() { 162 | if [[ -v PW_TEST_RUN_MANUAL_TESTS ]]; then 163 | echo "# Please enter $1" >&3 164 | else 165 | skip "Requires user input. Use test/run -m to also run manual tests." 166 | fi 167 | } 168 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 12.0.0 2 | --------------------------------------------------------------------------------