diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 27da5e1..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: lint - -on: [push, pull_request] - -jobs: - shellcheck: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: make keychain - run: | - make keychain - - name: run shellcheck - run: | - shellcheck keychain diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..fa45ea5 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,165 @@ +name: python-ci + +# Tiered triggering keeps PR feedback fast while still doing a full +# cross-platform sweep on every master push, weekly, and on demand. +# Feature-branch pushes get the fast tier (same as PRs) so contributors +# see CI feedback before opening a PR. +on: + push: + pull_request: + workflow_dispatch: + schedule: + # Monday 06:00 UTC: catches drift on runner image / Python rolling + # bugfix releases without humans needing to remember to push. + - cron: '0 6 * * 1' + +# Default to read-only for every job; neither static analysis nor pytest +# needs anything more. +permissions: + contents: read + +# Use bash for every run: step so that single-quoted pip extras work +# consistently on all platforms (PowerShell on Windows handles them +# differently in some edge cases) and inside Linux containers. +defaults: + run: + shell: bash + +jobs: + # ------------------------------------------------------------------------- + # Static analysis (ruff + mypy + bandit). One job, fast, fails fast. + # ------------------------------------------------------------------------- + static-analysis: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: install lint extras + run: | + python -m pip install --upgrade pip + python -m pip install -e '.[lint]' + - name: ruff + run: ruff check . + - name: mypy + run: mypy + - name: bandit + run: bandit -r src/keychain -c pyproject.toml + + # ------------------------------------------------------------------------- + # Fast tier — runs on every event (PR, push, cron, dispatch). + # + # Floor + ceiling on Linux + Windows-on-ceiling. Rationale: + # - 3.9 (floor) catches usage of features added later. + # - 3.13 (ceiling) catches deprecations and stdlib drift. + # - Windows × 3.13 catches POSIX-only assumptions; OS-portability + # bugs almost never differ between Python minor versions, so one + # Windows job is enough for PR-time feedback. + # - macOS is intentionally omitted from this tier because it's a + # POSIX system whose failures correlate ~100% with Linux. The + # full sweep below restores macOS coverage on master. + # ------------------------------------------------------------------------- + test-fast: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + python-version: "3.9" + - os: ubuntu-latest + python-version: "3.13" + - os: windows-latest + python-version: "3.13" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: install + run: | + python -m pip install --upgrade pip + python -m pip install -e '.[dev]' + - name: pytest + run: python -m pytest -v --tb=short --cov=src/keychain --cov-branch --cov-report=term-missing:skip-covered + + # ------------------------------------------------------------------------- + # Production-target authenticity: Rocky Linux 8 + python3.9 module. + # + # This is the documented install path for RHEL 8 / Rocky 8 users + # (see docs/python-version-modernization.md). Validating it on every + # commit keeps three risks contained: + # - The 3.9-floor guard in src/keychain/__init__.py wiring. + # - The polyglot sh/python multi-version probing shebang baked + # into keychain.pyz by the Makefile. + # - Subtle stdlib differences between AppStream's python3.9 build + # and the upstream cpython binary that setup-python ships. + # ------------------------------------------------------------------------- + test-rocky8-py39: + runs-on: ubuntu-latest + container: + image: rockylinux:8 + steps: + - name: install python3.9 module + build prerequisites + # git: needed by actions/checkout + # python39: AppStream's RHEL-shipped 3.9 -- our actual target + # gcc + python39-devel: allow C-extension wheels (psutil) to + # build from source if no manylinux wheel matches; not + # strictly required today (no C deps) but cheap insurance + # against transitive deps growing one later. + # make: builds keychain.pyz for the smoke test below. + run: | + dnf install -y git make gcc + dnf module install -y python39 + dnf install -y python39-devel + - uses: actions/checkout@v4 + - name: python version + run: python3.9 --version + - name: install package + run: | + python3.9 -m pip install --upgrade pip + python3.9 -m pip install -e '.[dev]' + - name: pytest + run: python3.9 -m pytest -v --tb=short --cov=src/keychain --cov-branch --cov-report=term-missing:skip-covered + - name: build polyglot zipapp & smoke-test shebang resolution + # /usr/bin/python3 on Rocky 8 is 3.6.8; the shebang must still + # find python3.9 via the polyglot probe. If the probe regresses + # to /usr/bin/env python3, the version-floor guard fires and + # this step exits 2. + run: | + make keychain.pyz + ./keychain.pyz version + ./keychain.pyz help >/dev/null + + # ------------------------------------------------------------------------- + # Full sweep — runs on master pushes, tags, weekly cron, and manual + # dispatch. *Skipped on PRs and feature-branch pushes* to keep PR and + # day-to-day feedback under a few minutes. If a branch needs the full + # sweep before merge (e.g. touching subprocess wiring, signal handling, + # path resolution), a maintainer can trigger it via the Actions tab → + # "Run workflow". + # ------------------------------------------------------------------------- + test-full-sweep: + if: >- + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && github.ref == 'refs/heads/master') || + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: install + run: | + python -m pip install --upgrade pip + python -m pip install -e '.[dev]' + - name: pytest + run: python -m pytest -v --tb=short --cov=src/keychain --cov-branch --cov-report=term-missing:skip-covered diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8df6f4..14bb8e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,62 +3,108 @@ name: release on: push: tags: - - '[0-9]+.[0-9]+.[0-9]+' + - "[0-9]*.[0-9]*.[0-9]*" + +permissions: + contents: write + +defaults: + run: + shell: bash jobs: - build: - runs-on: ubuntu-latest - container: - image: debian:bookworm-slim + release: + runs-on: ubuntu-24.04 steps: - - name: Prepare build dependencies - run: | - apt-get update -y - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - ca-certificates make perl jq git gawk sed gzip tar openssh-client - rm -rf /var/lib/apt/lists/* - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Fix git ownership for container - run: | - git config --global --add safe.directory "$GITHUB_WORKSPACE" - - name: Verify tag/version consistency - id: ver + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Verify tag matches VERSION + id: version run: | - TAG_NAME="${GITHUB_REF##*/}" - FILE_VER=$(cat VERSION) - if [ "$TAG_NAME" != "$FILE_VER" ]; then - echo "Tag $TAG_NAME does not match VERSION file $FILE_VER" >&2 + tag="${GITHUB_REF_NAME}" + version="$(cat VERSION)" + if [[ "${tag}" != "${version}" ]]; then + echo "Tag ${tag} does not match VERSION ${version}" >&2 exit 1 fi - echo "version=$TAG_NAME" >> $GITHUB_OUTPUT - - name: Build + if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(_[A-Za-z0-9]+)?$ ]]; then + echo "Unsupported VERSION format: ${version}" >&2 + exit 1 + fi + prerelease=false + if [[ "${version}" == *"_"* ]]; then + prerelease=true + fi + title="${version//_/ }" + echo "version=${version}" >> "${GITHUB_OUTPUT}" + echo "title=${title}" >> "${GITHUB_OUTPUT}" + echo "prerelease=${prerelease}" >> "${GITHUB_OUTPUT}" + + - name: Install test dependencies run: | - make clean - make dist/keychain-$(cat VERSION).tar.gz - - name: Test space-in-home handling + python -m pip install --upgrade pip + python -m pip install -e '.[dev]' + + - name: Test + run: python -m pytest tests -q + + - name: Build release artifacts run: | - ver=$(cat VERSION) - ./scripts/test-space-home.sh "$ver" - - name: Extract changelog section + make clean + make release-artifacts + ./dist/keychain-${{ steps.version.outputs.version }}.pyz version 2>&1 | tee .version-smoke.txt + grep -F "keychain ${{ steps.version.outputs.version }}" .version-smoke.txt + ./dist/keychain-${{ steps.version.outputs.version }}.pyz man --list >/dev/null + + - name: Prepare release notes run: | - ver=$(cat VERSION) - awk -v ver="$ver" '/^## keychain '"$ver"' /{f=1;print;next} /^## keychain / && f && $0 !~ ver {exit} f' ChangeLog.md > .release-notes.md - if [ ! -s .release-notes.md ]; then - echo "Failed to extract changelog for $ver" >&2 + version="${{ steps.version.outputs.version }}" + awk -v ver="${version}" ' + $0 == "## " ver {f=1; next} + /^## / && f {exit} + f {print} + ' ChangeLog.md > .release-notes.md + if [[ ! -s .release-notes.md ]]; then + echo "Failed to extract ChangeLog.md notes for ${version}" >&2 exit 1 fi - - name: Upload artifacts (build only; manual publish step remains maintainer-driven) + + - name: Upload workflow artifacts uses: actions/upload-artifact@v4 with: - name: keychain-${{ steps.ver.outputs.version }}-artifacts - path: | - dist/keychain-${{ steps.ver.outputs.version }}.tar.gz - keychain - keychain.1 - .release-notes.md - - name: Summary + name: keychain-${{ steps.version.outputs.version }} + path: | + dist/keychain-${{ steps.version.outputs.version }}.pyz + dist/SHA256SUMS + .release-notes.md + + - name: Create draft GitHub release + env: + GH_TOKEN: ${{ github.token }} run: | - echo 'Artifacts prepared. Use make release or release-refresh locally to publish via API if desired.' >> $GITHUB_STEP_SUMMARY + version="${{ steps.version.outputs.version }}" + if gh release view "${version}" >/dev/null 2>&1; then + gh release upload "${version}" \ + "dist/keychain-${version}.pyz" \ + dist/SHA256SUMS \ + --clobber + else + args=( + --draft + --title "Keychain ${{ steps.version.outputs.title }}" + --notes-file .release-notes.md + ) + if [[ "${{ steps.version.outputs.prerelease }}" == "true" ]]; then + args+=(--prerelease) + fi + gh release create "${version}" \ + "dist/keychain-${version}.pyz" \ + dist/SHA256SUMS \ + "${args[@]}" + fi diff --git a/.gitignore b/.gitignore index e2a4e3d..ca9b0c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,20 @@ /keychain.1.orig .idea/ keychain.iml -keychain -keychain.1 -keychain.txt -keychain.spec +/keychain +/keychain.1 +/keychain.txt +/lint_out.txt +/pytest_out.txt +/keychain.spec +/src/keychain/docs/_doc_texts.json +__pycache__/ +*.pyc .specstory/ .ci-artifacts*/ dist/ +build/ +*.egg-info/ +*.pyz +*~ +.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8a93f93 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +# Pre-commit hooks. Install with ``pre-commit install`` (provided by the +# ``pip install -e .[lint]`` extra). Hooks run on staged files at commit +# time and in CI via the ``pre-commit/action`` GitHub Action. +# +# ruff-format auto-reformats (no --check; apply directly). +# ruff auto-fixes lint issues. +# Both run before the hygiene hooks so fixes are in place before those checks. +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff-format + - id: ruff + args: [--fix] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 3f6bcae..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "files.eol": "\n", - "files.autoGuessEncoding": true, - "files.insertFinalNewline": true, - "files.trimTrailingWhitespace": true -} diff --git a/COPYING.txt b/COPYING.txt deleted file mode 100644 index d159169..0000000 --- a/COPYING.txt +++ /dev/null @@ -1,339 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. diff --git a/ChangeLog.md b/ChangeLog.md index ffe46e3..eebc73b 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,651 +1,35 @@ -# ChangeLog for Keychain - https://github.com/danielrobbins/keychain - -## keychain 2.9.8 (2 Nov 2025) - -This release fixes the release tarball to include all necessary files for building and using keychain. - -Bug fixes: - -* Fixed release tarball generation to include bash completion script (`completions/keychain.bash`), - Makefile, source files, and other essential components. Previous release (2.9.7) tarball was - missing these files. -* Improved tarball generation to use `git archive` as source of truth, eliminating manual file - inventory and preventing future omissions. -* Updated release logic to use `dist/` directory for archive generation. GitHub workflow plumbing - work for new `/dist` tarball location, associated `Makefile` and CI fixes. - -Documentation: - -* Added bash completion information to keychain man page (NOTES section). - -## keychain 2.9.7 (31 Oct 2025) - -This release fixes critical issues with spaces in HOME directories and usernames, and adds official Git Bash on Windows compatibility. - -Bug fixes: - -* Fixed keychain failures when HOME directory path contains spaces (e.g., `C:\Users\John Doe`). - ([#188](https://github.com/danielrobbins/keychain/issues/188)) -* Fixed username detection for usernames containing spaces (e.g., "Mathew Binkley" on Windows). - Implemented portable `get_owner()` function using POSIX-defined `ls -ld` output format with - intelligent field parsing to distinguish space-in-username from normal owner/group fields. -* Fixed pidfile generation to properly quote `SSH_AUTH_SOCK` paths containing spaces while - leaving `SSH_AGENT_PID` unquoted (numeric value). Rewrote `write_pidfile()` to use robust - eval-in-subshell approach for extracting variable values from ssh-agent output. -* All pidfile formats (sh/csh/fish) now correctly handle paths with spaces. -* Fixed ssh-agent invocation to always use `-s` option for Bourne-compatible output, simplifying - pidfile generation and improving compatibility across different environments. - ([#185](https://github.com/danielrobbins/keychain/issues/185)) - -Testing and quality improvements: - -* Added `scripts/test-space-home.sh` - automated test harness that simulates HOME directories - with spaces and validates proper handling. Returns proper exit codes for CI integration. -* Integrated space-in-home test into GitHub Actions release workflow to prevent regressions. -* Added ShellCheck disable comments with justification for intentional POSIX ls usage. -* Fixed Unicode arrow characters in comments that caused ShellCheck errors. - -New features: - -* Added bash completion support (`completions/keychain.bash`) with intelligent context-aware - completion for command-line options, SSH keys, GPG keys, and full `--extended` mode support. - Based on work by @mikkoi with significant enhancements for keychain 2.9.x features: - - Dynamically parses `keychain --help` for up-to-date option completion - - Completes SSH key names from `~/.ssh/*.pub` files - - Completes GPG key IDs (8-character short format) - - `--extended` mode: `sshk:`, `gpgk:`, `host:` with prefix completion - - Detects hostnames from `~/.ssh/config` for `host:` completion - - ShellCheck compliant - ([#186](https://github.com/danielrobbins/keychain/issues/186)) -* Added Makefile targets: `install-completions` and `uninstall-completions` for optional - bash completion installation (separate from default install target). -* Updated RPM spec file (`keychain.spec.in`) for modern distributions: - - Modernized description to focus on OpenSSH and GnuPG (removed obsolete ssh.com/Sun SSH) - - Updated dependencies: `sh-utils` → `coreutils`, added `Recommends: bash-completion` - - Added bash completion installation to RPM package - -Documentation: - -* Updated keychain.pod with detailed implementation notes for space handling, POSIX compliance, - and the robust eval approach used in pidfile generation. -* Standardized option ordering in keychain.pod to follow Unix convention (short option first, - then long option), ensuring compatibility with bash completion regex patterns. -* Added comprehensive COMPATIBILITY section to keychain.pod documenting: - - Minimum OpenSSH version (7.3+) and supported features - - GnuPG 2.1+ requirements for gpg-agent integration - - Shell compatibility (Bourne/POSIX, csh/tcsh, fish) - - **Git Bash (MSYS2) for Windows** - officially documented as supported platform - - Legacy SSH implementation status (SunSSH, ssh.com) - - Systemd user environment integration - - Spaces in HOME and paths handling details -* Updated README.md with bash completion installation instructions for both system-wide - and user-only installations. - -## keychain 2.9.6 (06 Sep 2025) - -Documentation/branding release (no functional code changes): - -* Updated references in wiki to reflect the new official home of Keychain at - https://github.com/danielrobbins/keychain. -* Consolidate historical references; retain only intentional archival note(s). - -Additional release engineering improvements: - -* Add release automation helpers: Makefile `release` (create) and - `release-refresh` (asset replace), plus scripts under `scripts/` and - GitHub Actions workflow to build artifacts on tag push (staging only). -* Add `docs/release-steps.md` to formalize release process (numeric tags only, - assets: tarball, wrapper script, man page). -* Orchestrated release flow (`make release` / `make release-refresh`) now enforces: - - Mandatory CI (Debian container) artifact fetch for the tag. - - Normalized comparisons: - * `keychain` – raw sha256. - * `keychain.1` – raw sha256; on mismatch, re-compare with Pod::Man first line stripped. - * Tarball – internal file list + per-file sha256 (man page internally normalized) ignoring tar/gzip metadata. - - If (and only if) all artifacts match (raw or normalized) CI artifacts are used DIRECTLY for publication; local artifacts are never overwritten (kept for audit). - - Any real content mismatch aborts unless `KEYCHAIN_FORCE_LOCAL=1` is explicitly set (single override; `KEYCHAIN_ADOPT_CI` removed). - - Copy/paste diff command hints emitted on mismatch for rapid investigation. - - Asset path indirection via exported variables prevents local file mutation, improving auditability. -* Release notes body automatically extended with a Build Provenance table (sha256 for `keychain` and `keychain.1`) plus the tag commit SHA1. -* Workflow continues to only stage artifacts; publication requires explicit maintainer action (no auto-release on tag push). - -## keychain 2.9.5 (16 May 2025) - -This is a bugfix release. - -* Hardening checks were failing on Android and some MacOS environments. Make them - more compatible and lower to warnings instead of aborting the script, until - they have been tested in more environments. - ([#177](https://github.com/funtoo/keychain/issues/177)) - -* Fixed issues with indentation of `note()`, `warn()`, `mesg()`. - -* Convert `SSH_AUTH_SOCK in pidfile is invalid; ignoring it` into a debug message, - as this is normal when rebooting your system so is not really useful to show - typically. ([#176](https://github.com/funtoo/keychain/issues/176)) - -## keychain 2.9.4 (14 May 2025) - -This is a minor bugfix release. - -* Fix minor regression which allowed some warnings to display with `--quiet`. -([#175](https://github.com/funtoo/keychain/issues/175)) - -* "Cannot find separate public key" turned into a `note()` rather than `warn()`, - along with several other non-critical notices. `note()` can be suppressed with - `--quiet`, unlike `warn()`. ([#157](https://github.com/funtoo/keychain/issues/157)) - -* Minor improvement when wiping GnuPG keys with `--wipe` option so keychain output - is more understandable when gpg-agent is not running. - -## keychain 2.9.3 (14 May 2025) - -This is a security and bug fix release. Many thanks to those who have reported -issues to GitHub, send in pull requests, and tested out fixes. 2.9.3 includes -the following updates: - -* The `--quick` option logic had several bugs which have been resolved. Thanks - to Filipe Fernandes (@ffernand) for reporting the issue and for assistance - testing fixes. ([#167](https://github.com/funtoo/keychain/issues/167)) - -* Fix keychain `--query` exit code when no pidfile exists. - ([#171](https://github.com/funtoo/keychain/issues/171)) - -* `--systemd` option should now be fixed. - ([[#168](https://github.com/funtoo/keychain/issues/168)]) - -* Harden keychain so the use of the `--dir` and `--absolute` options cannot be - used to instruct keychain to write pidfiles into insecure areas. - ([#174](https://github.com/funtoo/keychain/issues/174)) - - Prior to this release, it was possible to use these options in combination - with bad (empty) default umask to write pidfiles into a public area on disk - where they were writable by other users. In the worst case, this could allow - arbitrary execution of the contents of the malicious pidfile by keychain. - - This hardening now makes it difficult for a user to configure their keychain - in a way that would allow this to happen. Note that if you are not using the - `--dir` or `--absolute` options, keychain will use the `$HOME/.keychain` - directory by default, which is typically under the full control of the - current user and thus not exploitable. - - The hardening changes include: - - * Setting a global restrictive `umask` in the script. - * Remove pidfiles before redirecting data to them to ensure they are created - with restrictive permissions from the `umask`. - * Check the keychain pidfile directory to ensure it is owned by the current - user, and only the current user can access it (mode 700). If not, abort - with an informative error message. - * Check any existing pidfiles prior to use to make sure they are owned by the - current user, and only the current user can access them. If not, abort with - an informative error message. - - Thanks to Eisuke Kawashima (@e-kwsm) for reporting this issue, the `--systemd` - issue, as well as for the `--query` fix. - - -## keychain 2.9.2 (2 May 2025) - -This is primarily a bug fix release, but also introduces the new `--extended` -option -- see below: - -* Deprecate `--confhost` option and replace with `--extended` option. The old - `--confhost myhost` would now be `--extended host:myhost`. This also allows - specifying SSH keys (`sshk:` prefix), GPG keys ( `gpgk:` prefix) and hosts - (`host:` prefix) together without confusion. -* Well, I became intimately familiar with `IFS` the hard way. Fix 2.9.1 bug - [#159](https://github.com/funtoo/keychain/issues/159) by reworking IFS settings and - adding proper documentation to the right places. This fixes the `--timeout` option - and also now allows `--stop` to work properly which was broken. -* Improve `--agents` deprecation warning. -* Have keychain properly adopt a currently-running gpg-agent providing ssh-agent - functionality when `--ssh-use-gpg` is specified. -* Explicitly clean up known-bad pidfiles during processing. -* Deprecate `--confhost` option and replace with new `--extended` option. -* Improve host-based key processing by using `ssh -G` to officially extract - host-based keys. -* Make `Makefile` BSD-compatible. - -## keychain 2.9.1 (1 May 2025) - -This release fixes a major bug related to the `--eval` option with non-Bourne shells. - -* Fix `--eval` option so it works with non-Bourne shells ([#158](https://github.com/funtoo/keychain/issues/158)). -* Last-minute option change: replace `--ssh-wipe` and `--gpg-wipe` with `--wipe [ssh|gpg|all]`. -* Deprecate `--attempts` option which doesn't work with gpg-agent pinentry nor modern OpenSSH. -* More script rewriting -- default to IFS of newline in the script, totally rework SSH and GPG - key adding code. -* Remove undocumented and likely unused `--` option. -* Script is now at a svelte 1049 lines of code. - -## keychain 2.9.0 (30 Apr 2025) - -These release notes contain a summary of all changes, including cumulative -changes in pre-releases: - -* A new release after 8 years, with Daniel Robbins (script creator) returning as maintainer. -* 60% of the script has been rewritten, and is now compliant with -[ShellCheck](https://shellcheck.net). -* `--agents` and `--inherit` options have been deprecated to improve ease-of-use. -* `gpg-agent` no longer started by default -- only when a GPG key has been provided on the - command-line. GnuPG 2.1+ supported. -* GnuPG pidfiles with `-gpg` extension are deprecated and no longer used. -* Better GnuPG integration: `gpg-agent` can be used for SSH key storage. This can be enabled - by specifying one of the new `--ssh-allow-gpg` and `--ssh-spawn-gpg` options. Agent information - for `gpg-agent`'s SSH socket will be stored in the regular pidfile for compatibility. -* Add `--ssh-rm`, `--ssh-wipe`, `--gpg-wipe` options for removing/wiping SSH and GPG keys. This addresses - GitHub Issue [#153](https://github.com/funtoo/keychain/issues/153). -* `--clear` option is now designed to be used for "initial clearing" of keys only. -* Many user interface output improvements, to provide additional detail. -* `--debug` option which can be used to troubleshoot issues with keychain. -* Manual page significantly improved: New section on invocation, as well as documentation of - the startup and agent detection algorithm. -* Addition of `--ssh-agent-socket` option to manually specify desired path of the ssh-agent socket - when starting. -* Addition of `--confallhosts` to load identity files for all hosts. -* Various bug fixes and improvements. -* Script size reduced from 1500 to 1133 lines. - -## keychain 2.9.0_beta4 (26 Apr 2025) - -* Rewrite key parsing code to remove unwanted use of `wantagent gpg` in the code. This may fix previous - bugs related to identifying and loading GPG keys. -* Fix GitHub Issue [#61](https://github.com/funtoo/keychain/issues/61) by ensuring that any error messages - generated when adding SSH or GPG keys are printed as warnings to facilitate troubleshooting by users. -* Manually merge in fish shell examples into `keychain.pod`. -* Resolve GitHub Issue [#75](https://github.com/funtoo/keychain/issues/75) and ensure that "IdentityFile" - allows case variations. - -## keychain 2.9.0_beta3 (25 Apr 2025) - -* The previous beta of keychain attempted to use gpg-agent by default instead of ssh-agent. This behavior has been - changed so that now you must opt-in to using gpg-agent. There are two new options to allow you to do this: - `--ssh-allow-gpg` and `--ssh-spawn-gpg`, which are documented in the man page. -* Displayed information about found/started agents has been greatly enhanced. -* New option `--debug`, which currently allows display of more information regarding - keychain's decisions. -* Full technical documentation for keychain's agent-detection algorithm in the man page. -* Key decision points in keychain's internal code now have better comments. -* Fixing behavior of `--noinherit` to match previous versions. -* Fixing behavior of `--quick` to match previous versions. -* Tweaking agent detection to match legacy `--inherit=local-once` option. -* Many documentation updates and improvements. -* Now at 1119 lines of code (from 1500 lines of code in keychain 2.8.5) - -## keychain 2.9.0_beta2 (23 Apr 2025) - -* Code has been overhauled to be more maintainable and various parts of the codebase have been rewritten. During this - process, which started with 2.9.0_alpha1, various things were broken. THIS IS THE FIRST POTENTIALLY VIABLE WORKING - RELEASE since 2.8.5, so PLEASE TEST AND PROVIDE FEEDBACK. It will be marked as a non-prerelease on GitHub to get - more active testing and feedback. -* Please note -- this version of keychain uses gpg-agent by default, if available. We are evaluating the consequences - of this change at the moment, request feedback on complications related to this change, and THIS DECISION MAY BE - REVERSED in the final release of 2.9.0 (we may go back to defaulting to ssh-agent, and have an option to use gpg-agent - in place of ssh-agent, instead of just automatically using gpg-agent.) Please provide feedback in GitHub issues - based on your experience. We have already found some challenges with the gpg-agent-by-default strategy, so no need - to convince anyone. Just share your feedback/opinion. -* ChangeLog has been converted to ChangeLog.md (thanks @d4g33z) to facilitate better interoperability with GitHub and - modern conventions. - -## keychain 2.9.0_beta1 (15 Apr 2025) - -* Keychain will now detect when gpg-agent is available and has ssh-agent functionality, and use gpg-agent by default. To disable this behavior, use the '--nosub' option to disable auto-substitution of gpg-agent for ssh-agent. This implements GitHub issue #67 requested by Martin Väth. -* Begin removal of support for gpg-agent earlier than 2.1. This release is about 9 years old at this point, and if you have a newer version of keychain on a system, it's likely you also have updated GNUPG. -* Update of keychain project URL to point to GitHub, and minor copyright updates. - -## keychain 2.9.0_alpha1 (9 Apr 2025) - -* Daniel Robbins returns as keychain maintainer. -* 'keychain' and 'keychain.1' removed from git repo and added to .gitignore. -* Make 'keychain.sh' fully-compliant with ShellCheck, and add necessary exception comments to the codebase. These changes require testing, as some of the suggested fixes are not desired, and I tried to catch all of these. Thus the \_alpha1 status. -* Merge in typographical errors from Peter Pentchev (@ppentchev). (commit d8a566d6402a2e93ce1664cb9c7e9df3233323de) -* Merge in validity checking for malformed SSH public key files, also from Peter Pentchev (@ppentchev). (commit 2722fdcc5d86725dd72995a83694862849494471) -* Merge in support for --agents, which adds detection of gpg-agent which was disabled. From Mikko Koivunalho (@mikkoi). (commit 1d170da33be908c742a0840191533fe90b82db5b) -* Merge in support for --agent-socket option, to specify the path for SSH_AUTH_SOCK manually. From Mikhail f. Shiryaev (@felixoid). (commit 2a3dfcd46e91c32a620d64035c42c8d2971c926f) -* Fix handling of exit codes. This fix is from Manolis Androulidakis (@manolis-andr). (commit ced07855cca34664c382aaaf536e719113f9e4e4) -* Add --confallhosts option to allow loading of keys from all hosts. This is from Ole Martin Ruud (@barskern). (commit 004107877d0258076653b291f25c1e6083fec0fb) -* Update GPL-2 license file. This is from Karol Babioch (@ghost). (commit 15ad9e1c8d5624dd762e503c4da014bcc8ebe890) - -## keychain 2.8.5 (24 Jan 2018) - -* Summary: Various fixes and support systemd gnupg sockets -* Some shells don't support local builtin (Roy Marples) -* Support systemd managed gnupg sockets (Pedro Romano) -* Fix some lintian warnings in the man page (Chris West) -* Fix issues loading pem keys (Jack Twilley) - -## keychain 2.8.4 (19 Oct 2017) - -* Summary: Support to GPG2 (Ryan Harris) -* Support busybox ps (Alastair Hughes) -* Various optimizations - -## keychain 2.8.3 (24 Jun 2016) - -* Summary: fix gpg key addition (Clemens Kaposi) - -## keychain 2.8.2 (06 Nov 2015) - -* Summary: Support new ssh features, bug fix release. -* Support for new hash algorithms (Ben Boeckel) -* Remove bashisms (Daniel Hertz) -* Various optimizations (Daniel Hahler) -* --timeout option now gets passed to agent, doc fixes (Andrew Bezella, Emil Lundberg) -* RPM, Makefile fixes (Mike Frysinger) - -## keychain 2.8.1 (29 May 2015) - -* Summary: POSIX compatibility and bug fix release. -* Only set PATH to a standard value if PATH is not set. Otherwise, do not modify. -* Makefile Cygwin and RPM spec fixes (thanks Luke Bakken and Ricardo Silva) -* Confhost fixes. Deprecate in_path. Use command -v instead. -* Find_pids: Modify "ps" call to work with non-GNU ps. (Bryan Drewery) -* Re-introduce POSIX compatibility (remove shopt.) (vaeth) - -## keychain 2.8.0 (21 Mar 2015) - -* Support for OpenSSH 6.8 fingerprints. -* Support for GnuPG 2.1.0. -* Handle private keys that are symlinks, even if the associated public key is in the target directory rather than alongside the symlink. -* Allow private keys to have extensions, such as foo.priv. When looking for matching public keys, look for foo.priv.pub, but also strip extension and look for foo.pub if foo.priv.pub doesn't exist. -* Initial support for --list/-l option to list SSH keys. -* Updated docs for fish shell usage. - -## keychain 2.7.2_beta1 (07 July 2014) - -* Various changes and updates: -* Fixes for fish from Marc Joliet. -* Keychain will default to start only ssh-agent unless GPG is explicitly updated using --agents. -* Write ~/.gpg-agent-info when launching gpg-agent - fix from Thomas Spura. -* Add support for injecting agents into systemd (Ben Boeckel) -* Add support for --query option (Ben Boeckel) -* Add --absolute flag, allowing user to set a full path without getting a .keychain suffix automatically appended. -* Add --confhost option to scan ~/.ssh/config file to locate private key path specified there. - -## keychain 2.7.1 (07 May 2010) - -* 07 May 2010; Daniel Robbins : Addition of a "make clean" target. removal of runtests as it is currently broken. -* 07 May 2010; Daniel Robbins : New release process in Makefile and release.sh - keychain release tarball will now contain pre-generated keychain, keychain.1 and keychain.spec so that users do not need to run "make". Updated README.rst to refer to the "source code" as a "release archive" since it contains both source code and ready-to-go script and man page. -* 14 Apr 2010; Daniel Robbins : GPG fix from Gentoo bug 203871; from Frederic Bathelery. This fix will fix the issue with pinentry starting in the background and not showing up in the terminal. -* 20 Feb 2010; Daniel Robbins : MacOS X documentation fix from James Turnbull. - -## keychain 2.7.0 (23 Oct 2009) - -* 23 Oct 2009; Daniel Robbins : updated README.rst with 2.7.0 and MacOS X package update. -* 18 Oct 2009; Daniel Robbins : lockfile() replacement from Parallels Inc. OpenVZ code, takelock() rewrite, resulting in ~100 line code savings. Default lock timeout set to 5 seconds, and now keychain will try to forcefully acquire the lock if the timeout aborts, rather than simply failing and aborting. -* 30 Sep 2009; Daniel Robbins : MacOS X/BSD improvements: fix sed call in Makefile for MacOS X and presumably other *BSD environments, Rename COPYING to COPYING.txt, slight COPYING.txt formatting changes to allow license to display more cleanly from MacOS X .pkg automated install. Fixed POD errors (removed '=end'). -* 29 Sep 2009; Daniel Robbins : disable "Identity added" messages when --quiet is specified (Gentoo bug #250328, thanks to Richard Laager,) --help will print output to stdout (Gentoo bug #196060, thanks to Elan Ruusamäe,) output cleanup and colorization changes - moving away from blue and over to cyan as it displays better terminals with black background. Also some additional colorization. Version bump to 2.6.10. - -## keychain 2.6.9 (26 Jul 2009) - -* 26 Jul 2009; Daniel Robbins : Close Gentoo bug 222953 from Bernd Petrovitsch to fix potential issues with GNU grep, Mac OS X color fix when called with --eval from Aron Griffis , Perl 5.10 Makefile fix from Aron Griffis . Transition README to README.rst (reStructuredText). Updated maintainership information. Simplified default output ( --version or --help now required to show version, copyright and license information.) - -## keychain 2.6.8 (24 Oct 2006) - -* 24 Oct 2006; Aron Griffis : - Save `LC_ALL` for gpg invocation so that pinentry-curses works. This affected peper and kloeri, though it seems to work for me in any case. - -## keychain 2.6.7 (24 Oct 2006) - -* 24 Oct 2006; Aron Griffis : - Prevent `gpg_listmissing` from accidentally loading keys - -## keychain 2.6.6 (08 Sep 2006) - -* 08 Sep 2006; Aron Griffis : - Make --lockwait -1 mean forever. Previously 0 meant forever but was undocumented. Add more locking regression tests #137981 - -## keychain 2.6.5 (08 Sep 2006) - -* 08 Sep 2006; Aron Griffis : - Break out of loop when empty lockfile can't be removed #127471. Add locking regression tests: - `100_lock_stale` `101_lock_held` `102_lock_empty` `103_lock_empty_cant_remove` - -## keychain 2.6.4 (08 Sep 2006) - -* 08 Sep 2006; Aron Griffis : - Add validinherit function so that validity of `SSH_AUTH_SOCK` and friends can be validated from startagent rather than up front. The advantage is that warning messages aren't emitted unnecessarily when `--inherit *-once`. - Fix `--eval` for fish, and add new testcases: - * `053_start_with_--eval_ksh` - * `054_start_with_--eval_fish` - * `055_start_with_--eval_csh` - -## keychain 2.6.3 (07 Sep 2006) - -* 07 Sep 2006; Aron Griffis : - Support fish: http://roo.no-ip.org/fish/ - Thanks to Ilkka Poutanen for the patch. - -## keychain 2.6.2 (20 Mar 2006) - -* 20 Mar 2006; Aron Griffis : - Add `--confirm` option and corresponding regression tests for Debian bug 296382. Thanks to Liyang HU for the patch. Also add initialization for `$ssh_timeout` which was being inherited from the environment and add regression tests for `--timeout` - -## keychain 2.6.1 (10 Oct 2005) - -* 10 Oct 2005; Aron Griffis : - Change `unset evalopt` to `evalopt=false` and run through *all* the regression tests instead of just the new ones. *sigh* - -## keychain 2.6.0 (10 Oct 2005) - -* 10 Oct 2005; Aron Griffis : - Add the `--eval` option which makes keychain startup easier. See the man-page for examples. Get rid of the release notes from README, so now this file is where changes are tracked. - -## keychain 2.5.5 (28 Jul 2005) - -* 28 Jul 2005; Aron Griffis : - Add the `--env` option and automatic reading of `.keychain/env`. This allows variables such as PATH to be overridden for peculiar environments - -## keychain 2.5.4.1 (11 May 2005) - -* 11 May 2005; Aron Griffis : - A minor bug in 2.5.4 resulted in always exiting with non-zero status. Change back to the correct behavior of zero for success, non-zero for failure - -## keychain 2.5.4 (11 May 2005) - -* 11 May 2005; Aron Griffis : - Fix bug 92316: If any locale variables are set, override them with `LC_ALL=C`. This fixes a multibyte issue with awk that could keep a running ssh-agent from being found. - Fix bug 87340: Use files instead of symlinks for locking, since symlink creation is not atomic on cygwin. - -## keychain 2.5.3.1 (10 Mar 2005) - -* 10 Mar 2005; Aron Griffis : - Fix problem introduced in 2.5.3 wrt adding gpg keys to the agent. Thanks to Azarah for spotting it. - -## keychain 2.5.3 (09 Mar 2005) - -* 09 Mar 2005; Aron Griffis : - Improve handling of DISPLAY by unsetting if blank. Call gpg with `--use-agent` explicitly. - -## keychain 2.5.2 (06 Mar 2005) - -* 06 Mar 2005; Aron Griffis : - Fix bug 78974 "keychain errors on Big/IP (x86 BSD variant)" by refraining from using ! in conditional expressions. Fix RSA fingerprint extraction on Solaris, reported in email by Travis Fitch. Use \$HOSTNAME when possible instead of calling `uname -n` to improve bash_profile compatibility. - -## keychain 2.5.1 (12 Jan 2005) - -* 12 Jan 2005; Aron Griffis : - Don't accidentally inherit a forwarded agent when inheritwhich=local-once. Move the --stop warning after the version splash. - -## keychain 2.5.0 (07 Jan 2005) - -* 07 Jan 2005; Aron Griffis : - Add inheritance support via --inherit. Add parameters to --stop for more control. Change the default behavior of keychain to inherit if there's no keychain agent running ("--inherit local-once"), and refrain from killing other agents unless "--stop others" is specified. - -## keychain 2.4.3 (17 Nov 2004) - -* 17 Nov 2004; Aron Griffis : - Fix bug 69879: Update findpids to work again on BSD; it has been broken since the changes in version 2.4.2. Now we use OSTYPE (bash) or uname to determine the system type and call ps appropriately. - -## keychain 2.4.2.1 (30 Sep 2004) - -* 30 Sep 2004; Aron Griffis : - Fix minor issues in the test for existing gpg keys wrt DISPLAY - -## keychain 2.4.2 (29 Sep 2004) - -* 29 Sep 2004; Aron Griffis : - Make gpg support more complete. Allow adding keys, clearing the agent, etc. Fix --quick support to work properly again; it was broken since 2.4.0. Change default --attempts to 1 since the progs ask multiple times anyway. - -## keychain 2.4.1 (22 Sep 2004) - -* 22 Sep 2004; Aron Griffis : - Fix bugs 64174 and 64178; support Sun SSH, which is really OpenSSH in disguise and a few critical outputs changed. Thanks to Nathan Bardsley for lots of help debugging on Solaris 9 -* 15 Sep 2004; Aron Griffis : - Fix pod2man output so it formats properly on SGI systems. Thanks to Matthew Moore for reporting the problem. - -## keychain 2.4.0 (09 Sep 2004) - -* 09 Sep 2004; Aron Griffis : - Fix bug 26970 with first pass at gpg-agent support -* Fix Debian bug 269722; don't filter output of ssh-add -* Fix bug reported by Marko Myllynen regarding keychain and Solaris awk's inability to process -F'[ :]' -* Fix bug in now_seconds calculation, noticed by me. - -## keychain 2.3.5 (28 Jul 2004) - -* 28 Jul 2004; Aron Griffis : - Fix bug 58623 with patch from Daniel Westermann-Clark; don't put an extra newline in the output of listmissing -* Generate keychain.spec from keychain.spec.in automatically so that the version can be set appropriately. - -## keychain 2.3.4 (24 Jul 2004) - -* 24 Jul 2004; Aron Griffis : - Fix bug 28599 reported by Bruno Pelaia; ignore defunct processes in ps output - -## keychain 2.3.3 (30 Jun 2004) - -* 30 Jun 2004; Aron Griffis : - Fix bug reported by Matthew S. Moore in email; escape the backticks in --help output -* Fix bug reported by Herbie Ong in email; set pidf, cshpidf and lockf variables after parsing command-line to honor --dir setting -* Fix bug reported by Stephan Stahl in email; make spaces in filenames work throughout keychain, even in pure Bourne shell -* Fix operation on HP-UX with older OpenSSH by interpreting output of ssh-add as well as the error status - -## keychain 2.3.2 (16 Jun 2004) - -* 16 Jun 2004; Aron Griffis : - Fix bug 53837 (keychain needs ssh-askpass) by unsetting SSH_ASKPASS when --nogui is specified - -## keychain 2.3.1 (03 Jun 2004) - -* 03 Jun 2004; Aron Griffis : - Fix bug 52874: problems when the user is running csh - -## keychain 2.3.0 (14 May 2004) - -* 14 May 2004; Aron Griffis : - Rewrite the locking code to avoid procmail - -## keychain 2.2.2 (03 May 2004) - -* 03 May 2004; Aron Griffis : - Call loadagent prior to generating `$HOSTNAME-csh` file so that variables are set. - -## keychain 2.2.1 (27 Apr 2004) - -* 27 Apr 2004; Aron Griffis : - Find running ssh-agent processes by searching for /[s]sh-agen/ instead of /[s]sh-agent/ for the sake of Solaris, which cuts off ps -u output at 8 characters. Thanks to Clay England for reporting the problem and testing the fix. - -## keychain 2.2.0 (21 Apr 2004) - -* 21 Apr 2004; Aron Griffis : - Rewrote most of the code, organized into functions, fixed speed issues involving ps, fixed compatibility issues for various UNIXes, hopefully didn't introduce too many bugs. This version has a --quick option (for me) and a --timeout option (for carpaski). -* Also added a Makefile and converted the man-page to pod for easier editing. See perlpod(1) for information on the format. Note that the pod is sucked into keychain and colorized when you run make. - -## keychain 2.0.3 (06 Apr 2003) - -* 06 Apr 2003; Seth Chandler : - Added keychain man page, fixed bugs with displaying colors for keychain --help. Also added a \$grepopts to fix the grepping for a pid on cygwin Also added a TODO document color fix based on submission by Luke Holden - -## keychain 2.0.2 (26 Aug 2002) - -* 26 Aug 2002; the Tru64 fix didn't work; it was being caused by "trap - foo" rather than "tail +2 -". Now really fixed. -* 26 Aug 2002; fixed "ssh-add" call to only redirect stdin (thus enabling ssh-askpass) if ssh_askpass happens to be set; this is to work around a bug in openssh were redirecting stdin will enable ssh-askpass even if ssh_askpass isn't set, which contradicts the openssh 3.4_p1 man page. to enable ssh-askpass, keychain now requires that the ssh_askpass var be set to point to your askpass program. - -## keychain 2.0.1 (24 Aug 2002) - -* 24 Aug 2002; "--help" fixes; the keychain files were listed as sh-\${HOSTNAME} rather than \${HOSTNAME}-sh. Now consistent with the actual program. Thanks to Christian Plessl , others for reporting this issue. -* 24 Aug 2002; cycloon : "If you add < /dev/null when adding the missingkeys via "ssh-add \${missingkeys}" (at line 454 of version 2.0) so that it reads: "ssh-add \${missingkeys} < /dev/null" then users can use program like x11-ssh-askpass in xfree to type in their passphrase. It then still works for users on shell, depending if \$DISPLAY is set." Added. -* 24 Aug 2002; A fix to calling "tail" that *should* fix things for Tru64 Unix; unfortunately, I have no way to test but the solution should be portable to all other flavors of systems. Thanks to Mark Scarborough for reporting the issue. -* 24 Aug 2002; Changed around the psopts detection stuff so that "-x -u \$me f" is used; this is needed on MacOS X. Thanks to Brian Bergstrand , others for reporting this issue. - -## keychain 2.0 (17 Aug 2002) - -* 17 Aug 2002; (Many submitters): A fix for keychain when running on HP-UX 10.20. -* 17 Aug 2002; Patrice DUMAS - DOCT : Now perform help early on to avoid unnecessary processing. Also added --dir option to allow keychain to look in an alternate location for the .keychain directory (use like this: "keychain --dir /var/foo") -* 17 Aug 2002; Martial MICHEL : Martial also suggested moving help processing to earlier in the script. He also submitted a patch to place .ssh-agent-* files in a ~/.keychain/ directory, which makes sense particularly for NFS users so I integrated the concept into the code. -* 17 Aug 2002; Fred Carter : Cygwin fix to use proper "ps" options. -* 17 Aug 2002; Adrian Howard : patch so that lockfile gets removed even if --noask is specified. -* 17 Aug 2002; Mario Wolff : Replaced an awk dependency with a shell construct for improved performance. -* 17 Aug 2002; Marcus Stoegbauer , Dmitry Frolov : I (Daniel Robbins) solved problems reported by Marcus and Dmitry (mis-parsed command line issues) by following Dmitry's good suggestion of performing argument parsing all at once at the top of the script. -* 17 Aug 2002; Brian W. Curry : Added commercial SSH2 client support; improved output readability by initializing myfail=0; integrated Cygwin support into the main keychain script; improved Cygwin support by setting "trap" appropriately. Thanks Brian! - -## keychain 1.9 (04 Mar 2002) - -* 04 Mar 2002; changed license from "GPL, v2 or later" to "GPL v2". -* 04 Mar 2002; added "keychain.cygwin" for Cygwin systems. It may be time to follow this pattern and start building separate, optimized scripts for each platform so they don't get too sluggish. Maybe I could use a C preprocessor for this. -* 06 Dec 2001; several people: Solaris doesn't like '-e' comparisons; switched to '-f' - -## keychain 1.8 (29 Nov 2001) - -* 29 Nov 2001; Philip Hallstrom (philip@adhesivemedia.com) Added a "--local" option for removing the \${HOSTNAME} from the various files that keychain creates. Handy for non-NFS users. -* 29 Nov 2001; Aron Griffis (agriffis@gentoo.org) Using the Bourne shell "type" builtin rather than using the external "which" command. Should make things a lot more robust and slightly faster. -* 09 Nov 2001; Mike Briseno (mike@radik.com) Solaris' "which" command outputs "no lockfile in..." to stdout rather than stderr. A one-line fix (test the error condition) has been applied. -* 09 Nov 2001; lockfile settings tweak -* 09 Nov 2001; Rewrote how keychain detects failed passphrase attempts. If you stop making progress providing valid passphrases, it's three strikes and you're out. -* 09 Nov 2001; Constantine P. Sapuntzakis (csapuntz@stanford.edu) Some private keys can't be "ssh-keygen -l -f"'d; this patch causes keychain to look for the corresponding public key if the private key doesn't work. Thanks Constantine! -* 09 Nov 2001; Victor Leitman (vleitman@yahoo.com) CYAN color misdefined; fixed. -* 27 Oct 2001; Brian Wellington (bwelling@xbill.org) A "quiet mode" (--quiet) fix; I missed an "echo". -* 27 Oct 2001; J.A. Neitzel (jan@belvento.org) Missed another "kill -9"; it's now gone. - -## keychain 1.7 (21 Oct 2001) - -* 21 Oct 2001; Frederic Gobry (frederic.gobry@smartdata.ch) Frederic suggested using procmail's lockfile to serialize the execution of critical parts of keychain, thus avoiding multiple ssh-agent processes being started if you happen to have multiple xterms open automatically when you log in. Initially, I didn't think I could add this, since systems may not have the lockfile command; however, keychain will now auto-detect whether lockfile is installed; if it is, keychain will automatically use it, thus preventing multiple ssh-agent processes from being spawned. -* 21 Oct 2001; Raymond Wu (ursus@usa.net): --nocolor test is no longer inside the test for whether "echo -e" works. According to Raymond, this works optimally on his Solaris box. -* 21 Oct 2001; J.A. Neitzel (jan@belvento.org): No longer "kill -9" our ssh-agent processes. SIGTERM should be sufficient and will allow ssh-agent to clean up after itself (this reverses a previously-applied patch). -* 21 Oct 2001; Thomas Finneid (tfinneid@online.no): Added argument "--quiet | -q" to make the program less intrusive to the user; with it, only error and interactive messages will appear. -* 21 Oct 2001; Thomas Finneid (tfinneid@online.no): Changed the format of some arguments to bring them more in line with common *nix programs: added "-h" as alias for "--help"; added "-k" as alias for "--stop" -* 21 Oct 2001; Mark Stosberg (mark@summersault.com): \$pidf to "\$pidf" fixes to allow keychain to work with paths that include spaces (for Darwin and MacOS X in particular). -* 21 Oct 2001; Jonathan Wakely (redi@redi.uklinux.net): Small patch to convert "echo -n -e" to "echo -e "\c"" for FreeBSD compatibility. - -## keychain 1.6 (15 Oct 2001) - -* 13 Oct 2001; Ralf Horstmann (ralf.horstmann@webwasher.com): Add /usr/ucb to path for Solaris systems. -* 11 Oct 2001; Idea from Joe Reid (jreid@vnet.net): Try to add multiple keys using ssh-add; avoid typing in identical passphrases more than once. Good idea! - -## keychain 1.5 (21 Sep 2001) - -* 21 Sep 2001; David Hull (hull@paracel.com): misc. compatibility, signal handling, cleanup fixes -* 21 Sep 2001; "ps" test to find the right one for your OS. -* 20 Sep 2001; Marko Myllynen (myllynen@lut.fi): "grep [s]sh-agent" to "grep [s]sh-agent" (zsh fix) - -## keychain 1.4 (20 Sep 2001) - -* 20 Sep 2001; David Hull (hull@paracel.com): "touch \$foo" to ">\$foo" optimization and other "don't fork" fixes. Converted \${foo#--} to a case statement for Solaris sh compatibility. -* 20 Sep 2001; Try an alternate "ps" syntax if our default one fails. This should give us Solaris and IRIX (sysV) compatibility without breaking BSD. -* 20 Sep 2001; Hans Peter Verne (h.p.verne@usit.uio.no); "echo -e" to "echo \$E" (for IRIX compatibility with --nocolor), optimization of grep ("grep [s]sh-agent") -* 17 Sep 2001; Marko Myllynen (myllynen@lut.fi): Various fixes: trap signal 2 if signal INT not supported (NetBSD); handle invalid keys correctly; ancient version of ash didn't support ~, so using \$HOME; correct zsh instruction; minor cleanups - -## keychain 1.3 (12 Sep 2001) - -* 12 Sep 2001; Minor color changes; the cyan was hard to read on xterm-colored terms so it was switched to bold. Additional --help text added. -* 10 Sep 2001; We now use .ssh-agent-[hostname] instead of .ssh-agent. We now create a .ssh-agent-csh-[hostname] file that can be sourced by csh-compatible shells. We also now kill all our existing ssh-agent processes before starting a new one. -* 10 Sep 2001; Robert R. Wal (rrw@hell.pl): Very nice NFS fixes, colorization fixes, tcsh redirect -> grep -v fix. Thanks go out to others who sent me similar patches. -* 10 Sep 2001; Johann Visagie (johann@egenetics.com): "source" to "." shell-compatibility fixes. Thanks for the FreeBSD port. -* 10 Sep 2001; Marko Myllynen (myllynen@lut.fi): rm -f \$pidf after stopping ssh-agent fix - -## keychain 1.2 (09 Sep 2001) - -* 09 Sep 2001; README updates to reflect new changes. -* 09 Sep 2001; Marko Myllynen (myllynen@lut.fi): bash 1/zsh/sh compatibility; now only tries to kill *your* ssh-agent processes, version fix, .ssh-agent file creation error detection. Thanks! - -## keychain 1.1 (07 Sep 2001) - -* 07 Sep 2001; Addition of README stating that keychain requires bash 2.0 or greater, as well as quick install directions and web URL. -* 07 Sep 2001; Explicitly added /sbin and /usr/sbin to path, and then called "pidof". I think that this is a bit more robust. -* 06 Sep 2001; from John Ellson (ellson@lucent.com): "pidof" changed to "/sbin/pidof", since it's probably not in \$PATH -* 06 Sep 2001; New ChangeLog! :) - -## keychain 1.0 (Aug 2001) -* initial release +# ChangeLog + +## 3.0.0_beta1 + +Initial public beta of Keychain 3.x. + +Keychain 3 is a ground-up Python 3 rewrite of Daniel Robbins' long-running +SSH/GPG agent manager. The release preserves the traditional single-file +deployment model through `keychain.pyz`, while replacing the historical +Bourne shell implementation with a tested, auditable Python package. + +Highlights: + +- Ships as a standalone `keychain.pyz` with no third-party runtime + dependencies. +- Requires Python 3.9 or newer at runtime; the zipapp bootstrap can re-exec + into a newer `python3.NN` on systems where `/usr/bin/env python3` is below + the floor. +- Adds an action-oriented command surface such as `keychain add`, + `keychain agent start`, `keychain agent stop`, `keychain list`, + `keychain env`, `keychain inspect`, `keychain help`, and `keychain man`. +- Keeps keychain 2.x-style invocations working through an explicit + compatibility layer. +- Embeds documentation in the zipapp; use `keychain man` and + `keychain man --list` to browse it. +- Uses a default-deny model for `KEYCHAIN_*` environment variables; pass + `--allow-env` / `-E` when legacy environment-variable behavior is desired. +- Releases under GPLv3 for the 3.x series. Keychain 2.x remains GPLv2. + +Known beta notes: + +- WSL login-shell startup can run keychain in a noninteractive/no-TTY context + when invoked by automation. This may fall through to `ssh_askpass`; stale + WSL `/tmp/ssh-*` sockets and hostname-specific pidfiles are tracked for + follow-up polish. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/MAINTAINERS.txt b/MAINTAINERS.txt deleted file mode 100644 index 306f42d..0000000 --- a/MAINTAINERS.txt +++ /dev/null @@ -1,6 +0,0 @@ -Originally authored by Daniel Robbins -Maintained August 2002 - April 2003 by Seth Chandler -Maintained and rewritten April 2004 - July 2007 by Aron Griffis -Maintained July 2009 - Sept 2017 by Daniel Robbins -Maintained September 2017 - 2018 by Ryan Harris -Maintained currently by Daniel Robbins diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..e169a21 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include man/embedded-docs.txt +include scripts/build_backend.py +include scripts/build_doc_texts.py +include scripts/pyz_bootstrap.py diff --git a/Makefile b/Makefile index 42a0410..59bc0ab 100644 --- a/Makefile +++ b/Makefile @@ -1,100 +1,43 @@ -# For BSD, AIX, Solaris: -V:sh = cat VERSION -D:sh = date +'%d %b %Y' -Y:sh = date +'%Y' - -# for GNU Make: -V ?= $(shell cat VERSION) -D ?= $(shell date +'%d %b %Y') -Y ?= $(shell date +'%Y') - -PREFIX ?= /usr/local -COMPLETIONSDIR ?= $(PREFIX)/share/bash-completion/completions - -all: keychain.1 keychain keychain.spec - -.PHONY : tmpclean -tmpclean: - rm -rf dist keychain.1.orig keychain.txt - -.PHONY : clean -clean: tmpclean - rm -rf keychain.1 keychain keychain.spec - -keychain.spec: keychain.spec.in keychain.sh VERSION - sed 's/KEYCHAIN_VERSION/$V/' keychain.spec.in > keychain.spec - -keychain.1: keychain.pod keychain.sh VERSION - pod2man --name=keychain --release=$V \ - --center='https://github.com/danielrobbins/keychain' \ - keychain.pod keychain.1 - sed -i.orig -e "s/^'br /.br /" keychain.1 - -keychain.1.gz: keychain.1 - gzip -9 keychain.1 - -GENKEYCHAINPL = open P, "keychain.txt" or die "cannot open keychain.txt"; \ - while (

) { \ - $$printing = 0 if /^\w/; \ - $$printing = 1 if /^(SYNOPSIS|OPTIONS)/; \ - $$printing || next; \ - s/\$$/\\\$$/g; \ - s/\`/\\\`/g; \ - s/\\$$/\\\\/g; \ - s/\*(\w+)\*/\$${CYAN}$$1\$${OFF}/g; \ - s/(^|\s)(-+[-\w]+)/$$1\$${GREEN}$$2\$${OFF}/g; \ - $$pod .= $$_; \ - }; \ - open B, "keychain.sh" or die "cannot open keychain.sh"; \ - $$/ = undef; \ - $$_ = ; \ - s/INSERT_POD_OUTPUT_HERE[\r\n]/$$pod/ || die; \ - s/\#\#VERSION\#\#/$V/g || die; \ - print - -keychain: keychain.sh keychain.txt VERSION MAINTAINERS.txt - perl -e '$(GENKEYCHAINPL)' | sed -e 's/##CUR_YEAR##/$(Y)/g' >keychain || rm -f keychain - chmod +x keychain - -keychain.txt: keychain.pod - pod2text keychain.pod keychain.txt - -dist/keychain-$V.tar.gz: keychain keychain.1 keychain.spec +# keychain — Makefile for building keychain.pyz (single-file Python executable) +# +# Prerequisites: Python 3.9+ on the build host. No other dependencies needed. +# +# Targets: +# make keychain.pyz — build the zipapp +# make clean — remove build artifacts + +V := $(shell cat VERSION) + +.PHONY : all clean keychain.pyz release-artifacts + +all : keychain.pyz + +src/keychain/docs/_doc_texts.json : man/embedded-docs.txt scripts/build_doc_texts.py + python3 scripts/build_doc_texts.py + +keychain.pyz : Makefile $(shell find src/keychain -name '*.py') src/keychain/docs/_doc_texts.json man/embedded-docs.txt scripts/build_doc_texts.py VERSION scripts/pyz_bootstrap.py + rm -rf build/pyz-stage + mkdir -p build/pyz-stage + cp -r src/keychain build/pyz-stage/ + cp VERSION build/pyz-stage/keychain/VERSION + find build/pyz-stage -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null || true + python3 -m compileall -q build/pyz-stage + cp scripts/pyz_bootstrap.py build/pyz-stage/__main__.py + python3 -m zipapp build/pyz-stage -o keychain.pyz -p '/usr/bin/env python3' -c + chmod +x keychain.pyz + +dist : mkdir -p dist - rm -rf dist/keychain-$V - git archive --format=tar --prefix=keychain-$V/ HEAD | tar -xf - -C dist/ - cp keychain keychain.1 keychain.spec dist/keychain-$V/ - tar -C dist -czf dist/keychain-$V.tar.gz keychain-$V - rm -rf dist/keychain-$V - ls -l dist/keychain-$V.tar.gz - -# --- Release Automation Helpers --- -.PHONY: release release-refresh - -RELEASE_ASSETS=dist/keychain-$V.tar.gz keychain keychain.1 - -# "release" will orchestrate a tagged release with CI artifact validation & confirmation. -release: clean $(RELEASE_ASSETS) - @echo "Orchestrating release $(V)"; \ - if [ -z "$$GITHUB_TOKEN" ]; then \ - echo "GITHUB_TOKEN not set; export a repo-scoped token to proceed." >&2; exit 1; \ - fi; \ - ./scripts/release-orchestrate.sh create $(V) -# "release-refresh" updates assets of an existing GitHub release (e.g. fixups) with CI validation. -release-refresh: clean $(RELEASE_ASSETS) - @echo "Orchestrating release-refresh $(V)"; \ - if [ -z "$$GITHUB_TOKEN" ]; then \ - echo "GITHUB_TOKEN not set; export a repo-scoped token to proceed." >&2; exit 1; \ - fi; \ - ./scripts/release-orchestrate.sh refresh $(V) +dist/keychain-$(V).pyz : keychain.pyz | dist + cp keychain.pyz dist/keychain-$(V).pyz -# --- Bash Completion --- -.PHONY: install-completions uninstall-completions +dist/SHA256SUMS : dist/keychain-$(V).pyz + cd dist && sha256sum keychain-$(V).pyz > SHA256SUMS -install-completions: - install -d -m 0755 $(DESTDIR)$(COMPLETIONSDIR) - install -m 0644 completions/keychain.bash $(DESTDIR)$(COMPLETIONSDIR)/keychain +release-artifacts : dist/keychain-$(V).pyz dist/SHA256SUMS + cd dist && sha256sum -c SHA256SUMS -uninstall-completions: - rm -f $(DESTDIR)$(COMPLETIONSDIR)/keychain +clean : + rm -rf dist build keychain.pyz src/keychain/docs/_doc_texts.json + find src -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null || true diff --git a/README.md b/README.md index ffad46b..219d6aa 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,167 @@ +# Keychain -Introduction to Keychain -======================== +Keychain manages SSH and GPG agent state for login shells, cron jobs, remote +sessions, and long-running user environments. It is a frontend to `ssh-agent`, +`ssh-add`, and `gpg-agent` that lets a user keep one useful agent per host +instead of spawning a new agent for every terminal session. -`Keychain` helps you to manage SSH and GPG keys in a convenient and secure -manner. It acts as a frontend to `ssh-agent` and `ssh-add`, but allows you -to easily have one long running `ssh-agent` process per system, rather than -the norm of one `ssh-agent` per login session. +Keychain 3 is a Python rewrite of the original Bourne shell tool created by +Daniel Robbins. It preserves the single-file deployment model that made +Keychain useful for two decades, while moving the implementation to a runtime +that can be tested, audited, and extended with confidence. -This dramatically reduces the number of times you need to enter your -passphrase. With `keychain`, you only need to enter a passphrase once every -time your local machine is rebooted. `Keychain` also makes it easy for remote -cron jobs to securely "hook in" to a long running `ssh-agent` process, -allowing your scripts to take advantage of key-based logins. +For more on that decision, see +[Why Keychain 3 Uses Python](docs/python-rationale.md). -`Keychain` also integrates with `gpg-agent`, so that GPG keys can be cached -at the same time as SSH keys. +Official project page: -Bash Completion -=============== +```text +https://kernel-seeds.org/projects/keychain/ +``` + +Source repository: + +```text +https://github.com/danielrobbins/keychain +``` + +## Install + +Keychain 3 ships as a Python zipapp, `keychain.pyz`. The zipapp has no +third-party runtime dependencies and does not require `pip`; it only needs +Python 3.9 or newer. You can inspect the source code of the installed zipapp +by using the `unzip` command, so it is easily auditable. + +Install it as `keychain` somewhere in `PATH`: + +```console +sudo cp keychain.pyz /usr/local/bin/keychain +sudo chmod 755 /usr/local/bin/keychain +keychain version +``` + +On systems where `/usr/bin/env python3` is older than Python 3.9, the zipapp +bootstrap looks for a newer `python3.NN` on `PATH` and re-execs into it before +importing Keychain. + +## Quick Start + +For a Bourne-compatible shell, a typical login setup is: + +```sh +eval "$(keychain add --eval ~/.ssh/id_ed25519)" +``` + +Keychain will start or reuse an agent, ensure the requested identity is loaded, +and write reusable environment files under `~/.keychain/` so later shells and +cron jobs can attach to the same agent. + +You can also inspect the current state without changing it: -Keychain includes bash completion support for command-line options, SSH keys, -GPG keys, and the `--extended` key format (`sshk:`, `gpgk:`, `host:`). +```console +keychain inspect +keychain inspect --json +``` + +## Command Map + +Keychain 3 uses an action-oriented command surface: + +```console +keychain add KEY... start or reuse an agent and load keys +keychain env print reusable agent environment +keychain list list keys currently held by ssh-agent +keychain inspect show how Keychain sees the current state +keychain agent start start or reuse an agent +keychain agent stop stop Keychain-managed agents +keychain wipe remove loaded SSH/GPG keys +keychain forget KEY... remove specific SSH keys from ssh-agent +keychain man open the embedded manual +keychain man --list list embedded documentation topics +``` + +The 2.x flat command style remains supported through an explicit compatibility +layer, so existing shell startup snippets can continue to work while users move +to the clearer 3.x commands. -Most Linux distributions will install the completion script automatically when -you install keychain via your package manager. +## Configuration -For manual installation: +Keychain 3 introduces `~/.keychainrc` for persistent preferences. This keeps +normal interactive configuration out of ambient shell environment variables. -- **System-wide** (requires `bash-completion` package and root access): - ``` - sudo make install-completions - ``` - This installs to `/usr/local/share/bash-completion/completions/` by default. - Use `PREFIX=/usr` for `/usr/share/bash-completion/completions/`. +Example: -- **User-only** (no root required): - ``` - mkdir -p ~/.local/share/bash-completion/completions - cp completions/keychain.bash ~/.local/share/bash-completion/completions/keychain - ``` +```ini +[keychain] +quiet = true +lockwait = 5 -After installation, restart your shell or run: +[agent.ssh] +args = -t 3600 + +[agent.gpg] +args = --default-cache-ttl 3600 ``` -source /etc/bash_completion + +For the full configuration schema: + +```console +keychain man topic:config +keychain man --list ``` -**Tip:** If pressing tab doesn't show all possible completions when there are -multiple matches (e.g., `sshk:id_` completes to common prefix but doesn't -list all keys), add this to your `~/.inputrc`: +## Explain Mode + +Append `--explain` to an invocation to see how Keychain understands that +argument chain and which embedded documentation applies: + +```console +keychain add --quick --eval ~/.ssh/id_ed25519 --explain +keychain --list --explain +keychain agent start --explain ``` -set show-all-if-ambiguous on + +This is useful when checking compatibility-mode invocations, new options, or +commands copied from older Keychain documentation. + +## Documentation + +Keychain 3 embeds its documentation: + +```console +keychain help +keychain man +keychain man --list +keychain man add +keychain man topic:agents ``` -Then restart your shell or run `bind -f ~/.inputrc`. -Support This Project -==================== +Use the project page for release notes and broader project context: + +```text +https://kernel-seeds.org/projects/keychain/ +``` -Keychain is maintained by [BreezyOps](https://breezyops.com) - -Daniel Robbins' Open Source Innovation Lab. If you find it useful, please consider: +## Environment Variables -- Starring the repository ⭐ -- Joining [Discussions](https://github.com/danielrobbins/keychain/discussions) to share tips and ask questions 💬 -- [Supporting development](https://paypal.me/breezyops) to help maintain and improve keychain! ❤️ +Keychain 3 ignores `KEYCHAIN_*` environment variables by default. To opt in to +legacy environment-driven behavior for a specific invocation, pass: -Your support helps keep this project alive and actively maintained, and supports the creation of future projects. Thank you! +```console +keychain --allow-env ... +keychain -E ... +``` -IMPORTANT - GitHub Contributors -=============================== +This gate allows `KEYCHAIN_CONFIG`, `KEYCHAIN_THEME`, +`KEYCHAIN_SSH_AGENT_ARGS`, and `KEYCHAIN_GPG_AGENT_ARGS` to affect the run. +For normal use, prefer `~/.keychainrc`. -Please submit pull requests against the `master` branch which should track official -releases. Before submitting your PR, please: +## Platform Notes -1. Make sure that you have [ShellCheck](https://shellcheck.net) enabled in your - IDE and that your changes don't introduce any bashisms or other non-POSIX things. - For any *intended* exceptions, such as non-quoting of expanded variables, please - insert a commented ShellCheck exception to disable the warning, and if not totally - obvious, then add a comment to the exception like this: +Keychain 3 targets POSIX-shaped systems with Python 3.9 or newer, including +Linux, macOS, BSDs, and WSL. - # shellcheck disable=SC2086 # this is intentional: +## License - If you do not understand a ShellCheck warning, then don't just blindly disable it. - Do some research first, make any necessary changes, and then submit your PR. -2. Please use tabs for initial indentation, not spaces. -3. Don't use tabs at the end of lines, such as to align comments. Either use a full - line to add a comment or add a short comment at the end of a command, separating - the "#" from the actual command with just a single space. -4. For any new features or options, update `keychain.pod` with documentation on how - to use the new feature. +Keychain 3.x is released under GPLv3; see `LICENSE`. Previous Keychain 2.x +releases remain under GPLv2. diff --git a/VERSION b/VERSION index d8589d1..24b091b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.9.8 +3.0.0_beta1 diff --git a/completions/keychain.bash b/completions/keychain.bash deleted file mode 100644 index 0296923..0000000 --- a/completions/keychain.bash +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env bash -# Bash completion for keychain -# https://github.com/danielrobbins/keychain -# -# Original script by Mikko Koivunalho (@mikkoi) -# https://github.com/mikkoi/keychain-bash-completion -# Enhanced with --extended mode support by Daniel Robbins - -__keychain_init_completion() { - COMPREPLY=() - _get_comp_words_by_ref cur prev words cword -} - -# Get ssh key file names. Find all files with .pub suffix and remove the suffix -__keychain_ssh_keys() { - if [ -d ~/.ssh ]; then - keys=() - while IFS= read -r -d '' file; do - key="$(basename -s .pub "$file")" - keys+=( "$key" ) - done < <(\find "${HOME}/.ssh" -type f -name \*.pub -print0) - echo "${keys[*]}" - fi -} - -# Get gpg keys - 8-character short key IDs -__keychain_gpg_keys() { - keys=() - while IFS= read -r row; do - if [[ "$row" =~ ^sec:[a-z]:[[:digit:]]{0,}:[[:alnum:]]{0,}:([[:alnum:]]{0,}): ]]; then - key="$(echo "${BASH_REMATCH[1]}" | cut -b 9-16)" - keys+=( "$key" ) - fi - done < <(\gpg --list-secret-keys --with-colons 2>/dev/null) - echo "${keys[*]}" -} - -# Get hostnames from ~/.ssh/config -__keychain_ssh_config_hosts() { - if [ -f ~/.ssh/config ]; then - # Extract Host entries, excluding wildcards - grep -i "^Host " ~/.ssh/config 2>/dev/null | \ - awk '{print $2}' | \ - grep -v '[*?]' - fi -} - -# Parse command-line options from keychain --help output -__keychain_command_line_options() { - opts=() - # Try to find keychain executable (handle Git Bash/MINGW64 PATH issues) - local keychain_cmd - if command -v keychain >/dev/null 2>&1; then - keychain_cmd="keychain" - elif [ -x ./keychain ]; then - keychain_cmd="./keychain" - elif [ -x ./keychain.sh ]; then - keychain_cmd="./keychain.sh" - else - # Fallback: provide common options if keychain not found - echo "-h --help -V --version -q --quiet -Q --quick" - return 0 - fi - - while IFS= read -r row; do - # Match: " -X --option" format (short first, long second) - if [[ "$row" =~ ^[[:space:]]{4}([-]{1}[[:alpha:]]{1})[[:space:]]{1,}([-]{2}[[:alnum:]-]{1,})[[:space:]]{0,} ]] - then - opt1="${BASH_REMATCH[1]}" - opts+=( "$opt1" ) - opt2="${BASH_REMATCH[2]}" - opts+=( "$opt2" ) - # Match: " --option" format (long only) - elif [[ "$row" =~ ^[[:space:]]{4}([-]{2}[[:alnum:]-]{1,})[[:space:]]{1,} ]] - then - opt="${BASH_REMATCH[1]}" - opts+=( "$opt" ) - fi - done < <("$keychain_cmd" --help 2>/dev/null) - echo "${opts[*]}" -} - -_keychain() { - local cur - - if declare -F _init_completion >/dev/null 2>&1; then - _init_completion -n : || return - else - # Fallback if bash-completion not available - COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - fi - - # Check if --extended is in the command line - local extended_mode=false - for word in "${COMP_WORDS[@]}"; do - [[ "$word" == "--extended" ]] && extended_mode=true && break - done - - # Handle --extended mode completions with prefixes - if [[ "$extended_mode" == true ]]; then - local prefix completions=() - - # Determine which prefix type we're completing - case "$cur" in - sshk:*) - # shellcheck disable=SC2207 - local items=( $(__keychain_ssh_keys) ) - prefix="sshk:" - ;; - gpgk:*) - # shellcheck disable=SC2207 - local items=( $(__keychain_gpg_keys) ) - prefix="gpgk:" - ;; - host:*) - # shellcheck disable=SC2207 - local items=( $(__keychain_ssh_config_hosts) ) - prefix="host:" - ;; - s*|g*|h*) - # Handle partial prefix matches (s->sshk:, g->gpgk:, h->host:) - # Only if no colon present yet - [[ ! "$cur" =~ : ]] || return 0 - [[ "sshk:" == "$cur"* ]] && completions+=( "sshk:" ) - [[ "gpgk:" == "$cur"* ]] && completions+=( "gpgk:" ) - [[ "host:" == "$cur"* ]] && completions+=( "host:" ) - # If we have prefix matches, show them - if [ ${#completions[@]} -gt 0 ]; then - # shellcheck disable=SC2207 - COMPREPLY=( $(compgen -W "${completions[*]}" -- "$cur") ) - compopt -o nospace 2>/dev/null - fi - # Also add matching options - # shellcheck disable=SC2207 - local opts=( $(__keychain_command_line_options) ) - if [ ${#opts[@]} -gt 0 ]; then - # shellcheck disable=SC2207 - COMPREPLY+=( $(compgen -W "${opts[*]}" -- "$cur") ) - fi - return 0 - ;; - -*) - # Show options only - # shellcheck disable=SC2207 - local opts=( $(__keychain_command_line_options) ) - if [ ${#opts[@]} -gt 0 ]; then - # shellcheck disable=SC2207 - COMPREPLY=( $(compgen -W "${opts[*]}" -- "$cur") ) - fi - return 0 - ;; - *) - # No prefix yet, offer all prefixed possibilities - # shellcheck disable=SC2207 - local ssh_keys=( $(__keychain_ssh_keys) ) - # shellcheck disable=SC2207 - local gpg_keys=( $(__keychain_gpg_keys) ) - # shellcheck disable=SC2207 - local hosts=( $(__keychain_ssh_config_hosts) ) - - for key in "${ssh_keys[@]}"; do completions+=( "sshk:$key" ); done - for key in "${gpg_keys[@]}"; do completions+=( "gpgk:$key" ); done - for host in "${hosts[@]}"; do completions+=( "host:$host" ); done - - # shellcheck disable=SC2207 - COMPREPLY=( $(compgen -W "${completions[*]}" -- "$cur") ) - __ltrim_colon_completions "$cur" 2>/dev/null || true - compopt -o nospace 2>/dev/null - return 0 - ;; - esac - - # Build completions for sshk:/gpgk:/host: prefixes - if [[ -n "$prefix" ]]; then - for item in "${items[@]}"; do - completions+=( "${prefix}${item}" ) - done - # shellcheck disable=SC2207 - COMPREPLY=( $(compgen -W "${completions[*]}" -- "$cur") ) - __ltrim_colon_completions "$cur" 2>/dev/null || true - compopt -o nospace 2>/dev/null - return 0 - fi - fi - - # Normal mode (no --extended): complete bare keys and options - # shellcheck disable=SC2207 - COMPREPLY=( $(compgen -W "$(__keychain_command_line_options) $(__keychain_ssh_keys) $(__keychain_gpg_keys)" -- "$cur") ) - return 0 -} - -complete -F _keychain keychain diff --git a/docs/python-rationale.md b/docs/python-rationale.md new file mode 100644 index 0000000..0dc845d --- /dev/null +++ b/docs/python-rationale.md @@ -0,0 +1,117 @@ +# Why Keychain 3 Uses Python + +Keychain 3 is a ground-up rewrite in Python 3. For a project whose identity +for two decades was "one POSIX shell script you drop on any UNIX-like system," +that was not a casual decision. It deserves a direct explanation. + +## Why Move Off The Shell? + +The 2.x series was a single, large POSIX Bourne shell script that had grown +steadily more sophisticated since the project began in 2002. It was portable +in the strictest sense: any sufficiently complete `/bin/sh` could run it. But +maintaining that portability had become a tax on every change. + +Anything more expressive than the POSIX-era intersection of `sh`, `grep`, +`sed`, `awk`, `ps`, and `ls` was off-limits, even when modern GNU, BSD, macOS, +and Linux systems all supported something better. Quoting rules, the behavior +of `read`, the output format of `ps`, the flags accepted by `ls`, and the +handling of paths containing spaces all varied in subtle ways that could +surface as bug reports years after the code that triggered them was written. + +Even with the help of modern tools like ShellCheck, the result was a codebase +that worked but was increasingly hostile to contribute to, hard to audit with +confidence, and effectively impossible to test in a meaningful automated way. +For a tool that handles SSH agent sockets and manages cached credentials in +users' login sessions, "hard to audit" and "hard to test" are not acceptable +long-term states. + +## Why Python, And Why Now? + +By 2026, Python 3 is effectively ubiquitous on the POSIX-shaped systems where +Keychain is deployed: modern Linux distributions, the BSDs, macOS, WSL, and the +homelab and infrastructure-class systems Keychain users tend to manage. + +Compared to Bourne shell stitched together with POSIX command-line utilities, +Python 3 offers a dramatically more consistent, stable, and expressive +multi-platform API. Its standard library covers, with care and good defaults, +the areas where the old shell implementation had accumulated the most +platform-specific workarounds: process spawning and signaling, environment +manipulation, path and filename handling, structured text parsing, and TTY +interaction. + +This rewrite is a deliberate choice by Daniel Robbins, Keychain's original +creator and current maintainer. The motivation is plain: ease ongoing +maintenance, improve auditability, enable real automated testing, and provide a +foundation solid enough to support future feature development without each new +option fighting the language it is written in. + +Python makes it practical to apply software engineering practices that were +awkward or impossible in the shell version: unit and integration tests, static +type checking, linting, security scanning, and a clean separation of concerns +across a small package. + +## Preserving The One-File Deployment Story + +The single best property of the 2.x shell script was operational, not +technical: it was one file. You could copy it onto a system, mark it +executable, drop it in `/usr/local/bin`, and use it. No virtualenv, no `pip`, +no package-manager ceremony, and no entry-point shim. + +Any rewrite that lost that property would have lost something real. + +Keychain 3 preserves it by shipping as a Python zipapp: a single executable +`.pyz` file built from the `keychain` package and runnable on systems with +Python 3.9 or newer. The release artifact can be renamed to `keychain`, marked +executable, and dropped in `PATH` much like the historical shell script. + +There is no `pip install` step for normal use. The zipapp has no third-party +runtime dependencies. The only requirement on the target system is a suitable +Python 3 interpreter. + +For systems where `/usr/bin/env python3` is older than Python 3.9, the zipapp +bootstrap looks for a newer `python3.NN` on `PATH` and re-execs into it before +importing Keychain. This preserves the single-file deployment model while +still allowing Keychain 3 to use a modern Python floor. + +## Why This Fits A Credential Helper + +The combination of "interpreted Python" and "shipped as a zipapp" is unusually +well-aligned with what a credential helper should be. + +**Inspectable on-box, with no extra tools.** A deployed `keychain` zipapp is a +zip file with a shebang. For example: + +```console +unzip -p /usr/local/bin/keychain keychain/main.py +``` + +That shows the source for the program installed on the machine. A native +binary, by contrast, requires a different audit path before you can answer: +"what is this thing actually doing on my server right now?" + +**Auditable in an emergency.** A sysadmin staring at a live incident can unpack +the `.pyz`, inspect the code, patch it locally if necessary, and keep going +without a compiler toolchain or release build environment. That is a real +operational property, and it is closely related to the property that made the +shell script trustworthy for two decades. + +**A smaller trust gap between source and artifact.** A zipapp contains the +Python source that is shipped to users. There is no separate native compiler or +linker artifact between what you read in the repository and what runs on the +machine. For a tool that talks to your SSH agent, that is not just a packaging +detail; it is part of the security posture. + +**Easy to wrangle.** The canonical `.pyz` file makes deployment easy for users +who need Keychain on several systems. Packagers and downstream maintainers can +still rebuild the artifact from source, carry small patches, or package the +Python project using their normal tooling. + +## The Trade-Off + +The trade-off is honest: starting Python costs more than starting `sh`. +Depending on the system, Keychain 3 may pay roughly an extra 80-200 ms of +interpreter startup time. + +For a tool that runs at login, from shell startup, or from cron rather than in +a tight inner loop, that cost is worth what it buys in maintainability, +testability, auditability, and the ability to keep moving the project forward. diff --git a/docs/release-steps.md b/docs/release-steps.md deleted file mode 100644 index f20d391..0000000 --- a/docs/release-steps.md +++ /dev/null @@ -1,141 +0,0 @@ -# Keychain Release Steps - -This document defines the standard release process. Releases use **numeric tags only** (no leading `v`). Example: `2.9.6`. - -## 1. When to Bump -- Patch (X.Y.Z -> X.Y.Z+1): Documentation, branding, hardening w/o behavior change. -- Minor (X.Y -> X.Y+1): User-visible new features, option additions. -- Major (X -> X+1): Backward-incompatible changes, removed options. - -## 2. Pre-Flight Checklist -1. Working tree clean (`git status`). -2. Update `ChangeLog.md`: add new section at top: `## keychain (

)`. -3. Update `VERSION` file to match new version. -4. Ensure only intentional `funtoo.org` references (historical note in docs only). -5. Decide if any last-minute man page edits are required. - -## 3. Build Artifacts -Manual build (optional; `make release` now auto-rebuilds prerequisites): -``` -make clean && make dist/keychain-$(cat VERSION).tar.gz -``` -`make release` or `make release-refresh` will ensure these artifacts exist automatically. - -Artifacts: -- `keychain` (executable wrapper, not committed) -- `keychain.1` (man page) -- `keychain.spec` -- `keychain.txt` -- `dist/keychain-.tar.gz` - -## 4. Local Sanity Tests -``` -./keychain --version -./keychain --help | head -20 -grep -R "github.com/funtoo/keychain" . && echo "(should be zero results)" -``` -Check man page header `.TH` line for correct date/version and updated center URL (GitHub canonical). - -## 5. Tagging -Signed (preferred): -``` -git tag -s $(cat VERSION) -m "$(cat VERSION)" -``` -Unsigned: -``` -git tag $(cat VERSION) -``` -Push: -``` -git push -git push --tags -``` - -## 6. Orchestrated Release Path (Preferred) -Run: -``` -make release # for first publication -``` -You will see: -1. Local build presence check (or build via prerequisites). -2. CI artifact fetch (MANDATORY). Failure to retrieve artifacts aborts; you must wait for the workflow to finish. -3. Normalized comparison phase (LOCAL vs CI build): - * `keychain` – raw sha256 digest compare. - * `keychain.1` – raw hash first; if different, re-compare with the Pod::Man auto-generated first line stripped. A normalized match counts as a match (header differences ignored). - * `dist/keychain-.tar.gz` – unpack both tarballs; compare sorted file list and per-file sha256 (man page internally also normalized on first line). Blob-level tar/gzip metadata differences (mtime, uid, compression variance) are ignored if internal contents match. - Outcome: - - If all artifacts match (raw or normalized) -> Release uses the CI artifact files directly (local artifacts remain untouched for auditing). - - If any real content mismatch exists -> Abort. - - Override (discouraged) to force publish local artifacts despite mismatch: `KEYCHAIN_FORCE_LOCAL=1 make release` - (Use corresponding `... make release-refresh` for refresh mode.) -4. Display of generated release notes (ChangeLog excerpt + provenance table preview). -5. Y/N confirmation prompt. -6. Release creation (or refresh) + asset upload + release notes (re)generation with provenance table via GitHub API. - -## 7. Automated Path (Tag-Driven Workflow) -Pushing a tag matching `X.Y.Z` triggers `.github/workflows/release.yml` which: -- Validates `VERSION` matches tag. -- Builds artifacts inside a Debian container. -- Extracts ChangeLog section into `.release-notes.md`. -- Uploads a private workflow artifact bundle (NOT a published GitHub Release). - -Publication only occurs when you run `make release` (or refresh) locally; CI never auto-publishes. - -## 8. Fast-Fail vs Refresh -Targets: -- `make release` – Orchestrated create (fails if release exists) with digest validation & confirmation. -- `make release-refresh` – Same flow but updates existing release assets AND regenerates release notes (including provenance table). - -Both require `GITHUB_TOKEN` (repo scope) exported in the environment. - -## 9. Refresh Scenario Workflow -If you forgot something (docs only, same version): -``` -# Edit ChangeLog.md (if you need to adjust text; refresh will regenerate release notes from current ChangeLog plus provenance.) -# Rebuild if needed (optional): make dist/keychain-$(cat VERSION).tar.gz -make release-refresh -``` -You will again get CI fetch attempt, comparisons, preview, and prompt. -If functional change needed after publishing: bump version, amend ChangeLog, retag. - -## 10. Rollback -If a bad tag was pushed: -``` -git push origin :refs/tags/ -# Optionally delete the GitHub release in the UI. -# Fix issues, retag and push again. -``` - -## 11. Future Hardening (Planned) -- ShellCheck + POSIX lint gating before release. -- GPG signing of tarball & man page. -- Audit target (`make audit-brand`) to fail on unexpected deprecated domains. -- Security hardening sweep (tracked separately). - -## 12. Changelog Extraction (Reference) -Pseudo-command used by workflow: -``` -version=$(cat VERSION) -awk -v ver="$version" '/^## keychain 'ver' /{f=1;print;next} /^## keychain /&&f && $0 !~ ver {exit} f' ChangeLog.md -``` - -## 13. Verification Matrix -| Item | Location | Must Match | -|------|----------|------------| -| Version tag | git tag | `VERSION` file | -| Wrapper script | `keychain` | contains version string | -| Man page header | `keychain.1` | version/date/center URL | -| Tarball name | `dist/keychain-.tar.gz` | version | - -## 14. Minimal Quick Release Recap -``` -$EDITOR ChangeLog.md VERSION -make clean && make dist/keychain-$(cat VERSION).tar.gz -./keychain --version -git tag -s $(cat VERSION) -m "$(cat VERSION)" -git push && git push --tags -# Create GitHub release, upload assets -``` - ---- -Maintained as of 06 Sep 2025 (CI artifacts canonical: local artifacts are never overwritten; only source path selection differs). diff --git a/keychain.pod b/keychain.pod deleted file mode 100644 index dd2c73f..0000000 --- a/keychain.pod +++ /dev/null @@ -1,747 +0,0 @@ -=head1 NAME - -keychain - Manager for ssh-agent, gpg-agent and private keys. Compatible with POSIX systems. - -=head1 SYNOPSIS - -S -S<--extended --gpg2 --help --ignore-missing --list --noask --nocolor --nogui> -S<--noinherit --nolock --quick --quiet --ssh-allow-forwarded --ssh-allow-gpg> -S<--ssh-rm --ssh-spawn-gpg --systemd --version ] [ --ssh-agent-socket I ]> -S<[ --dir I ] [ --host I ] [ --lockwait I ]> -S<[ --stop I ] [ --timeout I ] [ --wipe I ] [ keys... ]> - -=head1 INTRODUCTION - -B helps you to manage SSH and GPG keys in a convenient and secure -manner. It acts as a frontend to C and C, but allows you -to easily have one long-running C process per system, rather than -the norm of one C per login session. - -This dramatically reduces the number of times you need to enter your -passphrase. With C, you only need to enter a passphrase once every -time your local machine is rebooted. Keychain also makes it easy for remote -cron jobs to securely "hook in" to a long running C process, -allowing your scripts to take advantage of key-based logins. - -Keychain also supports GnuPG 2.1 and later, and will automatically start -gpg-agent if any GPG keys are referenced on the command-line, and will ensure -these credentials are cached in memory and available for use. - -Official project home: L. - -=head1 COMPATIBILITY - -Keychain supports most UNIX-like operating systems and intentionally limits -its scope to modern, widely deployed implementations of OpenSSH and GnuPG. - -=over 4 - -=item * Minimum OpenSSH version: 7.3 - -OpenSSH 7.3 (released 2016-08-01) introduced the C<-G> (config dump) option -used by Keychain to expand C extended keys and the C<--confallhosts> -feature. Because this option is central to modern key discovery, Keychain -officially targets OpenSSH 7.3 or newer. Earlier versions (>=6.5) may appear -to work for basic usage (loading explicitly named keys) but are not part of -the supported test matrix. - -Key types supported under the 7.3+ baseline include RSA, ECDSA and Ed25519 -(Ed25519 first appeared in OpenSSH 6.5). DSA keys have been disabled by -default upstream since OpenSSH 7.0; Keychain will still list or load them -if they are explicitly enabled and present, but they are considered legacy. - -Features leveraged from OpenSSH: - - ssh -V (implementation detection) - ssh -nG host (configuration expansion for host: extended keys) - ssh-agent -s (Bourne-compatible environment output; always forced) - ssh-agent -t (default key lifetime when spawning) - ssh-agent -a path (explicit socket path when requested) - ssh-add -l/-L (list fingerprints / public keys) - ssh-add -d/-D (remove one / remove all keys) - ssh-add -c (confirmation mode) - ssh-add -t (per-key lifetime override) - ssh-keygen -l -f (fingerprint extraction, handles SHA256 & legacy MD5) - Forwarded agent sockets (optionally adopted with --ssh-allow-forwarded) - -=item * GnuPG / gpg-agent - -GnuPG 2.1 or later is required for gpg-agent integration. Keychain uses -C C and C -to detect, adopt or (optionally) spawn gpg-agent when acting as an -SSH agent via C<--ssh-allow-gpg> or C<--ssh-spawn-gpg>. Earlier GnuPG -versions (2.0.x) lacked reliable SSH key storage support and are not -supported. - -=item * Shells - -The Keychain script itself requires a POSIX / Bourne-compatible shell. -It generates pidfiles for Bourne (C<...-sh>), csh/tcsh (C<...-csh>) and -fish (C<...-fish>) shells. Internally Keychain now always consumes the -canonical Bourne output of ssh-agent (forced with C<-s>) and derives -the other shell formats. Bash, ksh and zsh consume the Bourne pidfiles. - -Keychain is officially supported on Git Bash (MSYS2) for Windows, including -proper handling of Windows-style paths and usernames containing spaces. - -The csh/tcsh and fish pidfiles remain supported for backward compatibility. -They are candidates for deprecation: a warning about planned removal will -be announced at least two minor releases before any change. - -=item * Spaces in HOME and paths - -Keychain 2.9.7+ properly handles home directories and private key paths -containing spaces. All path variables in pidfiles (SSH_AUTH_SOCK) are quoted -to ensure correct parsing by shells. The implementation uses a robust approach -that evaluates ssh-agent output in a subshell to extract environment variables -directly, rather than fragile string parsing. This ensures compatibility even -if ssh-agent output format changes. File ownership detection uses POSIX-defined -ls output format with logic to correctly parse usernames containing spaces. - -=item * Legacy / Other SSH implementations - -SunSSH (found on older Oracle Solaris releases) may still function; code -paths for SunSSH detection are retained but are not actively tested. They -are scheduled for review and possible removal after three future minor -releases unless community feedback requests retention. - -The historical commercial ssh.com implementation and its agent are no longer -targets. If detected, behavior may fall back to simpler key loading; advanced -features (confirmation, modern fingerprint parsing, host expansion) are not -guaranteed. Reporting such usage via GitHub Issues will help shape any -future support decisions. - -=item * Systemd user environment - -On systems with systemd --user, Keychain can inject C (and -optionally C) via C<--systemd> for wider session availability. - -=back - -Summary: A supported environment is OpenSSH >= 7.3 plus (optionally) GnuPG ->= 2.1, running under any modern UNIX-like OS with a POSIX shell. Earlier -versions or unlisted SSH implementations may work for basic scenarios but -are outside official scope. - -=head1 LIFECYCLE - -Typically, you configure keychain to run when you first log in to a system. -If you are using Bourne shell or bash, you will create a F<~/.profile> or -F<~/.bash_profile> file and include the following line in it: - - eval "$(keychain --eval id_rsa)" - -Keychain will start ssh-agent if one isn't already running. Keychain then -checks to make sure your private keys (in this example, "id_rsa") are loaded -into the agent. If they are not, you are prompted for any passphrase necessary -to decrypt them, so that they are cached in memory and available for use. - -In addition to printing some user-friendly output to your terminal, keychain -will also output important ssh-agent environment variables, which the S<"$( )"> -(you can also use S<"` `">) captures, and the "eval" evaluates, setting these -variables in your current shell. - -These ssh-agent environment variables are also written to -F<~/.keychain/${HOSTNAME}-sh>, so that subsequent logins and non-interactive -shells such as cron jobs can source the file to access the running ssh-agent -and make passwordless ssh connections using the cached private keys -- -even when you are logged out. These files are collectively called B. - -The key files specified on the command-line will be searched for in the -F<~/.ssh/> directory, and keychain will expect to find the private key file -with the same name, as well as a C<.pub> public key. Keychain will also -see if any GPG keys are specified, and if so, prompt for any passphrases -to cache these keys into C. - -Typically, private SSH key files are specified by filename only, without path, -although it is possible to specify an absolute or relative -path to the private key file as well. Private key files can be symlinks -to the actual key as long as your system has the C command available. -More advanced features are available for specifying keys as well -- see the -B<--extended> and B<--confallhosts> options for more information. - -In addition, for GPG keys specified, similar steps will be taken to ensure -that gpg-agent has the GPG key cached in memory and ready for use. - -=head1 STREAMLINING AND SIMPLIFICATION - -Keychain 2.9.0 has been streamlined, and with this maintenance several -command-line options have been retired as they are not completely necessary. -This simplifies the use of the tool by making it more intuitive to use. -The files created in F<~/.keychain> have also been cleaned up. This section -details all the important changes. - -=head2 PIDFILE CHANGES - -"Pidfile" is the nickname for files created in F<~/.keychain> which can then -be sourced by your scripts to access a running agent. - -When using gpg-agent for GPG keys, keychain will no longer create a -F<~/.keychain/${HOSTNAME}-sh-gpg> pidfile. This file is no longer needed -as the canonical GPG socket inside F<~/.gnupg/> will be used to detect the -running gpg-agent, which is the modern convention. GnuPG 2.1 and later -have stopped using environment variables to find the agent, so we follow -this upstream change. - -=head2 IMPROVED DEBUGGING - -A new B<--debug> option is now available which will print additional information -related to keychain's decisions regarding why and how an agent was found -- or -not. - -=head2 NEW (AND DEPRECATED) OPTIONS - -This section provides an overview of new and deprecated command-line options. -For full details on each option, see the respective option definition under -L. - -=head3 Keychain 2.9.2 - -The C<--confhost> option has been deprecated. Instead of C<--confhost hostname>, -use C<--extended host:hostname>. This extended format allows multiple keys of -multiple types (SSH, GPG and SSH-from-hostname) to be specified on the -command-line together. This is also fully compatible with C<--confallhosts>, -and keychain now de-duplicates the list of keys to be loaded. - -=head3 Keychain 2.9.1 - -The short-lived C<--ssh-wipe> and C<--gpg-wipe> options that appeared only in -version 2.9.0 were replaced with C<--wipe I> to work similarly to -the C<--stop> option, and allows you to specify "ssh", "gpg" or "all". - -=head3 Keychain 2.9.0 - -The C<--agents> option is now deprecated. Keychain will always ensure an -ssh-agent or equivalent is running, or if C<--ssh-spawn-gpg> is used, -potentially a gpg-agent for the purpose of storing SSH keys if no running -ssh-agent is available. To simply use a gpg-agent if one is already running, -falling back to launching ssh-agent if no agent is available, use the -C<--ssh-allow-gpg> option. - -Specifying a GPG key on the command-line will instruct keychain to -enable gpg-agent functionality automatically. This eliminates an option -that you need to specify (C<--agents ssh,gpg>) when invoking keychain, -and for many will avoid spawning a GPG agent you may not be using. - -Specifying the C<--agents> option will now display a warning that it's -deprecated, but keychain will not abort. - -The C<--inherit> option, which took one of four arguments, has been -deprecated. Keychain's default behavior remains that of preferring to -use an ssh-agent or equivalent referenced by its pidfile, falling -back to finding an ssh-agent in its environment. By default, keychain -will not use a gpg-agent socket for SSH keys unless at least C<--ssh-allow-gpg> -is specified. Similarly, the use of any forwarded SSH agent connection -is disabled by default and can be enabled via C<--ssh-allow-forwarded>. -Again, see the full documentation for each option in the L -section. - -You can still influence keychain's behavior via the still-present -C<--noinherit> option which will prevent all detection of existing -SSH agents via the environment. - -The C<--clear> option is still available, but isn't intended to be a -"standalone" option, meaning that it is used to perform an initial clearing -of cached keys before loading any specified keys when Keychain is run. -To perform the sole action of wiping all cached keys, use the C<--wipe> -action. To remove an individual cached SSH private -key or keys, use the C<--ssh-rm> I option. - -The C<--stop> option will now only stop any running ssh-agent processes, -and still supports three possible options: "mine", "others" and "all". -It no longer stops gpg-agent processes, which tend to get auto-respawned -by GPG tools, so killing gpg-agent typically doesn't make a lot of sense. - -=head2 BETTER GNUPG INTEGRATION - -Keychain can now use an existing gpg-agent that has been started in your -environment to store ssh keys, rather than spawning its own ssh-agent, by -using the C<--ssh-allow-gpg> option. - -If you would like keychain to spawn gpg-agent instead of ssh-agent, and -use it to store SSH keys, specify the "--ssh-spawn-gpg" option. - -Without either option, keychain will not use an SSH_AUTH_SOCK that is -provided by gpg-agent, and will spawn an official ssh-agent process. - -In addition, behind the scenes, keychain now uses the gpg-connect-agent -executable to restart the agent, get official PID and socket information, etc. - -Please note that while gpg-agent provides full compatibility with ssh-agent, -its password prompt is handled by pinentry and its store may encrypt your -in-memory keys. For this reason, consider -this new feature experimental, and use GitHub issues to report back any -anomalies or suggested improvements for gpg-agent integration. - -=head2 DISPLAY CHANGES - -When keychain uses gpg-agent for either GnuPG or SSH support, then keychain will display -the GnuPG socket file in its output, rather than the PID. Since the socket -file has the F<~/.gnupg> path in it, this communicates to you that gpg-agent, -not ssh-agent, is active. If you see an integer PID, this means that ssh-agent -is being used. - -=head2 STREAMLINED STARTUP - -By default, keychain will always ensure that an ssh-agent should be started. -It will only start a gpg-agent if a GPG key is referenced on the command-line. - -Modern versions of gpg-agent also support the caching of SSH keys, allowing it -to be a drop-in replacement for ssh-agent. With keychain 2.9.0, a new -"--ssh-spawn-gpg" option has been added, which when specified will give -keychain permission to spawn a gpg-agent in place of ssh-agent. - -=head2 CODE OPTIMIZATION - -With keychain 2.9.0, there has been significant code cleanup, reducing the size -of the script from 1500 lines to about 1100 lines. In addition, the script is -now fully compliant with L, which will be -hugely helpful to ensure continued POSIX shell compatibility moving forward. - -=head1 AGENT DETECTION AND STARTUP ALGORITHM - -This section documents the official algorithm used for detecting and if necessary -starting ssh-agent, to facilitate understanding as well as developer maintenance -of the codebase. - -=head2 DEFINITIONS - -There are several important definition related to the algorithm: - -=over - -=item 1. The B, "ssh-agent", which is a long-running daemon. This can also - in some cases be "gpg-agent", depending on command-line options. - -=item 2. The B, which is the agent or forwarded agent that was not started by keychain, - but is detected in the environment or by other means. - -=item 3. The B, which is an B whose information has - been persisted by being written to the B (see below). - -=item 4. The B C, which points to the socket file - used to communicate with ssh-agent, and optionally C, which indicates - its process ID if running locally (although gpg-agent does not define C, - even if running locally.) - -=item 5. The file F<~/.keychain/${HOSTNAME}-sh> and related files, which are - collectively referred to as "B". - Pidfiles are used to persistently store C and C - environment variables for use by other scripts, as well as by keychain itself, - formatted so that they can be "sourced" by shells of various types. - -=item 6. Relevant B affecting behavior, which include B<--noinherit>, - B<--ssh-allow-gpg>, B<--ssh-spawn-gpg>, and B<--ssh-allow-forwarded>. - -=back - -=head2 ALGORITHM OVERVIEW - -When the keychain script is run, it will first attempt to find a running ssh-agent. - -=over - -=item Phase 1: pidfile: To do this, it will first look for an existing B. If one exists, it will -be inspected and used to find a running agent, in alignment with specified -B. If this process is successful, a "keychain-spawned" agent is -found and this process is complete. Otherwise, we continue to the next step. - -=item Phase 2: environment: If keychain's B did not yield a running agent, keychain looks at -B defined in the current environment. This step will be skipped if the -B<--noinherit> option is specified. If an agent is found -that is in alignment with specified B, it is considered "B" by keychain, -and the process is complete. Otherwise, we continue to the next step. - -=item Phase 3: spawn agent: In the absence of finding a keychain-spawned or existing agent that can be -adopted, keychain will spawn a new ssh-agent, or a new gpg-agent if -B<--ssh-spawn-gpg> is specified and gpg-agent is available. - -=item Final Phase: update pidfile: In addition, the Bs will be updated to reflect the keychain-spawned or -B<"inherited"> agent. An B<"inherited"> agent, once written to the B, is now considered -to be B<"adopted">. - -=item Pidfile update exception: If the B<--ssh-allow-forwarded> option was specified, -and a forwarded SSH socket was found -- which is identified as a valid SSH -socket defined in a C variable, which has no associated or -valid C also defined in the environment and is also determined to not -be the socket of any running gpg-agent -- then this agent will simply be used, B in any -B. This is because this SSH-supplied -socket will disappear when the underlying SSH connection terminates, and thus -it cannot be relied on to be available persistently. - -=back - -=head2 THE QUICK SHORT-CIRCUIT - -When the B<--quick> option is specified, a special algorithm will run prior to the -main agent-detection algorithm listed above. A pidfile, if it exists, will be -evaluated as per Phase 1 of the main algorithm. If a valid running agent -is found, it will be queried for valid keys. If at least one valid key is loaded -into the agent, the quick start is considered successful, and keychain will skip -the regular agent startup algorithm, and will use this found agent. - -=head2 SUMMARY AND RATIONALE - -The keychain ssh-agent detection and startup algorithm is somewhat sophisticated for -a reason. There is an intention behind its behavior. - -The algorithm has been specifically designed to prefer an agent spawned by keychain, -or previously adopted, if that agent is currently available. This is by design, because -other system software could spawn ssh-agent and/or gpg-agent processes, and we want keychain to -not coerced into using these new agents which may suddenly appear in the environment -unexpectedly when new desktop sessions start and in other circumstances. If keychain -is too "suggestible", it will lose track of the agent which currently holds valid keys, -which can result in unnecessary prompting for passphrases, and general confusion. - -=head1 OPTIONS - -=over - -=item B<--absolute> - -This option can be used with the B<--dir> option, if you would like to specify a -non-default directory to store pidfiles (defaults to F<~/.keychain>). When this -option is used, the script does not automatically append F to the -path, allowing you to use any arbitrary directory name for the storing of pidfiles. -Please note that Keychain 2.9.3 adds some extra security checks related to -directory and file permissions -- you must have exclusive ownership of any -directory that keychain uses to store pidfiles, or keychain will abort. - -=item B<--clear> - -When specified, this option adds an initial step prior to adding any keys -to the agents of wiping all existing cached keys/passphrases. -This is intended to be used alongside keychain --eval to ensure that only the specified -keys are loaded, and that keychain should assume that you are an intruder -until proven otherwise and force all interactive logins to specify valid -passphrases. This option increases security and still allows your -cron jobs to use your ssh keys when you're logged out. - -=item B<--confallhosts> - -In addition to any keys specified on the command-line, this option will -tell keychain to scour F<~/.ssh/config> for all private keys referenced -in all C lines, and load all keys for all hosts. - -=item B<--confirm> - -Keys are subject to interactive confirmation by the SSH_ASKPASS -program before being used for authentication. See the -c option for -ssh-add(1). - -=item B<--debug> - -Keychain 2.9.0 introduces the B<--debug> option, which will output -additional information related to how Keychain makes its agent-selection -process. Specifically, it will output when an B is rejected -because it is being supplied by gpg-agent -- and this is not allowed -due to no B<--ssh-allow-gpg> option, or when it is rejected because it -appears to be from a forwarded SSH connection, and B<--ssh-allow-forwarded> -was not supplied. - -=item B<--dir> I - -This option allows you to use another directory besides F<$HOME/.keychain> -for the storing of pidfiles. Please note that Keychain 2.9.3 adds some -extra security checks related to directory and file permissions -- you -must have exclusive ownership of any directory that keychain uses to store -pidfiles, or the script will abort. Also see the B<--absolute> option. - -=item B<--env> I - -After parsing options, keychain will load additional environment -settings from "filename". By default, if "--env" is not given, then -keychain will attempt to load from F<~/.keychain/[hostname]-env> or -alternatively F<~/.keychain/env>. The purpose of this file is to -override settings such as PATH, in case ssh is stored in -a non-standard place. - -=item B<--eval> - -Keychain will print lines to be evaluated in the shell on stdout. It -respects the SHELL environment variable to determine if Bourne shell -or C shell output is expected. - -=item B<--extended> - -This enables extended command-line key processing with more features, -and is a replacement for the old C<--confhost> option. When specified, -each key specified on the command-line must have a prefix to explicitly -categorize it. SSH keys must have a prefix of "sshk:" immediately -followed by the path or key name (the part after the "sshk:" is processed -just like a SSH key is without the C<--extended> option). GPG keys must -be in the format "gpgk:" immediately followed by the 8 or 16-character -fingerprint. If "host:" is specified, then Keychain will -extract the SSH configuration for the specified hostname, grab all -identityfile options (private keys) specified, and these keys will be -included in the set of keys to be loaded by keychain. This allows -multiple keys of multiple types, including SSH-keys-by-host, to be -specified together, which wasn't possible with C<--confhost>. - -=item B<--gpg2> - -This option changes the default gpg calls to use gpg2 instead to support -distributions such as Ubuntu which has both gpg and gpg2 - -=item B<-h --help> - -Show help that looks remarkably like this man-page. As of 2.6.10, -help is sent to stdout so it can be easily piped to a pager. - -=item B<--host> I - -Set alternate hostname for creation of pidfiles - -=item B<--ignore-missing> - -Don't warn if some keys on the command-line can't be found. This is -useful for situations where you have a shared .bash_profile, but your -keys might not be available on every machine where keychain is run. - -=item B<-l --list> - -List signatures of all active SSH keys, and exit, similar to "ssh-add -l". - -=item B<-L --list-fp> - -List fingerprints of all active SSH keys, and exit, similar to "ssh-add -L". - -=item B<--lockwait> I - -How long to wait for the lock to become available. Defaults to 5 -seconds. Specify a value of zero or more. If the lock cannot be -acquired within the specified number of seconds, then this keychain -process will forcefully acquire the lock. - -=item B<--noask> - -This option tells keychain do everything it normally does (ensure -ssh-agent is running, set up the F<~/.keychain/[hostname]-{c}sh> files) -except that it will not prompt you to add any of the keys you -specified if they haven't yet been added to ssh-agent. - -=item B<--nocolor> - -Disable color highlighting for non ANSI-compatible terms. - -=item B<--nogui> - -Don't honor SSH_ASKPASS, if it is set. This will cause ssh-add to -prompt on the terminal instead of using a graphical program. - -=item B<--noinherit> - -Don't inherit any agent processes, overriding the default behavior -of inheriting all non-forwarded ssh-agent and any existing -gpg-agent processes. Also see L. - -=item B<--nolock> - -Don't attempt to use a lockfile while manipulating files, pids and -keys. - -=item B<--query> - -Keychain will print lines in KEY=value format representing the values -which are set by the agents. - -=item B<-Q --quick> - -If an ssh-agent process is running then use it. Don't verify the list -of keys, other than making sure it's non-empty. This option avoids -locking when possible so that multiple terminals can be opened -simultaneously without waiting on each other. See the -L section for more information regarding -how this fits into the overall startup algorithm. - -=item B<-q --quiet> - -Only print messages in case of warning, error or required interactivity. As of -version 2.6.10, this also suppresses "Identities added" messages for ssh-agent. - -=item B<-k --stop> I - -Kill currently running ssh-agent processes and exit. - -Note that previous versions of keychain (2.8.5 and earlier) allowed -killing of gpg-agent as well. This functionality was removed as -ssh-agent and gpg-agent have a bit different design philosophies -and you almost always only have at most one gpg-agent running at -a time. Use "killall gpg-agent" if you really want to kill gpg-agent. -However, since this option also removes pidfiles, it will remove -any gpg-agent processes adopted by keychain that were being used to -store ssh keys. - -The following values are valid for "which" which controls which -ssh-agents to target: - -=over 9 - -=item all - -Kill all ssh-agent processes and quit keychain immediately. Prior to -keychain-2.5.0, this was the behavior of the bare "--stop" option. - -=item others - -Kill agent processes other than the ones keychain is providing. Prior -to keychain-2.5.0, keychain would do this automatically. The new -behavior requires that you specify it explicitly if you want it. - -=item mine - -Kill keychain's agent processes, leaving other agents alone. - -=back - -=item B<--ssh-agent-socket> I - -Use this option to specify the path to the socket file that you would -like ssh-agent to create and use as its official socket. By default, -ssh-agent will create its own socket file, typically in /tmp. - -=item B<--ssh-allow-forwarded> - -By default, keychain will not use a forwarded ssh-agent connection, -which is a ssh-agent socket created by SSH that has no associated -local process. To permit keychain to use a forwarded ssh-agent -connection, specify this option. If a SSH-forwarded socket is used, -it will not be persisted in the pidfiles, as it is not likely to -be available outside of the currently-active SSH session. - -=item B<--ssh-allow-gpg> - -Would you like to have keychain use an already-running gpg-agent to -store your SSH keys, rather than spawning a new ssh-agent? This option -does just that. When this option is specified, keychain will -accept an SSH_AUTH_SOCK environment variable in its environment, even -if it was created by gpg-agent. Modern versions of gpg-agent are also -able to store SSH keys. By default, keychain has a special -check to avoid using a gpg-agent that has set the SSH_AUTH_SOCK -environment variable, and will instead spawn its own ssh-agent. With -this option enabled, this restriction is turned off. -Please note that this option does not actually instruct keychain to I a -gpg-agent for storing SSH keys if no agent is available -- if you want that, -see the B<--ssh-spawn-gpg> option, below. - -ALSO NOTE: When a gpg-agent is adopted for ssh-agent duties in this way, the -F<~/.keychain/${HOSTNAME}-sh> pidfile will be updated to reference the -gpg-agent socket, so it will be seamlessly used by future cron jobs needing -an ssh-agent, as well as by future invocations of keychain, as long as the -B<--ssh-allow-gpg> or B<--ssh-spawn-gpg> (which implies B<--ssh-allow-gpg>) -are specified. - -=item B<--ssh-spawn-gpg> - -This is the option to use if you're really on-board with using gpg-agent -as a replacement for ssh-agent. Not only will keychain use a running -gpg-agent if found as per the B<--ssh-allow-gpg>, but if it needs to spawn -a new ssh-agent, it will go ahead and spawn a gpg-agent in its place, -and use it instead. Also see notes for the B<--ssh-allow-gpg> option, -as this option also implies B<--ssh-allow-gpg>. - -=item B<--ssh-rm -r> I - -Only perform the single action of removing the specified cached keys from the -running ssh-agent, and then exit. - -=item B<--systemd> - -Inject environment variables into the systemd --user session. - -=item B<--timeout> I - -Allows a timeout to be set for identities added to ssh-agent. When this option -is used with a keychain invocation that starts ssh-agent itself, then keychain -uses the appropriate ssh-agent option to set the default timeout for ssh-agent. -The --timeout option also gets passed to ssh-add invocations, so any keys added -to a running ssh-agent will be individually configured to have the timeout -specified, overriding any ssh-agent default. - -Most users can simply use the timeout setting they desire and get the result -they want -- with all identities having the specified timeout, whether added by -keychain or not. More advanced users can use one invocation of keychain to set -the default timeout, and optionally set different timeouts for keys added by -using a subsequent invocation of keychain. - -=item B<-V --version> - -Show version information. - -=item B<--wipe> I - -Only perform the single action of wiping all agent's cached keys. Specify -'ssh', 'gpg' or 'all' for SSH keys, GPG keys and all agents respectively. -Also see the C<--ssh-rm> action and the C<--clear> option. - -=back - -=head1 EXAMPLES - -This snippet should work in most shells to load two ssh keys and one gpg -key: - - eval `keychain --eval id_rsa id_dsa 0123ABCD` - -For the fish shell, use the following format: - - if status --is-interactive - keychain --eval --quiet -Q id_rsa | source - end - -If you have trouble with that in csh: - - setenv SHELL /bin/csh - eval `keychain --eval id_rsa id_dsa 0123ABCD` - -This is equivalent for Bourne shells (including bash and zsh) but -doesn't use keychain's --eval feature: - - keychain id_rsa id_dsa 0123ABCD - [ -z "$HOSTNAME" ] && HOSTNAME=`uname -n` - [ -f $HOME/.keychain/$HOSTNAME-sh ] && \ - . $HOME/.keychain/$HOSTNAME-sh - -This is equivalent for C shell (including tcsh): - - keychain id_rsa id_dsa 0123ABCD - host=`uname -n` - if (-f $HOME/.keychain/$host-csh) then - source $HOME/.keychain/$host-csh - endif - -Likewise, the following commands can be used in fish: - - keychain id_rsa id_dsa 0123ABCD - test -z "$hostname"; and set hostname (uname -n) - if test -f "$HOME/.keychain/$hostname-fish" - source $HOME/.keychain/$hostname-fish - end - -To load keychain variables from a script (for example from cron) and -abort unless id_dsa is available: - - # Load keychain variables and check for id_dsa - [ -z "$HOSTNAME" ] && HOSTNAME=`uname -n` - . $HOME/.keychain/$HOSTNAME-sh 2>/dev/null - ssh-add -l 2>/dev/null | grep -q id_dsa || exit 1 - -=head1 SEE ALSO - -L, L, L, L - -=head1 NOTES - -Keychain was created and is currently maintained by Daniel Robbins. To report a -bug or request an enhancement, use the issue tracker at -L. - -Keychain includes bash completion support for command-line options, SSH keys, -GPG keys, and extended mode prefixes (C, C, C). The -completion script is included in the source tarball at -C. For installation instructions, see the README -file or run C. - -The former Funtoo Linux wiki page is preserved only as an historical reference: -L. diff --git a/keychain.sh b/keychain.sh deleted file mode 100755 index 0e91859..0000000 --- a/keychain.sh +++ /dev/null @@ -1,1123 +0,0 @@ -#!/bin/sh - -versinfo() { - qprint - qprint " Copyright ${CYANN}2002-##CUR_YEAR##${OFF} Daniel Robbins, BreezyOps" - qprint " lockfile() Copyright ${CYANN}2009${OFF} Parallels, Inc." - qprint " Copyright ${CYANN}2007${OFF} Aron Griffis" - qprint " Copyright ${CYANN}2002-2006${OFF} Gentoo Foundation" - qprint - qprint " Keychain is free software: you can redistribute it and/or modify" - qprint " it under the terms of the ${CYANN}GNU General Public License version 2${OFF} as" - qprint " published by the Free Software Foundation." - qprint -} - -umask 0077 -NEWLINE=" -" -version=##VERSION## -PATH="${PATH}${PATH:+:}/usr/bin:/bin:/sbin:/usr/sbin:/usr/ucb" -unset pidfile_out -unset myaction -havelock=false -unset hostopt -extended=false -confallhosts=false -ignoreopt=false -noaskopt=false -noguiopt=false -nolockopt=false -lockwait=5 -openssh=unknown -sunssh=unknown -quickopt=false -quietopt=false -clearopt=false -allow_inherited=true -color=true -unset stopwhich -unset timeout -unset ssh_agent_socket -unset ssh_timeout -unset sshavail -unset sshkeys -unset gpgkeys -unset cmdline_keys -keydir="${HOME}/.keychain" -unset envf -evalopt=false -confirmopt=false -absoluteopt=false -systemdopt=false -unset ssh_confirm -unset GREP_OPTIONS -gpg_prog_name="gpg" -gpg_started=false -ssh_allow_forwarded=false -ssh_allow_gpg=false -ssh_spawn_gpg=false -debugopt=false -CYAN="" -CYANN="" -GREEN="" -RED="" -PURP="" -YEL="" -OFF="" - -# GNU awk and sed have regex issues in a multibyte environment. If any locale -# variables are set, then override by setting LC_ALL -unset pinentry_locale -if [ -n "$LANG$LC_ALL" ] || locale 2>/dev/null | grep -E -qv '="?(|POSIX|C)"?$' 2>/dev/null; then - # save LC_ALL so that pinentry-curses works right. This has always worked - # correctly for me but peper and kloeri had problems with it. - pinentry_lc_all="$LC_ALL" - LC_ALL=C - export LC_ALL -fi - -qprint() { - # shellcheck disable=SC2048,SC2086 - $quietopt || echo "$@" >&2; return 0 -} - -mesg() { # general information; suppressed with --quiet - qprint " ${GREEN}*${OFF} $*" -} - -warn() { # important warning; not suppressed with --quiet - # shellcheck disable=SC2048,SC2086 - echo " ${RED}* Warning${OFF}: "$* >&2 -} - -note() { # important notice; suppressed with --quiet - # shellcheck disable=SC2048,SC2086 - qprint " ${YEL}* Note${OFF}: "$* >&2 -} - -debug() { - # shellcheck disable=SC2048,SC2086 - $debugopt && echo " ${CYAN}debug>" $*"${OFF}" >&2; return 0 -} - -error() { - # shellcheck disable=SC2048,SC2086 - echo " ${RED}* Error${OFF}:" $* >&2 -} - -die() { - [ -n "$1" ] && error "$*" - qprint - $evalopt && { echo; echo "false;"; } - exit 1 -} - -helpinfo() { - cat >&1 < owner="drobbins", group="drobbins", size=4096 -# - "Mathew Binkley 197609 4096" -> owner="Mathew Binkley", group="197609", size=4096 -# -# We distinguish by checking if the field after potential owner+space is numeric: -# If field 5 is NOT numeric, then field 4 is part of the owner name (space in username). -# If field 5 IS numeric, then field 4 is the group name (no space in username). -get_owner() { - go_path="$1" - # shellcheck disable=SC2012 # Using ls -ld for POSIX-defined formatted output; not parsing ls in a loop - ls -ld "$go_path" 2>/dev/null | awk '{ - result = $3 - if (NF >= 5 && $5 !~ /^[0-9]+$/) { - result = result " " $4 - } - print result - }' -} - -# synopsis: testssh -# Figure out which ssh is in use, set the global boolean $openssh and $sunssh -testssh() { - # Query local host for SSH application, presently supporting OpenSSH and Sun SSH: - openssh=false - sunssh=false - - case "$(ssh -V 2>&1)" in - *OpenSSH*) openssh=true ;; - *Sun?SSH*) sunssh=true ;; - esac - # See if gpg-agent is available and provides ssh-agent functionality: - if $ssh_spawn_gpg; then - if ! out="$(gpg-agent --help | grep enable-ssh-support)" || [ -z "$out" ]; then - warn "gpg-agent ssh functionality not available; not using..." - ssh_spawn_gpg=false - fi - fi -} - -# synopsis: verifykeydir -# Make sure the key dir is set up correctly. Exits on error. -verifykeydir() { - # Create keydir if it doesn't exist already - if [ -f "${keydir}" ]; then - die "${keydir} is a file (it should be a directory)" - # Solaris 9 doesn't have -e; using -d.... - elif [ ! -d "${keydir}" ]; then - mkdir "${keydir}" || die "can't create ${keydir}" - fi - dir_owner="$(get_owner "${keydir}")" - [ "$dir_owner" != "$me" ] && warn "${keydir} is owned by ${dir_owner}, not ${me}. Please fix." - # shellcheck disable=SC2012 # POSIX defines the first 9 chars of ls -l: - [ "$(ls -ld "${keydir}" | cut -c5-10)" != "------" ] && warn "Keychain dir has lax permissions. Use ${CYAN}chmod -R go-rwx '${keydir}'${OFF} to fix." - if ! :> "$pidf.foo"; then - die "can't write inside $pidf" - else - rm -f "$pidf.foo" - fi -} - -lockfile() { - # This function originates from Parallels Inc.'s OpenVZ vpsreboot script. - - # Description: This function attempts to acquire the lock. If it succeeds, - # it returns 0. If it fails, it returns 1. This function retuns immediately - # and only tries to acquire the lock once. - - tmpfile="$lockf.$$" - - echo $$ >"$tmpfile" 2>/dev/null || exit - if ln "$tmpfile" "$lockf" 2>/dev/null; then - rm -f "$tmpfile" - havelock=true && return 0 - fi - if kill -0 "$(cat "$lockf" 2>/dev/null)" 2>/dev/null; then - rm -f "$tmpfile" - return 1 - fi - if ln "$tmpfile" "$lockf" 2>/dev/null; then - rm -f "$tmpfile" - havelock=true && return 0 - fi - rm -f "$tmpfile" "$lockf" && return 1 -} - -takelock() { - # Description: This function calls lockfile() multiple times if necessary - # to try to acquire the lock. It returns 0 on success and 1 on failure. - # Change in behavior: if timeout expires, we will forcefully acquire lock. - - [ "$havelock" = "true" ] && return 0 - [ "$nolockopt" = "true" ] && return 0 - - # First attempt: - lockfile && return 0 - - counter=0 - mesg "Waiting $lockwait seconds for lock..." - while [ "$counter" -lt "$(( lockwait * 10 ))" ] - do - lockfile && return 0 - sleep 0.1; counter=$(( counter + 1 )) - done - rm -f "$lockf" && lockfile && return 0 - return 1 -} - -# synopsis: droplock -# Drops the lock if we're holding it. -droplock() { - $havelock && [ -n "$lockf" ] && rm -f "$lockf" -} - -# synopsis: findpids [prog] -# Returns a space-separated list of agent pids. -# prog can be ssh or gpg, defaults to ssh. Note that if another prog is ever -# added, need to pay attention to the length for Solaris compatibility. -findpids() { - fp_prog=${1-ssh} - unset fp_psout - - # Different systems require different invocations of ps. Try to generalize - # the best we can. The only requirement is that the agent command name - # appears in the line, and the PID is the first item on the line. - if [ -z "$OSTYPE" ]; then - OSTYPE=$(uname) || die 'uname failed' - fi - - # Try systems where we know what to do first - case "$OSTYPE" in - AIX|*bsd*|*BSD*|CYGWIN|darwin*|Linux|linux-gnu|OSF1) - fp_psout=$(ps x 2>/dev/null) ;; # BSD syntax - HP-UX) - fp_psout=$(ps -u "$me" 2>/dev/null) ;; # SysV syntax - SunOS) - case $(uname -r) in - [56]*) - fp_psout=$(ps -u "$me" 2>/dev/null) ;; # SysV syntax - *) - fp_psout=$(ps x 2>/dev/null) ;; # BSD syntax - esac ;; - GNU|gnu) - fp_psout=$(ps -g 2>/dev/null) ;; # GNU Hurd syntax - esac - - # If we didn't get a match above, try a list of possibilities... - # The first one will probably fail on systems supporting only BSD syntax. - if [ -z "$fp_psout" ]; then - # shellcheck disable=SC2009 - fp_psout=$(UNIX95=1 ps -u "$me" -o pid,comm 2>/dev/null | grep '^ *[0-9]+') - [ -z "$fp_psout" ] && fp_psout=$(ps x 2>/dev/null) - [ -z "$fp_psout" ] && fp_psout=$(ps w 2>/dev/null) # Busybox syntax - fi - - # Return the list of pids; ignore case for Cygwin. - # Check only 8 characters since Solaris truncates at that length. - # Ignore defunct ssh-agents (bug 28599) - if [ -n "$fp_psout" ]; then - echo "$fp_psout" | \ - awk "BEGIN{IGNORECASE=1} /defunct/{next} - /$fp_prog-[a]gen/{print \$1}" | xargs - return 0 - fi - - # If none worked, we're stuck - error "Unable to use \"ps\" to scan for $fp_prog-agent processes" - error "Please report to https://github.com/danielrobbins/keychain/issues." - return 1 -} - -stop_ssh_agents() { - mesg "Stopping ssh-agent(s)..." - takelock || die - [ "$stopwhich" != all ] && eval "$(catpidf_shell sh)" # get SSH_AGENT_PID if defined - ssh_pids=$(findpids ssh) || die - if [ -z "$ssh_pids" ]; then - mesg "No ssh-agent(s) found running" - elif [ "$stopwhich" = all ]; then - # shellcheck disable=SC2086 - kill $ssh_pids >/dev/null 2>&1 - mesg "All ${CYANN}$me${OFF}'s ssh-agents stopped: ${CYANN}$ssh_pids${OFF}" - elif [ -n "$SSH_AGENT_PID" ]; then - if [ "$stopwhich" = mine ]; then - kill "$SSH_AGENT_PID" >/dev/null 2>&1 - mesg "Keychain ssh-agents stopped: ${CYANN}$SSH_AGENT_PID${OFF}" - else # others - for ssh_pid in $ssh_pids; do - [ "$ssh_pid" = "$SSH_AGENT_PID" ] && continue - kill "$ssh_pid" >/dev/null 2>&1 - killed_pids="$killed_pids $ssh_pid" - done - mesg "Other ${CYANN}$me${OFF}'s ssh-agents stopped:${CYANN}$killed_pids${OFF}" - fi - else - mesg "No keychain ssh-agent found running" - fi - - # remove pid files if keychain-controlled - if [ "$stopwhich" != others ]; then - rm -f "${pidf}" "${cshpidf}" "${fishpidf}" 2>/dev/null - fi - qprint && exit 0 -} - -# synopsis: catpidf_shell shell -# cat the pid file for the specified shell. -catpidf_shell() { - case "$1" in - */fish|fish) cp_pidf="$fishpidf" ;; - *csh) cp_pidf="$cshpidf" ;; - *) cp_pidf="$pidf" ;; - esac - if [ ! -f "$cp_pidf" ]; then - debug "pidfile doesn't exist"; return 1 - else - cat "${cp_pidf}"; echo; return 0 - fi -} - -startagent_gpg() { - if $gpg_started; then - return 0 - else - gpg_started=true - fi - if gpg_agent_sock="$( echo "GETINFO socket_name" | gpg-connect-agent --no-autostart | head -n1 | sed -n 's/^D //;1p' )" && [ -S "$gpg_agent_sock" ]; then - mesg "Using existing gpg-agent: ${CYANN}$gpg_agent_sock${OFF}" - pidfile_out="SSH_AUTH_SOCK=$gpg_agent_sock; export SSH_AUTH_SOCK" # make sure we adopt it - else - gpg_opts="--daemon" - [ -n "${timeout}" ] && gpg_opts="$gpg_opts --default-cache-ttl $(( timeout * 60 )) --max-cache-ttl $(( timeout * 60 ))" - $ssh_spawn_gpg && gpg_opts="$gpg_opts --enable-ssh-support" - mesg "Starting gpg-agent..." - # shellcheck disable=SC2086 # this is intentional - pidfile_out="$(gpg-agent --sh $gpg_opts)" - return $? - fi -} - -ssh_envcheck() { - # Initial short-circuits for known abort cases: - [ -z "$SSH_AUTH_SOCK" ] && return 1 - if [ ! -S "$SSH_AUTH_SOCK" ]; then - debug "SSH_AUTH_SOCK in $1 is invalid; ignoring it" - unset SSH_AUTH_SOCK && return 1 - fi - - # Throw away the PID with a devug warning if it's invalid: - - if [ -n "$SSH_AGENT_PID" ] && ! kill -0 "$SSH_AGENT_PID" >/dev/null 2>&1; then - unset SSH_AGENT_PID && debug "SSH_AGENT_PID in $1 is invalid; ignoring it" - fi - - # Now, find potential agents: - - if [ -z "$SSH_AGENT_PID" ]; then - - # There are some cases where we can accept a socket without an associated SSH_AGENT_PID: - - if gpg_socket="$(echo "GETINFO ssh_socket_name" | gpg-connect-agent --no-autostart 2>/dev/null | head -n1 | sed -n 's/^D //;1p' )"; then - if [ "$gpg_socket" = "$SSH_AUTH_SOCK" ]; then - if $ssh_allow_gpg; then - $quickopt || mesg "Using ssh-agent ($1): ${CYANN}$gpg_socket${OFF} (GnuPG)" - return 0 - else - unset SSH_AUTH_SOCK && debug "Ignoring SSH_AUTH_SOCK -- this is the GnuPG-supplied socket" && return 1 - fi - fi - fi - - if $ssh_allow_forwarded; then - SSH_AGENT_PID="forwarded" - $quickopt || mesg "Using ${GREEN}forwarded${OFF} ssh-agent: ${GREEN}$SSH_AUTH_SOCK${OFF}" - return 0 - else - unset SSH_AUTH_SOCK && debug "Ignoring SSH_AUTH_SOCK -- this is a forwarded socket" && return 1 - fi - else - # We have valid SSH_AGENT_PID, so we accept the socket too: - $quickopt || mesg "Existing ssh-agent ($1): ${CYANN}$SSH_AGENT_PID${OFF}" - return 0 - fi -} - -# synopsis: startagent_ssh -# This function specifically handles (potential) starting of ssh-agent. Unlike the -# classic startagent function, it does not handle writing out contents of pidfiles, -# which will be done in a combined way after startagent_gpg() is called as well. - -startagent_ssh() { - if $quickopt; then - if ( unset SSH_AGENT_PID SSH_AUTH_SOCK && eval "$(catpidf_shell sh)" && ssh_envcheck quick && ssh_l > /dev/null ); then - mesg "Found existing populated ssh-agent (quick)" - return 0 - else - if ( eval "$(catpidf_shell sh)" && ssh_envcheck quick ); then - note "Quick start unsuccessful -- no keys loaded..." - else - note "Quick start unsuccessful -- no agent found..." - fi - quickopt=false - fi - fi - takelock || die - # See if our pidfile is valid without wiping env: - if ( unset SSH_AGENT_PID SSH_AUTH_SOCK && eval "$(catpidf_shell sh)" && ssh_envcheck pidfile ); then - # Our pidfile is valid! :) We can simply use it: - debug "pidfile is valid" && unset SSH_AGENT_PID SSH_AUTH_SOCK && eval "$(catpidf_shell sh)" - elif $allow_inherited && ssh_envcheck env; then - # If our env is OK, then let's grab it for our pidfile, as long as we don't have a forwarded ssh connection: - if [ "$SSH_AGENT_PID" != forwarded ]; then - pidfile_out="SSH_AUTH_SOCK=$SSH_AUTH_SOCK; export SSH_AUTH_SOCK" - if [ -n "$SSH_AGENT_PID" ]; then - pidfile_out="$pidfile_out -SSH_AGENT_PID=$SSH_AGENT_PID; export SSH_AGENT_PID;" - fi - fi - else # spawn, we must... - rm -f "${pidf}" "${cshpidf}" "${fishpidf}" 2>/dev/null # pidfile is either non-existant or invalid - if $ssh_spawn_gpg; then - startagent_gpg ssh # this function will set pidfile_out itself - return $? - else - mesg "Starting ssh-agent..." - # shellcheck disable=SC2086 # We purposely don't want to double-quote the args to ssh-agent so they disappear if not used: - pidfile_out="$(ssh-agent -s ${ssh_timeout} ${ssh_agent_socket})" - return $? - fi - fi -} - -write_pidfile() { - if [ -n "$pidfile_out" ]; then - pidfile_out=$(echo "$pidfile_out" | grep -v 'Agent pid') - case $pidfile_out in setenv\ *) error "unexpected csh-style ssh-agent output (expected -s)"; exit 1;; esac - rm -f "$pidf" "$cshpidf" "$fishpidf" # Remove first, so we can recreate with our umask - - # Robust approach: eval the output in a subshell to extract actual variable values - # This avoids fragile string parsing and handles any ssh-agent output format changes - wp_auth_sock=$(eval "$pidfile_out" >/dev/null 2>&1; echo "$SSH_AUTH_SOCK") - wp_agent_pid=$(eval "$pidfile_out" >/dev/null 2>&1; echo "$SSH_AGENT_PID") - - # Write sh format - quote SSH_AUTH_SOCK to handle spaces, SSH_AGENT_PID is numeric - { - [ -n "$wp_auth_sock" ] && echo "SSH_AUTH_SOCK=\"${wp_auth_sock}\"; export SSH_AUTH_SOCK" - [ -n "$wp_agent_pid" ] && echo "SSH_AGENT_PID=${wp_agent_pid}; export SSH_AGENT_PID;" - } >"$pidf" - - # Write csh format - { - [ -n "$wp_auth_sock" ] && echo "setenv SSH_AUTH_SOCK \"${wp_auth_sock}\";" - [ -n "$wp_agent_pid" ] && echo "setenv SSH_AGENT_PID ${wp_agent_pid};" - } >"$cshpidf" - - # Write fish format - { - [ -n "$wp_auth_sock" ] && echo "set -e SSH_AUTH_SOCK; set -x -U SSH_AUTH_SOCK \"${wp_auth_sock}\";" - [ -n "$wp_agent_pid" ] && echo "set -e SSH_AGENT_PID; set -x -U SSH_AGENT_PID ${wp_agent_pid};" - } >"$fishpidf" - else - debug skipping creation of pidfiles! - fi -} - -# synopsis: extract_fingerprints -# Extract the fingerprints from standard input, returns space-separated list. -# Utility routine for ssh_l and ssh_f -extract_fingerprints() { - while read -r ef_line; do - case "$ef_line" in - *\ *\ [0-9a-fA-F][0-9a-fA-F]:[0-9a-fA-F][0-9a-fA-F]:*) - # Sun SSH spits out different things depending on the type of - # key. For example: - # md5 1024 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00 /home/barney/.ssh/id_dsa(DSA) - # 2048 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00 /home/barney/.ssh/id_rsa.pub - echo "$ef_line" | cut -f3 -d' ' - ;; - *\ [0-9a-fA-F][0-9a-fA-F]:[0-9a-fA-F][0-9a-fA-F]:*) - # The more consistent OpenSSH format, we hope - # 1024 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00 /home/barney/.ssh/id_dsa (DSA) - echo "$ef_line" | cut -f2 -d' ' - ;; - *\ [A-Z0-9][A-Z0-9]*:[A-Za-z0-9+/][A-Za-z0-9+/]*) - # The new OpenSSH 6.8+ format, - # 1024 SHA256:mVPwvezndPv/ARoIadVY98vAC0g+P/5633yTC4d/wXE /home/barney/.ssh/id_dsa (DSA) - echo "$ef_line" | cut -f2 -d' ' - ;; - *) - # Fall back to filename. Note that commercial ssh is handled - # explicitly in ssh_l and ssh_f, so hopefully this rule will - # never fire. - warn "Can't determine fingerprint from the following line, falling back to filename" - mesg "$ef_line" - basename "$ef_line" | sed 's/[ (].*//' - ;; - esac - done | xargs -} - -# synopsis: ssh_l -# Return space-separated list of known fingerprints -ssh_l() { - sl_mylist=$(ssh-add -l 2>/dev/null) - sl_retval=$? - - if $openssh; then - # Error codes: - # 0 success - # 1 OpenSSH_3.8.1p1 on Linux: no identities (not an error) - # OpenSSH_3.0.2p1 on HP-UX: can't connect to auth agent - # 2 can't connect to auth agent - case $sl_retval in - 0) - echo "$sl_mylist" | extract_fingerprints - ;; - 1) - case "$sl_mylist" in - *"open a connection"*) sl_retval=2 ;; - esac - ;; - esac - return $sl_retval - - elif $sunssh; then - # Error codes (from http://docs.sun.com/db/doc/817-3936/6mjgdbvio?a=view) - # 0 success (even when there are no keys) - # 1 error - case $sl_retval in - 0) - echo "$sl_mylist" | extract_fingerprints - ;; - 1) - case "$sl_mylist" in - *"open a connection"*) sl_retval=2 ;; - esac - ;; - esac - return $sl_retval - else - # Error codes: - # 0 success - however might say "The authorization agent has no keys." - # 1 can't connect to auth agent - # 2 bad passphrase - # 3 bad identity file - # 4 the agent does not have the requested identity - # 5 unspecified error - if [ $sl_retval = 0 ]; then - # Output of ssh-add -l: - # The authorization agent has one key: - # id_dsa_2048_a: 2048-bit dsa, agriffis@alpha.zk3.dec.com, Fri Jul 25 2003 10:53:49 -0400 - # Since we don't have a fingerprint, just get the filenames *shrug* - echo "$sl_mylist" | sed '2,$s/:.*//' | xargs - fi - return $sl_retval - fi -} - -# synopsis: ssh_f filename -# Return fingerprint for a keyfile -# Requires $openssh or $sunssh -ssh_f() { - sf_filename="$1" - - if $openssh || $sunssh; then - realpath_bin="$(command -v realpath)" - # if private key is symlink and symlink to *.pub is missing: - if [ -L "$sf_filename" ] && [ -n "$realpath_bin" ]; then - sf_filename="$($realpath_bin "$sf_filename")" - fi - lsf_filename="$sf_filename.pub" - if [ ! -f "$lsf_filename" ]; then - # try to remove extension from private key, *then* add .pub, and see if we now find it: - if [ -L "$sf_filename" ] && [ -n "$realpath_bin" ]; then - sf_filename="$($realpath_bin "$sf_filename")" - fi - lsf_filename=$(echo "$sf_filename" | sed 's/\.[^\.]*$//').pub - if [ ! -f "$lsf_filename" ]; then - note "Cannot find separate public key for $1." - lsf_filename="$sf_filename" - fi - fi - sf_fing=$(ssh-keygen -l -f "$lsf_filename") || return 1 - echo "$sf_fing" | extract_fingerprints - else - # can't get fingerprint for ssh2 so use filename *shrug* - basename "$sf_filename" - fi - return 0 -} - -# synopsis: gpg_listmissing -# Accepts piped input from stdin. Returns a newline-separated list of keys found to be missing. -gpg_listmissing() { - unset glm_missing - GPG_TTY=$(tty) - - while IFS= read -r glm_k; do - [ -z "$glm_k" ] && continue - # Check if this key is known to the agent. Don't know another way... - if env -i GPG_TTY="$GPG_TTY" PATH="$PATH" GPG_AGENT_INFO="$GPG_AGENT_INFO" "${gpg_prog_name}" --no-autostart --no-options --use-agent --no-tty --sign --local-user "$glm_k" -o- >/dev/null 2>&1 " if they exist or "miss:" otherwise. -all_host_identities() { - if [ ! -e ~/.ssh/config ]; then - warn "No ~/.ssh/config -- can't extract host identities" && return - fi - while IFS= read -r line; do - case $line in - *[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee]*) - keyf="$(echo "$line" | awk '{print $2}')" - if [ -f "$keyf" ]; then - echo "sshk:${keyf}" - else - echo "miss:${keyf}" - fi - esac - done < ~/.ssh/config -} - -# Synopsis: this is the default logic for categorizing command-line keys. If a file is -# specified and is found in ~/.ssh, or just exists, it's a SSH key. If gpg recognizes it, -# then it's a GPG key. Otherwise, it's a missing key. -cmdline_keys_to_extkey() { - while read -r pm_k; do - [ -z "$pm_k" ] && continue - if [ -f "$pm_k" ]; then - echo "sshk:$pm_k" - elif [ -f "$HOME/.ssh/$pm_k" ]; then - echo "sshk:$HOME/.ssh/$pm_k" - elif "${gpg_prog_name}" --list-secret-keys "$pm_k" >/dev/null 2>&1; then - echo "gpgk:$pm_k" - else - echo "miss:$pm_k" - fi - done -} - -# Synopsis: sees if specified stdin $keyf exists; converts to "sshk:" or "miss:" lines -keyf_expand() { - while read -r keyf; do - if [ -f "$keyf" ]; then - echo "sshk:$keyf" - else - echo "miss:$keyf" - fi - done -} - -# Synopsis: We allow sshk:id_rsa from the command-line, with no path, but this needs -# to be expanded to the actual filename internally -- or "miss:". Logic is a bit different -# so we can't use cmdline_keys_to_extkey() code. -sshk_fixup() { - while read -r extkey; do - key_pref="$(echo "$extkey" | cut -b1-5)" - if [ "$key_pref" != "sshk:" ]; then - echo "$extkey" - else - pm_k="$(echo "$extkey" | cut -b6-)" - if [ -f "$pm_k" ]; then - echo "sshk:$pm_k" - elif [ -f "$HOME/.ssh/$pm_k" ]; then - echo "sshk:$HOME/.ssh/$pm_k" - else - echo "miss:$pm_k" - fi - fi - done -} - -# Synopsis: performs final processing on extended keys. Currently converts each "host:" -# extkeys to (possibly many) "sshk:" or "miss:" lines. Also validates all keys for basic -# syntax. -extkey_expand() { - while read -r extkey; do - [ -z "$extkey" ] && continue - key_pref="$(echo "$extkey" | cut -b1-5)" - if [ "$key_pref" = "host:" ]; then - ssh -nG "$(echo "$extkey" | cut -b6-)" 2>/dev/null | grep -e ^identityfile | awk '{print $2}' | keyf_expand - elif [ "$key_pref" = "sshk:" ] || [ "$key_pref" = "gpgk:" ] || [ "$key_pref" = "miss:" ]; then - echo "$extkey" - else - warn "Unrecognized extended key \"$extkey\". Should have a sshk:, gpgk: or host: prefix." - fi - done -} - -# Synopsis: gets all extended keys. SSH keys are in "sshk:" format. GPG fingerprints -# are in "gpgk:" format. Any SSH keys that cannot be found are expanded to "miss:, -# which is used for warnings later. If --extended is specified, we expect "sshk:foo" format on -# the command-line. Otherwise, we use cmdline_keys_to_extkey() to convert the standard command- -# line arguments into a format that keychain internals expect. - -get_all_extkeys() { - if $confallhosts; then - all_host_identities - fi - if ! $extended; then - echo "$cmdline_keys" | cmdline_keys_to_extkey | extkey_expand - else - echo "$cmdline_keys" | sshk_fixup | extkey_expand - fi -} - -setaction() { - if [ -n "$myaction" ]; then - die "you can't specify --$myaction and $1 at the same time" - else - myaction="$1" - fi -} - -wantagent() { - [ "$1" = "gpg" ] && [ -n "$gpgkeys" ] && return 0 - return 1 -} - -gpg_wipe() { - out="$( echo RELOADAGENT | gpg-connect-agent --no-autostart 2>/dev/null )" - if [ "$out" = "OK" ]; then - mesg "gpg-agent: All identities removed." - else - mesg "gpg-agent: Could not remove identities; possibly not running. (output: $out)" - fi -} - -ssh_wipe() { - if sshout=$(ssh-add -D 2>&1); then - mesg "ssh-agent: $sshout" - else - warn "ssh-agent: $sshout" - fi - -} - -while [ -n "$1" ]; do - case "$1" in - --absolute) absoluteopt=true ;; - --agents) shift; warn "--agents is deprecated, ignoring." ;; - --confhost) die "--confhost is deprecated; use \"${CYANN}--extended host:${OFF}\" instead." ;; - --confallhosts) confallhosts=true ;; - --confirm) confirmopt=true ;; - --debug|-D) debugopt=true ;; - --eval) evalopt=true ;; - --extended|--ext|-e) extended=true ;; - --gpg2) gpg_prog_name="gpg2" ;; - --help|-h) setaction help ;; - --host) shift; hostopt="$1" ;; - --ignore-missing) ignoreopt=true ;; - --inherit) shift; warn "--inherit is deprecated, ignoring. Use --ssh-allow-forwarded, --noinherit as needed instead.";; - --list|-l) setaction list ;; - --list-fp|-L) setaction list-fp ;; - --noask) noaskopt=true ;; - --nocolor) color=false ;; - --nogui) noguiopt=true ;; - --noinherit) allow_inherited=false ;; - --nolock) nolockopt=true ;; - --query) setaction query; quietopt=true ;; - --quiet|-q) quietopt=true ;; - --ssh-allow-gpg) ssh_allow_gpg=true ;; - --ssh-spawn-gpg) ssh_spawn_gpg=true; ssh_allow_gpg=true ;; - --ssh-agent-socket) shift; ssh_agent_socket="-a $1" ;; - --ssh-allow-forwarded) ssh_allow_forwarded=true ;; - --ssh-rm|-r) setaction ssh_rm ;; - --systemd) systemdopt=true ;; - --version|-V) setaction version ;; - --attempts) warn "--attempts is now deprecated." ;; - --clear) - clearopt=true - $quickopt && die "--quick and --clear are not compatible" - ;; - --dir) - shift - case "$1" in - */.*) keydir="$1" ;; - '') die "--dir requires an argument" ;; - *) - if $absoluteopt; then - keydir="$1" - else - keydir="$1/.keychain" # be backward-compatible - fi - ;; - esac - ;; - --env) - shift - if [ -z "$1" ]; then - die "--env requires an argument" - else - envf="$1" - fi - ;; - --lockwait) - shift - if [ "$1" -ge 0 ] 2>/dev/null; then - lockwait="$1" - else - die "--lockwait requires an argument zero or greater." - fi - ;; - --quick|-Q) - quickopt=true - $clearopt && die "--quick and --clear are not compatible" - ;; - --stop|-k) - setaction stop - case $2 in - all|mine|others) stopwhich="$2" ;; - *) die "Please specify 'all', 'mine' or 'others' for --stop" ;; - esac - ;; - --timeout) - shift - if [ "$1" -gt 0 ] 2>/dev/null; then - timeout=$1 - else - die "--timeout requires a numeric argument greater than zero" - fi - ;; - --wipe) - shift - case $1 in - gpg) setaction gpg_wipe ;; - ssh) setaction ssh_wipe ;; - all) setaction all_wipe ;; - *) die "Please specify ssh, gpg or all for --wipe action" - esac - ;; - -*) - zero=$(basename "$0") - echo "$zero: unknown option $1" >&2 - $evalopt && { echo; echo "false;"; } - exit 1 - ;; - *) - cmdline_keys="$1${NEWLINE}${cmdline_keys}" - ;; - esac - shift -done -if [ -z "$hostopt" ]; then - if [ -z "$HOSTNAME" ]; then - hostopt=$(uname -n 2>/dev/null || echo unknown) - else - hostopt="$HOSTNAME" - fi -fi - -pidf="${keydir}/${hostopt}-sh" -cshpidf="${keydir}/${hostopt}-csh" -fishpidf="${keydir}/${hostopt}-fish" -lockf="${keydir}/${hostopt}-lockf" -for keyf in "$pidf" "$cshpidf" "$fishpidf"; do - if [ -f "$keyf" ]; then - # shellcheck disable=SC2012 # POSIX defines the first 9 chars of ls -l: - go_modes="$(ls -ld "${keyf}" | cut -c5-10 )" - [ "$go_modes" != "------" ] && warn "Some pidfiles have lax permissions. Use ${CYAN}chmod -R go-rwx '${keydir}'${OFF} to fix." - keyf_owner="$(get_owner "${keyf}")" - [ -n "$keyf_owner" ] && [ "$keyf_owner" != "$me" ] && warn "${keyf} is owned by ${keyf_owner}, not ${me}. Please fix." - fi -done - -# Read the env snippet (especially for things like PATH, but could modify basically anything) -if [ -z "$envf" ]; then - envf="${keydir}/${hostopt}-env" - [ -f "$envf" ] || envf="${keydir}/env" - [ -f "$envf" ] || unset envf -fi -if [ -n "$envf" ]; then - # shellcheck disable=SC1090 - . "$envf" -fi - -# Don't use color if there's no terminal on stderr -if [ -n "$OFF" ]; then - tty <&2 >/dev/null 2>&1 || color=false -fi - -$color || unset BLUE CYAN CYANN GREEN PURP OFF RED - -# TODO: we can't assume pidfile has been created yet? Or not a big deal? -[ "$myaction" = list ] && eval "$(catpidf_shell sh)" && exec ssh-add -l -[ "$myaction" = list-fp ] && eval "$(catpidf_shell sh)" && exec ssh-add -L - -qprint #initial newline -mesg "${PURP}keychain ${OFF}${CYANN}${version}${OFF} ~ ${GREEN}https://github.com/danielrobbins/keychain${OFF}" - -[ "$myaction" = version ] && { versinfo; exit 0; } -[ "$myaction" = help ] && { versinfo; helpinfo; exit 0; } - -# Don't use signal names because they don't work on Cygwin. -if $clearopt; then - trap '' 2 # disallow ^C until we've had a chance to --clear - trap 'droplock; exit 1' 1 15 # drop the lock on signal - trap 'droplock;' 0 # drop the lock on exit -else - # Don't use signal names because they don't work on Cygwin. - trap 'droplock; exit 1' 1 2 15 # drop the lock on signal - trap 'droplock;' 0 # drop the lock on exit -fi - -testssh # sets $openssh, $sunssh and tweaks $ssh_spawn_gpg -verifykeydir # sets up $keydir - -# --stop: kill the existing ssh-agent(s) (not gpg-agent) and quit -[ "$myaction" = stop ] && stop_ssh_agents - -# --timeout translates almost directly to ssh-add/ssh-agent -t, but ssh.com uses -# minutes and OpenSSH uses seconds -if [ -n "$timeout" ]; then - ssh_timeout=$timeout - if $openssh || $sunssh; then - ssh_timeout=$(( ssh_timeout * 60 )) - fi - ssh_timeout="-t $ssh_timeout" -fi - -all_keys="$(get_all_extkeys | sort -u)" -if ! $ignoreopt; then - for key in $(echo "$all_keys" | grep ^miss:); do - warn "Can't find key \"${GREEN}$( echo "$key" | cut -c6- )${OFF}\"" - done -fi -sshkeys="$(echo "$all_keys" | sed -n '/^sshk:/s/sshk://p')" -gpgkeys="$(echo "$all_keys" | sed -n '/^gpgk:/s/gpgk://p')" -if [ "$myaction" = gpg_wipe ]; then - gpg_wipe; qprint; exit 0 -elif [ "$myaction" = ssh_wipe ]; then - ssh_wipe; qprint; exit 0 -elif [ "$myaction" = all_wipe ]; then - ssh_wipe; gpg_wipe; qprint; exit 0 -elif [ "$myaction" = query ]; then - # --query displays current settings, but does not start an agent: - if catpidf_shell sh > /dev/null; then - catpidf_shell sh | cut -d\; -f1 && exit 0 - else - die "Can't query. Does pidfile exist?" - fi -elif [ "$myaction" = ssh_rm ]; then - if [ -n "$sshkeys" ]; then - die "No ssh keys specified to remove." - fi - for key in $sshkeys; do - if sshout=$(ssh-add -d "$key" 2>&1); then - mesg "ssh-agent key $key removed." - else - die "keychain was unable to remove ssh-agent key $key. output: $sshout" - fi - done - qprint; exit 0 -else - # This will start gpg-agent as an ssh-agent if such functionality is enabled (default) - startagent_ssh || warn "Unable to start an ssh-agent (error code: $?)" - [ -n "$pidfile_out" ] && write_pidfile && eval "$pidfile_out" > /dev/null - if ! $gpg_started && wantagent gpg; then - # If we also want gpg, and it hasn't been started yet, start it also. We don't need to - # look for pidfile output, as this would have been output from the startagent_ssh->startagent_gpg - # call above, and gpg doesn't use pidfiles for gpg stuff anymore. - startagent_gpg || warn "Unable to start gpg-agent (error code: $?)" - fi - if $clearopt; then - ssh_wipe - if wantagent gpg; then - gpg_wipe - fi - trap 'droplock' 2 # done clearing, safe to ctrl-c - fi -fi - -if $evalopt; then - catpidf_shell "$SHELL" -fi - -$systemdopt && systemctl --user set-environment "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" -$systemdopt && [ -n "$SSH_AGENT_PID" ] && systemctl --user set-environment "SSH_AGENT_PID=$SSH_AGENT_PID" -# These options don't need to load keys, so terminate early: -$noaskopt && { qprint; exit 0; } -$quickopt && { qprint; exit 0; } - -load_ssh_keys() { - missing="$(echo "${sshkeys}" | ssh_listmissing)" - savedisplay="$DISPLAY" - if $confirmopt; then - if $openssh || $sunssh; then - ssh_confirm=-c - else - warn "--confirm only works with OpenSSH" - fi - fi - # Put $missing into args to access $# and other goodies. Since $missing is a line-delimited - # list of files with (potentially) spaces, we must do an IFS hack to get each file in - # $1, $2, $3, etc. For Bourne-shell compatibility, we don't have another good option: - IFS_BAK="$IFS"; IFS="$NEWLINE" - # shellcheck disable=SC2086 - set -- $missing - IFS="$IFS_BAK" - [ $# -eq 0 ] && return - mesg "Adding ${CYANN}$#${OFF} ssh key(s): ${CYANN}$*${OFF}" - if $noguiopt || [ -z "$SSH_ASKPASS" ] || [ -z "$DISPLAY" ]; then - unset DISPLAY # DISPLAY="" can cause problems - unset SSH_ASKPASS # make sure ssh-add doesn't try SSH_ASKPASS - fi - # shellcheck disable=SC2086 - sshout=$(ssh-add ${ssh_timeout} ${ssh_confirm} "$@" 2>&1) - ret=$? - if [ $ret = 0 ]; then - blurb="" - [ -n "$timeout" ] && blurb="life=${timeout}m" - [ -n "$timeout" ] && $confirmopt && blurb="${blurb}," - $confirmopt && blurb="${blurb}confirm" - [ -n "$blurb" ] && blurb=" (${blurb})" - mesg "ssh-add: Identities added: $sshkeys${blurb}" - else - warn "ssh-add failed: (return code: $ret; output: $sshout)" - fi - [ -n "$savedisplay" ] && DISPLAY="$savedisplay" - return $ret -} - -load_gpg_keys() { - $noguiopt && unset DISPLAY - [ -n "$DISPLAY" ] || unset DISPLAY # DISPLAY="" can cause problems - GPG_TTY=$(tty) ; export GPG_TTY # fall back to ncurses pinentry - for key in "$@"; do - [ -z "$key" ] && continue - mesg "Adding gpg key: $key" - # the 3>&1, etc. is a temp fd to allow us to capture stderr, while throwing away stdout which is encrypted data, and avoid a "null byte on input" bash warning: - gpgout="$(env LC_ALL="$pinentry_lc_all" "${gpg_prog_name}" --no-autostart --no-options --use-agent --sign --local-user "$key" -o- 3>&1 1>/dev/null 2>&3 -URL: https://github.com/danielrobbins/keychain -Source0: %{name}-%{version}.tar.bz2 -License: GPL v2 -Group: Applications/Internet -BuildArch: noarch -Requires: /bin/sh coreutils -Recommends: bash-completion -Prefix: /usr/bin -BuildRoot: %{_tmppath}/%{name}-root - -%description -Keychain is a manager for OpenSSH and GnuPG agents. It acts as a front-end -to ssh-agent and gpg-agent, allowing you to easily have one long-running -agent process per system, rather than per login session. This dramatically -reduces the number of times you need to enter your passphrase from once per -new login session to once every time your local machine is rebooted. - -Keychain also makes it easy for remote cron jobs to securely hook into a -long-running ssh-agent process, and integrates with gpg-agent for unified -key management. - -%prep -%setup -q - -%build - -%install -[ $RPM_BUILD_ROOT != / ] && rm -rf $RPM_BUILD_ROOT -mkdir -p $RPM_BUILD_ROOT/%{_bindir} $RPM_BUILD_ROOT/%{_mandir}/man1 -mkdir -p $RPM_BUILD_ROOT/%{_datadir}/bash-completion/completions -install -m0755 keychain $RPM_BUILD_ROOT/%{_bindir}/keychain -install -m0644 keychain.1 $RPM_BUILD_ROOT/%{_mandir}/man1 -install -m0644 completions/keychain.bash $RPM_BUILD_ROOT/%{_datadir}/bash-completion/completions/keychain - -%clean -rm -rf $RPM_BUILD_ROOT - -%files -%defattr(-,root,root) - %{_bindir}/* -%doc %{_mandir}/*/* -%doc ChangeLog COPYING.txt keychain.pod README.md - %{_datadir}/bash-completion/completions/keychain diff --git a/man/embedded-docs.txt b/man/embedded-docs.txt new file mode 100644 index 0000000..9b7dac8 --- /dev/null +++ b/man/embedded-docs.txt @@ -0,0 +1,868 @@ +== @tool keychain: Manager for ssh-agent, gpg-agent and private keys. + +Keychain helps you manage SSH and GPG keys in a convenient and secure manner. It +is a frontend to ``ssh-agent`` and ``ssh-add`` that lets you keep one +long-running ssh-agent process per user per host -- rather than the default of +one per login session -- so you only need to enter each key's passphrase once +per boot. + +This dramatically reduces the number of times you need to enter your +passphrase. With ``keychain``, you only need to enter a passphrase once every +time your local machine is rebooted. Keychain also makes it easy for remote +cron jobs to securely "hook in" to a long running ``ssh-agent`` process, +allowing your scripts to take advantage of key-based logins. + +Keychain also supports *GnuPG 2.1 and later**, and will automatically start +``gpg-agent`` if any GPG keys are referenced on the command-line, and will ensure these credentials are cached in memory and available for use. + +== @topic requirements: Keychain 3, Requirements and Backward Compatibility + +Keychain supports most UNIX-like operating systems and intentionally limits +its scope to modern, widely deployed implementations of OpenSSH and GnuPG. + +Keychain was conceived as a single POSIX Bourne shell script that had +grown steadily more sophisticated since its inception in 2002. In 2026, Daniel Robbins, Keychain's creator and current maintainer, has moved keychain 3 to a new, portable technology: a single Python *zipapp* -- a single executable ``.pyz`` file that is directly runnable on any system with Python 3.9 or newer, without requiring any external Python dependencies. This provides a modern runtime that is far more capable, while maintaining the self-contained, script-based nature of Keychain, supporting its continued evolution. + +Keychain 3 has a new action-driven interface (``keychain add id_rsa``, ``keychain agent stop --mine``, ``keychain wipe --ssh``, etc.) and is now the recommended way to drive Keychain going forward. Keychain now has a full embedded documentation engine; help for any action is available via +``--help ``, and an ``--explain`` option can be added to +any keychain invocation to display targeted documentation related to the action and options specified on the current command-line. A full embedded Keychain man page can be displayed via ``keychain man``, with ``keychain man --list`` displaying all sections that can be displayed individually via +``keychain man ``. + +As part of Keychain's evolution, maintaining keychain 2.x CLI compatibility was treated as a primary goal, not an afterthought. The legacy flat-flag command line (for example ``keychain --eval --quiet id_rsa``) continues to work exactly as before and is parsed by an explicit compatibility shim. + +Keychain 3 continues to be compatible with Bourne ``sh``, ``csh``/``tcsh`` and +``fish`` shells. ``bash``, ``ksh`` and ``zsh`` are compatible with the Bourne +``sh`` built-in to Keychain. Keychain also supports integration with *systemd*. + +== @topic usage: General Usage + +Typically, you configure keychain to run when you first log in to a system. If +you are using Bourne shell or bash, you will create a *~/.profile* or +*~/.bash_profile* file and include the following line in it: + +``` +eval "$(keychain add id_rsa --eval)" +``` + +Keychain will start ``ssh-agent`` if one isn't already running. Keychain then +checks to make sure your private keys (in this example, *id_rsa*) are loaded +into the agent. If they are not, you are prompted for any passphrase necessary +to decrypt them, so that they are cached in memory and available for use. + +In addition to printing some user-friendly output to your terminal, keychain +will also output important ``ssh-agent`` environment variables, which the ``"$( +)"`` (you can also use ``"` `"``) captures, and the shell built-in ``eval`` evaluates, causing these variables persist in your current session. + +These ssh-agent environment variables are also written to +``~/.keychain/${HOSTNAME}-sh``, so that subsequent logins and non-interactive +shells such as cron jobs can source this file to access the running ``ssh-agent`` and make passwordless ssh connections using the cached private keys -- even when you are logged out. These files are collectively called *pidfiles*. + +The key files specified on the command-line will be searched for in the +*~/.ssh/* directory, and keychain will expect to find the private key file with +the same name, as well as a *.pub* public key. Keychain will also see if any +GnuPG keys are specified, and if so, prompt for any passphrases to cache these keys into ``gpg-agent`` (and start a ``gpg-agent`` if none is running for the current user). + +Typically, private SSH key files are specified by filename only, without path, +although it is possible to specify an absolute or relative path to the private +key file as well. Private key files can be symlinks to the actual key. + +In addition, for GPG keys specified, similar steps will be taken to ensure that +gpg-agent has the GPG key cached in memory and ready for use. + +== @section ACTIONS + +== @action add: Add keys to the agent (auto-start one if needed). + +@syntax keychain add [OPTIONS] [KEYS...] + +``add`` is the workhorse of Keychain 3 -- in Keychain 2.x, this was the implied action when no other action was specified. It tells Keychain to first ensure that an *agent* (by default ``ssh-agent``) is currently running (equivalent to an automatic ``keychain agent start``). If not, one will be started. Then, for each KEY argument: + +* If the agent already holds the key, do nothing. +* Otherwise, prompt for the passphrase and load the key into the agent. +* Write the agent's environment to a per-user, per-host *pidfile** under + ``~/.keychain/`` that scripts can ``source`` to gain access to the agent. + +Bare key names are looked up under *~/.ssh/* and as GPG key IDs; absolute paths +are loaded directly. Symbolic links are supported Extended-key syntax -- ``sshk:PATH``, ``gpgk:KEYID``, ``host:HOSTNAME`` -- is automatically detected and can be used to explicitly specify the lookup method. + +When one or more KEY arguments are supplied and *none* of them resolve to a +real SSH key or known GPG identity, ``add`` exits without starting or attaching +to an agent. This is an intentional compatibility accommodation: keychain 2.x +bare-token invocation is still accepted, but a mistyped modern action should +not accidentally spawn a fresh background agent as a side effect. + + +``keychain man topic:extkeys``. + +== @action agent: Agent lifecycle (start | stop). + +@syntax keychain agent [OPTIONS] + +Domain command for managing agent processes without loading keys. ``agent +start`` ensures an agent exists and can emit its environment; ``agent stop`` +terminates agents recorded in the pidfile. + +== @action agent start: fork an agent if none is running (no key load) + +@syntax keychain agent start [OPTIONS] + +Start an ``ssh-agent`` (or ``gpg-agent`` when configured) if none is running, +then optionally emit its environment for the current shell. This action does not +load any keys; use ``add`` when you want prompt-and-load semantics. + +== @action agent stop: terminate ssh-agents (default: all in pidfile) + +@syntax keychain agent stop [--mine|--others] + +Terminate the ssh-agents recorded in the pidfile. Bare ``agent stop`` targets +every recorded agent; ``--mine`` and ``--others`` narrow the kill list by owner +UID. + +== @action list: List keys currently held by ssh-agent. + +@syntax keychain list [--json] + +Print the type, bit-length, fingerprint, and comment of every key held by the +reachable ssh-agent, in the same shape as ``ssh-add -l``. When the modern colour +theme is active, the output is rendered as a clean four-column table. + +``--json`` emits a JSON array suitable for scripting. + +== @action wipe: Wipe loaded keys (default: both ssh and gpg). + +@syntax keychain wipe [--ssh] [--gpg] + +Remove every key held by the agent (the agent process itself stays running). +With no flags, both ssh and gpg are wiped. + + wipe both + wipe --ssh ssh only + wipe --gpg gpg only + wipe --ssh --gpg same as bare ``wipe`` + +== @action forget: Evict specific SSH keys from ssh-agent. + +@syntax keychain forget [KEYS...] + +For each KEY argument, evict the matching SSH key from ``ssh-agent``. Bare key +names are looked up under *~/.ssh/*; absolute paths are used directly. +Extended-key syntax supports ``sshk:PATH`` and ``host:HOSTNAME``. ``gpgk:KEYID`` +is not supported by ``forget``. + +Without arguments, ``forget`` is a no-op (use ``wipe`` to remove all keys). + +== @action env: Print agent env in the requested format. + +@syntax keychain env [--shell FORMAT] [--json] + +Print ``SSH_AUTH_SOCK`` and ``SSH_AGENT_PID`` from the best available source +(running pidfile, then inherited env) in the requested format. Output targets: + + env bare ``KEY=value`` lines (default) + sh Bourne shell ``KEY=value; export KEY`` + csh C-shell ``setenv KEY value`` + fish ``set -x KEY value`` + json JSON object on one line + eval same as ``add --eval``: shell-detected dialect + systemd bare KEY=value (suitable for EnvironmentFile=) + +``--target`` is an alias for ``--shell``; ``--json`` is shorthand for ``--shell +json``. + +== @action status: One-screen agent summary (no side effects). + +@syntax keychain status [--json] + +Print a compact human-readable summary: which agent is reachable, where its +socket is, how many keys are loaded, whether the env was sourced from the +pidfile or inherited, and any health-check warnings. Read-only; safe to wire +into shell prompts or motd snippets. + +``--json`` emits the same data as a JSON object. + +== @action inspect: Structured snapshot of every state probe. + +@syntax keychain inspect [KEYS...] [--json] + +Run every state probe keychain would run during a real ``add`` (host detection, +paths, perms, env-file, agent reachability, key list, extkey expansion, ...) and +print the result -- without touching the agent, the pidfile, or the environment. +Used for diagnosis and for CI integration tests. + +== @action config: Show or edit ~/.keychainrc preferences. + +@syntax keychain config + +Inspect or mutate the persistent preferences file. Path resolution: +``$KEYCHAIN_CONFIG`` overrides; otherwise ``~/.keychainrc``. See ``keychain man +topic:config`` for the full key schema and ``keychain man config.`` for +individual key documentation. + +== @action config show: list every set value + +@syntax keychain config show + +Print every explicitly set preference as ``section.key = value``. + +== @action config get: print one value + +@syntax keychain config get KEY + +Print one preference value. Exits with status 1 when the key is unset. + +== @action config set: set or replace one value + +@syntax keychain config set KEY VALUE + +Validate and write one preference value into the config file. + +== @action config unset: remove one value + +@syntax keychain config unset KEY + +Remove one preference from the config file. + +== @action config edit: open the file in $VISUAL/$EDITOR + +@syntax keychain config edit + +Open the active config file in ``$VISUAL`` or ``$EDITOR``. + +== @action config path: print the path of the file in use + +@syntax keychain config path + +Print the path of the active config file and exit. + +== @action version: Print version banner. + +@syntax keychain version [--json] + +Print the version string, copyright, and license summary. + +== @action help: Print the cheat-sheet (same as --help). + +@syntax keychain help [] + +Print the top-level cheat sheet. With an action name, print the per-action cheat +sheet (same as ``keychain --help``). Identical to ``keychain --help``. + +For full documentation, use ``keychain man``. For a documentation walk-through +tied to a specific invocation, append ``--explain`` to the invocation itself. + +== @action man: Full documentation. + +@syntax keychain man [|] [--list] [--groff] [--no-pager] [--width N] + +Print the full embedded documentation in a pager-friendly form. Targets: + + keychain man whole tool manual + keychain man one action (incl. all flags) + keychain man named topical section + keychain man topic: same, with explicit prefix + keychain man --list index every doc target + keychain man --groff emit traditional roff (man -l -) + keychain man --no-pager skip $PAGER even on TTY + keychain man --width 100 explicit wrap width + +Same documentation source as ``--explain``: the doc records in +:mod:`keychain.docs.catalog`. The build system uses ``--groff`` to regenerate +``keychain.1``. + +== @option eval: emit shell env for `eval $(keychain ...)` + +@syntax keychain add --eval [KEYS...] + +This is a foundational keychain feature. If you're using Keychain on a POSIX +system, you'll likely want to use this flag in your shell startup files (e.g. +*~/.bash_profile*) to ensure that every new shell session can find and use +``ssh-agent`` without manual intervention. + +Here's how it works. When you pass ``--eval``, keychain will print out shell +commands to set environment variables that will allow future invocations of +``ssh`` and ``scp`` to find the running ``ssh-agent``. By using your shell's +built-in *eval* command to execute keychain's output, you effectively "import" +the emitted environment variables into your current shell session. Keychain +automatically detects the calling shell and emits the appropriate syntax for +that shell. This means you can use the snippet in any POSIX shell without +worrying about compatibility issues. Typical invocation in a *~/.bash_profile*: + +``` +eval $(keychain add --eval id_ed25519) +``` + +If you just want retrieve the agent environment without adding any keys, you can +use the ``keychain env --target eval`` action instead, or source the appropriate +``pidfile``. + +== @option systemd: push agent env to `systemctl --user` + +After loading keys, push ``SSH_AUTH_SOCK`` and ``SSH_AGENT_PID`` into the +calling user's systemd --user environment block using *systemctl* with ``--user +set-environment``. + +Useful when the user session is managed by systemd --user and child units +(timers, services) need access to the agent. + +== @option quick: short-circuit if an agent already has any key + +If a reachable ssh-agent already holds at least one key, exit immediately +without prompting or loading anything new. Intended for shell-startup snippets +where the agent is reused across many shells: the shell pays only the cost of a +single ``ssh-add -l`` on subsequent invocations. + +== @option no-passphrase: don't prompt for passphrases + +Refuse to spawn the passphrase prompt. If the agent doesn't already hold a key, +the load fails (silently by default; with ``--debug`` the reason is printed). +Pairs well with ``--quick`` for unattended scripts that should attach to an +existing agent or do nothing. + +``--noask`` is the legacy spelling; both spellings remain supported +indefinitely. + +== @option confirm: ssh-add -c: prompt per use + +Pass ``-c`` to ``ssh-add``: every subsequent use of the loaded key (e.g. by ssh +/ scp) requires explicit user confirmation via ``ssh-askpass``. A once-forever +lifestyle preference; usually set in ``~/.keychainrc`` rather than typed each +invocation. + +== @option timeout: auto-expire keys after MIN minutes + +Pass ``-t MINUTES*60`` to ``ssh-add``: the key is forgotten by the agent after +MIN minutes of inactivity. Common settings: 480 (8h, a workday) or 60 (high- +security). A common config-file preference. + +== @option ignore-missing: skip keys that don't exist + +For each KEY positional that doesn't resolve to an existing file (or known GPG +ID), skip it silently instead of erroring. Useful in dotfiles shared across +machines where not every key is present everywhere. If *all* requested keys are +missing, ``add`` still exits without spawning an agent. + +== @option clear: (hidden) wipe before adding -- use chained verbs + +Wipe both agents first, then load the new keys. Kept as a hidden compat flag for +cron-job dotfiles; the documented form is the chainable two-verb sequence: + +``` +keychain wipe && keychain add KEYS +``` + +which is more honest about what is happening and lets the steps fail +independently. + +== @option extended: (hidden) legacy no-op; prefixes always work + +Legacy no-op. ``sshk:``, ``gpgk:``, and ``host:`` prefixes are always recognized +per positional, and bare key names keep normal SSH/GPG lookup behavior. + +== @option confallhosts: load every IdentityFile in ~/.ssh/config + +Iterate every ``Host`` block in *~/.ssh/config* (excluding pure-wildcard +patterns and negations) and load its ``IdentityFile``. Equivalent to +``--extended host:NAME`` for every host. Per-host expansion is delegated to +``ssh -G NAME`` so include files, Match blocks, and ``%d``/``%u``/``%h`` tokens +work exactly as OpenSSH does. + +== @option ssh-allow-gpg: (hidden) use a running gpg-agent for SSH + +If a gpg-agent is already running with SSH support enabled +(``--enable-ssh-support`` at start), prefer it over spawning a new ssh-agent. A +once-forever lifestyle preference; set in ``~/.keychainrc``. + +== @option ssh-spawn-gpg: (hidden) spawn gpg-agent with SSH support + +This is the option to use if you're really on-board with using ``gpg-agent`` as +a replacement for ``ssh-agent``. Not only will keychain use a running +``gpg-agent`` if found, but if it needs to spawn a new ``ssh-agent``, it will go +ahead and spawn a ``gpg-agent`` in its place, and use it instead. This is best +set as a persistent setting in ``~/.keychainrc``: + +``` +[agent] +ssh_spawn_gpg = true +``` + +== @option ssh-allow-forwarded: (hidden) adopt a forwarded SSH agent + +Adopt the inherited ``$SSH_AUTH_SOCK`` if it points to a forwarded agent +(typical of ``ssh -A`` from a workstation into a server). **Has security +implications**: the forwarded socket is controllable by the source machine. See +``keychain man topic:security``. + +== @option no-inherit: (hidden) ignore inherited SSH_AUTH_SOCK / PID + +Don't trust ``$SSH_AUTH_SOCK`` / ``$SSH_AGENT_PID`` from the parent environment; +always start fresh from the pidfile or by spawning. The inverse of +``--ssh-allow-forwarded``. + +== @option ssh-agent-socket: (hidden) use PATH as the ssh-agent endpoint + +Use PATH as the ssh-agent socket explicitly, instead of letting ssh-agent pick +one. Rare; usually only needed when integrating with another tool that expects a +specific socket path. + +== @option mine: terminate only this UID's agents + +Restrict the kill list to agents whose owner UID matches the calling user. +Useful in shared accounts or root-cron contexts where you must not touch other +users' agents. + +== @option others: terminate agents owned by others + +Inverse of ``--mine``: terminate only agents whose owner UID does **not** match +the calling user. Rare; typically a maintenance tool. + +== @option list-json: emit machine-readable JSON on stdout + +Emit a JSON array of key records on stdout. + +== @option wipe-ssh: wipe SSH agent only + +Wipe only the keys held by ssh-agent. + +== @option wipe-gpg: wipe GPG agent only + +Wipe only the keys held by gpg-agent. + +== @option env-shell: output target (default: env) + +One of ``env`` / ``sh`` / ``csh`` / ``fish`` / ``json`` / ``eval`` / +``systemd``. + +== @option env-json: shorthand for --shell json + +Equivalent to ``--shell json``. + +== @option status-json: emit JSON + +Emit the status payload as JSON on stdout. + +== @option inspect-json: emit JSON + +Emit the inspection payload as JSON. + +== @option version-json: emit JSON + +Emit ``{"version": "X.Y.Z"}``. + +== @option man-list: list every doc target with its one-liner + +Print ``slug -- one-liner`` for every action, global flag, config key, and +topic. Useful as a discovery surface. + +== @option man-groff: emit traditional groff (man-page) output + +Render to groff(1) macros instead of plain text. Suitable for ``man -l -``, +``troff``, or for redirecting into ``keychain.1`` from the build system. + +== @option man-no-pager: don't pipe through $PAGER on TTY + +Skip the automatic ``$PAGER`` invocation even when stdout is a terminal. + +== @option man-width: wrap output at WIDTH columns + +Explicit wrap width. Defaults to ``$COLUMNS`` then 80. + +== @global quiet: suppress non-error output on stderr + +Silence informational messages on stderr. Errors are still printed. + +== @global debug: trace agent-selection decisions on stderr + +Emit a structured trace of every agent-discovery decision keychain makes: which +pidfile candidates were considered, which sockets were probed, why one was +selected, what ``ssh -G`` returned for each ``host:`` extkey, etc. Spelt ``-D`` +as a short option. + +== @global nocolor: disable ANSI colour output + +Disable ANSI colour output. Useful when piping to a tool that doesn't strip +escape codes, or in log capture where colour bytes muddy the file. The +conventional ``$NO_COLOR`` environment variable (https://no-color.org) is also +honoured; the flag wins when both are set. ``--no-color`` is an accepted alias. + +== @global theme: (hidden) colour theme: legacy or modern + +Select the colour / glyph theme. ``legacy`` is the keychain 2.x output style; +``modern`` is the 3.0 default. A once-forever lifestyle preference; usually set +in ``~/.keychainrc``. + +== @global no-gui: (hidden) don't use GUI passphrase prompts + +Don't use GUI passphrase prompts (DISPLAY / SSH_ASKPASS). Force a TTY prompt +instead. A once-forever lifestyle preference for headless or screen-shared +environments. + +== @global gpg2: (hidden) force the gpg2 binary + +Force the ``gpg2`` binary instead of ``gpg``. Kept for 2.x compatibility; modern +systems normally provide GnuPG 2 as the default ``gpg``. + +== @global absolute: (hidden) treat --dir as the literal pidfile dir + +Treat ``--dir`` (or ``[paths] dir``) as the literal pidfile directory; don't +append ``/.keychain``. + +== @global allow-env: allow KEYCHAIN_* environment variables + +By default, keychain silently ignores all ``KEYCHAIN_*`` environment variables +to prevent accidental or malicious injection in multi-user environments. +Pass ``-E`` or ``--allow-env`` to allow +``$KEYCHAIN_CONFIG`` to override the config file path, and``$KEYCHAIN_SSH_AGENT_ARGS``/``$KEYCHAIN_GPG_AGENT_ARGS`` to inject extra flags into spawned agents. + +This flag is intended for trusted, single-user environments or CI pipelines +where the shell environment is controlled. It is **not** needed for normal +interactive use; all KEYCHAIN_* behaviour can be achieved via +``~/.keychainrc`` configuration. + +== @global dir: pidfile directory (default: ~/.keychain) + +Override the per-user state directory. By default keychain uses ``~/.keychain/`` +for pidfiles, env-files, and lock files. ``--absolute`` (hidden) treats the +value verbatim instead of appending ``/.keychain``. + +== @global host: override hostname used for pidfile naming + +Override the hostname used in pidfile names. Pidfiles are named ``HOST-sh`` / +``HOST-csh`` / ``HOST-fish`` etc. so multiple hosts sharing a homedir over NFS +each get their own state. By default keychain probes ``hostname(1)``, +``$HOSTNAME``, then ``uname -n``. + +%% @config agent-env env-file: load extra KEY=value env before agent/tool startup + +Load additional environment assignments from ``FILE`` before keychain discovers +agents, starts new agents, or runs helper tools such as ``ssh-add``. + +Configure it in ``~/.keychainrc``: + +``` +[agent.env] +env_file = ~/.config/keychain/agent.env +``` + +The file format is a simple ``KEY=value`` list. The first ``=`` splits key from +value, matching outer quotes are stripped, and ``$VAR`` / ``${VAR}`` / ``%VAR%`` +references are expanded against keychain's current environment snapshot. + +Use this for niche, keychain-scoped overrides such as a custom ``PATH`` or extra +``KEYCHAIN_*_AGENT_ARGS``. Keychain does not auto-discover env files; set +``env_file`` explicitly when you want this behaviour. + +%% @config agent-env ssh-args: extra args passed to spawned ``ssh-agent`` + +Append extra arguments when keychain spawns ``ssh-agent``. + +Configure it in ``~/.keychainrc``: + +``` +[agent.env] +ssh_args = -a ~/.ssh/agent.sock +``` + +The value is split like shell words and appended after keychain's own managed +flags, so use it only for niche cases keychain does not model directly. + +If ``KEYCHAIN_SSH_AGENT_ARGS`` is already set in the outer environment, that +environment value wins. + +%% @config agent-env gpg-args: extra args passed to spawned ``gpg-agent`` + +Append extra arguments when keychain spawns ``gpg-agent``. + +Configure it in ``~/.keychainrc``: + +``` +[agent.env] +gpg_args = --allow-preset-passphrase +``` + +The value is split like shell words and appended after keychain's own managed +flags, so use it only for niche cases keychain does not model directly. + +If ``KEYCHAIN_GPG_AGENT_ARGS`` is already set in the outer environment, that +environment value wins. + +== @global lockwait: max seconds to wait for the keychain lockfile + +Maximum time (in seconds) to wait for keychain's own cooperative lockfile when +another keychain process is mutating the same agent state. Default 5 seconds. +Set to 0 to fail immediately if the lock is held. + +== @global no-lock: skip the keychain lockfile + +Skip the cooperative lockfile entirely. Use only in controlled environments (CI +runners that already serialise externally) where the lock-wait overhead is +wasteful and simultaneous mutation is impossible by construction. ``--nolock`` +is the legacy spelling. + +== @global explain: explain this invocation instead of running it + +Don't run the action. Instead, walk the rest of the command line left-to-right +and print documentation for the action and every recognised option, in the order +they appear. + +Bespoke documentation for the exact invocation in front of you. Pairs well with +command-line history: append ``--explain`` to any prior invocation to read about +it without rerunning it. + +== @global agents: (hidden) deprecated legacy agent selector + +Deprecated legacy flag. Accepted for old dotfiles, then ignored with a warning. + +== @global inherit: (hidden) deprecated legacy inherit selector + +Deprecated legacy flag. Accepted for old dotfiles, then ignored with a warning. + +== @global confhost: (hidden) deprecated legacy host expander + +Deprecated legacy flag. Accepted so the runtime can emit the modern replacement +hint. + +== @global attempts: (hidden) deprecated legacy retry count + +Deprecated legacy flag. Accepted for old dotfiles, then ignored with a warning. + +== @topic security: Security model + +**What keychain protects against.** + +* Re-prompting for the same passphrase across many shell sessions: each key + needs its passphrase entered exactly once between agent restarts. +* Accidental key exposure to other local users: the pidfile directory is created + mode 700 and owner-checked on every read; ssh-agent's UNIX-domain socket is + owner-only by default. +* Stale agent processes pointing the env at sockets that no longer exist: probed + and discarded before use. + +**What keychain does NOT protect against.** + +* Local-root attackers. ssh-agent and its socket live in userspace; root can + ptrace any process or read any socket. Keychain doesn't change that. +* Compromised SSH-server-side machines if you forwarded an agent into them + (``ssh -A``). The forwarded agent socket is controllable by the remote machine + for the duration of the forward. +* Stolen unattended laptops: an unlocked agent is a key. Pair ``--timeout`` with + screen-lock policy. + +**--ssh-allow-forwarded.** Adopting a forwarded agent socket on a remote host +means the *source* machine -- the laptop you forwarded from -- can sign +authentication requests on this host's behalf. That is by design (it's the point +of agent forwarding) but it widens the trust boundary. Don't enable on hosts you +don't fully control. + +**File permissions.** Pidfiles, env-files, and the lockfile all live under +``~/.keychain/`` (mode 700). On every read keychain ``stat()``\s the file, +verifies owner == current user, and refuses to operate if either check fails -- +so a world-writable home directory does not expose keys. + +== @topic agents: How keychain discovers, attaches, and spawns agents + +**Discovery order.** When ``add`` or ``agent start`` runs, keychain probes for +an existing agent in this order: + +1. The pidfile in ``$KEYCHAIN_DIR/HOST-pid`` (its writer left ``SSH_AUTH_SOCK`` + and ``SSH_AGENT_PID`` here). Socket presence on disk and reachability via + ``ssh-add -l`` are both verified. +2. Inherited ``$SSH_AUTH_SOCK`` / ``$SSH_AGENT_PID`` from the parent shell's + environment, unless ``--no-inherit`` is set. Forwarded agents (``ssh -A``) + are accepted only when ``--ssh-allow-forwarded`` is set. +3. A running gpg-agent with SSH support enabled, when ``--ssh-allow-gpg`` is + set. +4. None of the above -- a new agent is spawned (ssh-agent, or gpg-agent with + ``--enable-ssh-support`` if ``--ssh-spawn-gpg``). + +**Pidfile is the source of truth.** Per-host (so NFS-shared homes work) and +per-user (mode 700). Older pidfiles are replaced atomically; no in-place edits. + +**Why two agents.** ssh-agent handles SSH keys; gpg-agent handles GPG keys. +Modern gpg-agent can also serve as the SSH agent; users with mostly-GPG +workflows often prefer ``--ssh-spawn-gpg`` to collapse to one process. + +== @topic extkeys: Extended-key syntax + +Positional key arguments accept six prefixes: + + sshk:PATH a literal SSH key file path + gpgk:KEYID a GnuPG key ID (generic) + gpgs:KEYID warm only signing subkeys + gpge:KEYID warm only encryption subkeys + gpga:KEYID warm all subkeys + host:HOSTNAME every IdentityFile for HOSTNAME + (resolved via ``ssh -G HOSTNAME``) + +Bare positional names without a prefix are auto-classified: ``~/.ssh/NAME`` if +it exists → ``sshk:``; otherwise looked up via ``gpg --list-secret-keys`` → +``gpgk:``; otherwise reported as missing. + +Prefixed and bare positionals can be mixed freely. The old ``--extended`` flag +is accepted for 2.x compatibility and does not change parsing. + +``host:`` expansion is delegated to ``ssh -G`` so the full OpenSSH semantics +(``Match``, ``Include``, ``%d``/``%u``/ ``%h`` substitution, quoted args) apply +-- no parallel parser. + +== @topic pidfile: Pidfile location and contents + +Per user, per host, mode 700 directory: + + ~/.keychain/ + +(overridable by ``--dir`` / ``[paths] dir``). + +Files inside, where ``HOST`` is the resolved hostname: + + HOST-pid KEY=value lines for the running agent + HOST-sh Bourne-style ``KEY=value; export KEY`` + HOST-csh C-shell ``setenv KEY value`` + HOST-fish fish ``set -x KEY value`` + HOST-envfile bare ``KEY=value`` export for non-shell tools + .keychain.lock cooperative mutation lock + +All files are owner-only (mode 600) and re-written atomically (``write to .tmp; +rename``). Stale entries are recognised via socket presence + ``kill(0)`` and +replaced. + +One pidfile per host means that homedirs shared over NFS across many machines +each get their own agent state -- no ssh-agent confusion when you log in from +multiple hosts. + +== @topic env: Environment emission and shell integration + +Keychain's job is to set ``SSH_AUTH_SOCK`` and ``SSH_AGENT_PID`` in the calling +shell's environment. There are four documented ways: + +1. **Use *eval* on the rendered shell snippet** (most common): + + eval $(keychain add --eval ID_FILES) + +``add --eval`` (or ``env --target eval``) detects ``$SHELL`` and prints the +right dialect (sh/csh/fish). + +2. **Source the per-host pidfile** in shell startup: + + . ~/.keychain/$(hostname)-sh # bash/zsh + source ~/.keychain/$(hostname)-fish # fish + +Lower latency; no python invocation per shell. + +3. **systemd --user push**: + + keychain add --systemd + # or, on demand: + keychain env --target systemd + +Side-effecting: invokes *systemctl* with ``--user set-environment`` so child +units inherit the env. + +4. **Bare KEY=value emission** for tools that consume ``EnvironmentFile=`` or + ``--env-file``: + + keychain env > /run/user/$UID/keychain.env + # ↑ default ``--shell env`` format + +If you need extra environment variables before keychain starts or attaches to +agents, configure ``[agent.env] env_file`` in ``~/.keychainrc``. That file is +input to keychain itself; it is separate from the generated ``HOST-envfile`` +export file above. + +== @topic compat: keychain 2.x compatibility + +Every keychain 2.x flat-flag invocation is translated to the new-style action +verbs transparently. Translations (left → right): + + keychain => keychain add + keychain id_rsa => keychain add id_rsa + keychain --stop all => keychain agent stop + keychain -k mine => keychain agent stop --mine + keychain --list => keychain list + keychain -l => keychain list + keychain --list-fp => keychain list + keychain -L => keychain list + keychain --wipe all => keychain wipe + keychain --wipe ssh => keychain wipe --ssh + keychain --wipe gpg => keychain wipe --gpg + keychain --query => keychain env + keychain --ssh-rm id_rsa => keychain forget id_rsa + keychain -r id_rsa => keychain forget id_rsa + keychain --inspect => keychain inspect + +Old long-form spellings of options that have been renamed (``--noask``, +``--noinherit``, ``--nogui``, ``--nolock``, ``--no-color``) all remain accepted. +**No deprecation is planned for the legacy flag spellings.** + +Old options that are now hidden but still accepted with their original meaning: +``--extended``, ``--gpg2``, ``--absolute``, ``--clear``, ``--theme``, +``--ssh-allow-*``, ``--ssh-spawn-gpg``, ``--no-inherit``, +``--ssh-agent-socket``, ``--confirm``. These are now lifestyle preferences set +in ``~/.keychainrc``; the CLI flags work but no longer appear in --help. + +Removed (silent no-ops with a one-time warning): ``--agents``, ``--inherit``, +``--confhost``, ``--attempts``. + +Compatibility is therefore intentionally *high* but not perfect. In particular, +bare tokens still default to ``add`` for 2.x compatibility, but if none of the +requested keys resolve, keychain refuses to start an agent and exits with an +error instead of preserving the old side effect. + +== @topic windows: Windows-specific notes + +Keychain runs on Windows via ``keychain.pyz`` plus the Python launcher +(``py.exe``). What you get: + +* The full action surface (``add``, ``agent start``, ``list``, ``status``, + ``inspect``, ``env``, ...). The embedded documentation system (``--help``, + ``man``, ``--explain``) works identically to the Linux build. +* Native Win32 support requires the OpenSSH-for-Windows ssh-agent service. + Pageant (PuTTY) is **not** supported. +* ``--systemd`` is a no-op on Windows. +* The pager defaults to off (Windows ``more`` doesn't handle ANSI cleanly). Set + ``$PAGER`` to ``less`` / ``moar`` / ``bat`` to opt in. + +**fork-less environment.** ssh-agent is spawned via ``CreateProcess`` rather +than ``fork``+``exec``; the pidfile records the OpenSSH service socket path +instead of a UNIX-domain socket. + +See ``keychain man topic:agents`` for the discovery order; on Windows the +OpenSSH service socket is probed first. + +== @topic config: ~/.keychainrc preference file + +INI-style preferences file read at startup. Resolution order: + + 1. Explicit CLI flag (per-invocation) + 2. ``$KEYCHAIN_*`` environment vars (per-shell) + 3. ``~/.keychainrc`` (per-user) + 4. Built-in defaults (fallback) + +Sections (3.0 layout, conceptual grouping): + + [agent] agent lifecycle / behaviour: ``noask``, + ``quick``, ``confirm``, ``timeout``, + ``systemd``, ``ssh_allow_gpg``, + ``ssh_spawn_gpg``, ``ssh_allow_forwarded``, + ``noinherit``, ``ssh_agent_socket``, + ``gpg2``, ``clear`` + [agent.env] extra arguments and optional env-file + overrides for spawned agents / helpers: + ``ssh_args``, ``gpg_args``, ``env_file``. + ``ssh_args`` / ``gpg_args`` are exported + as ``KEYCHAIN_SSH_AGENT_ARGS`` / + ``KEYCHAIN_GPG_AGENT_ARGS`` for the agent + process to consume (env wins if already set) + [keys] key-resolution: ``confallhosts``, + ``extended``, ``ignore_missing`` + [paths] state location: ``dir``, ``host``, + ``absolute`` + [output] rendering: ``quiet``, ``debug``, ``nocolor``, + ``theme``, ``nogui``, ``evalopt`` + [lock] cooperative locking: ``nolock``, ``lockwait`` + +Boolean values accept ``true`` / ``false`` / ``yes`` / ``no`` / ``on`` / ``off`` +/ ``1`` / ``0``. Integers are decimal. Strings are passed verbatim. + +See ``keychain config show`` for the live values, or ``keychain man +config.`` for one key. The full key list is ``keychain man --list | grep +^config.``. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..acf5ca6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: GPL-3.0-only +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "build_backend" +backend-path = ["scripts"] + +[project] +name = "keychain" +dynamic = ["version"] +description = "Manager for ssh-agent, gpg-agent and private keys" +readme = "README.md" +license = { text = "GPL-3.0-only" } +requires-python = ">=3.9" +authors = [ + { name = "Daniel Robbins", email = "drobbins@breezyops.com" }, +] +keywords = ["ssh", "ssh-agent", "gpg", "gpg-agent", "keychain"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: POSIX", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Security", + "Topic :: System :: Systems Administration", + "Topic :: Utilities", +] +# Zero runtime dependencies: dataclasses and importlib.metadata are both +# in the stdlib on Python 3.9+ (the supported floor). +dependencies = [] + +[project.urls] +Homepage = "https://kernel-seeds.org/projects/keychain/" +Source = "https://github.com/danielrobbins/keychain" + +[project.scripts] +keychain = "keychain.main:main" + +[tool.setuptools] +package-dir = { "" = "src" } +packages = ["keychain", "keychain.docs", "keychain.output", "keychain.runtime"] + +[tool.setuptools.package-data] +"keychain" = ["docs/_doc_texts.json"] + +[project.optional-dependencies] +lint = [ + "ruff>=0.5", + "mypy>=1.4,<2", + "bandit>=1.7", +] + +# Convenience superset for local development. +dev = [ + "pytest>=7", + "pytest-cov>=5", + "ruff>=0.5", + "mypy>=1.4,<2", + "bandit>=1.7", + "pre-commit>=3", +] + +[tool.setuptools.dynamic] +version = { file = ["VERSION"] } + +[tool.pytest.ini_options] +testpaths = ["tests"] + +# --------------------------------------------------------------------------- +# Static analysis: ruff + mypy + bandit +# +# These are *complementary*, not overlapping: +# ruff -- style / imports / pyflakes (linting & autofix) + formatting +# mypy -- static type checking (typing.* annotations only) +# bandit -- security-focused linter (subprocess shell=True, hardcoded creds, ...) +# --------------------------------------------------------------------------- + +[tool.ruff] +target-version = "py39" +line-length = 120 +src = ["src", "tests"] + +[tool.ruff.format] +# ruff format is the single formatter for this project (replaces black). +# Line-length is inherited from ``[tool.ruff] line-length`` above. +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false + +[tool.ruff.lint] +# Selected rule sets (see https://docs.astral.sh/ruff/rules/): +# F -- pyflakes (real bugs: unused imports, undefined names) +# I -- isort (import ordering / grouping) +# B -- flake8-bugbear (likely-bug patterns) +# UP -- pyupgrade (keep the modernized 3.9+ codebase from regressing) +select = ["F", "I", "B", "UP"] +ignore = [ + "B904", # ``raise ... from`` inside except: noisy in this codebase. + "B008", # default arg call: ``dataclasses.field(default_factory=...)`` is fine. + "UP032", # ``.format()`` -> f-string: a few multi-arg cases stay readable as-is. +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["B011"] # ``assert`` is the entire point of a test. + +[tool.mypy] +python_version = "3.9" +files = ["src/keychain"] +ignore_missing_imports = true +no_implicit_optional = true + +[tool.bandit] +# Bandit's defaults are reasonable. We only need to tell it where to look and +# silence a couple of false positives that are part of normal subprocess use. +exclude_dirs = ["tests", "build", "dist", ".venv"] +skips = [ + # ``subprocess`` import & calls -- using subprocess is the whole point. + "B404", + "B603", + "B607", + # False positives on CLI/doc literals like "keychain" and "--". + "B105", +] diff --git a/scripts/build_backend.py b/scripts/build_backend.py new file mode 100644 index 0000000..b327453 --- /dev/null +++ b/scripts/build_backend.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: GPL-3.0-only +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from typing import Any + +from setuptools import build_meta as _setuptools + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "build_doc_texts.py" +OUTPUT = ROOT / "src" / "keychain" / "docs" / "_doc_texts.json" + + +def _generate_docs() -> None: + if SCRIPT.is_file(): + subprocess.run([sys.executable, str(SCRIPT)], check=True) + elif not OUTPUT.is_file(): + raise SystemExit("missing generated docs JSON") + + +def _editable_hook(name: str): + hook = getattr(_setuptools, name, None) + if hook is None: + raise SystemExit("editable builds require setuptools>=64") + return hook + + +def get_requires_for_build_wheel(config_settings: dict[str, Any] | None = None) -> list[str]: + return _setuptools.get_requires_for_build_wheel(config_settings) + + +def prepare_metadata_for_build_wheel(metadata_directory: str, config_settings: dict[str, Any] | None = None) -> str: + _generate_docs() + return _setuptools.prepare_metadata_for_build_wheel(metadata_directory, config_settings) + + +def build_wheel( + wheel_directory: str, + config_settings: dict[str, Any] | None = None, + metadata_directory: str | None = None, +) -> str: + _generate_docs() + return _setuptools.build_wheel(wheel_directory, config_settings, metadata_directory) + + +def build_sdist(sdist_directory: str, config_settings: dict[str, Any] | None = None) -> str: + _generate_docs() + return _setuptools.build_sdist(sdist_directory, config_settings) + + +def get_requires_for_build_editable(config_settings: dict[str, Any] | None = None) -> list[str]: + return _editable_hook("get_requires_for_build_editable")(config_settings) + + +def prepare_metadata_for_build_editable(metadata_directory: str, config_settings: dict[str, Any] | None = None) -> str: + _generate_docs() + return _editable_hook("prepare_metadata_for_build_editable")(metadata_directory, config_settings) + + +def build_editable( + wheel_directory: str, + config_settings: dict[str, Any] | None = None, + metadata_directory: str | None = None, +) -> str: + _generate_docs() + return _editable_hook("build_editable")(wheel_directory, config_settings, metadata_directory) diff --git a/scripts/build_doc_texts.py b/scripts/build_doc_texts.py new file mode 100644 index 0000000..ff25850 --- /dev/null +++ b/scripts/build_doc_texts.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: GPL-3.0-only +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import OrderedDict +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +_ROOT = Path(__file__).resolve().parents[1] +_SOURCE = _ROOT / "man" / "embedded-docs.txt" +_OUTPUT = _ROOT / "src" / "keychain" / "docs" / "_doc_texts.json" + +_HEADING_RE = re.compile(r"^==\s+@([a-z][a-z0-9-]*)(?:\s+(.*\S))?\s*$") + + +@dataclass +class Section: + lineno: int = 0 + kind: str = "" + name: str = "" + short_help: str = "" + syntax: str = "" + body: str = "" + + @property + def tag(self) -> str: + return f"{self.kind}:{self.name}" + + +def _split_heading(rest: str) -> tuple[str, str]: + name, sep, short_help = rest.partition(":") + return (name.rstrip(), short_help.strip() if sep else "") + + +def get_heading(stripped: str, *, lineno: int) -> Section | None: + """Return a Section if the current line starts a tagged block.""" + match = _HEADING_RE.match(stripped.rstrip("\r\n")) + if match is None: + return None + kind, rest = match.groups() + name, short_help = _split_heading(rest or "") + return Section(lineno=lineno, kind=kind, name=name, short_help=short_help) + + +def parse_sections(text: str) -> list[Section]: + """Parse tagged embedded-doc text into Section records.""" + docs: list[Section] = [] + cur_section: Section | None = None + current_lines: list[str] = [] + + def _finish(*, lineno: int) -> None: + # Close out the current section when a new heading starts or at end of file. + nonlocal cur_section, current_lines + if cur_section is None: + return + cur_section.body = "".join(current_lines).rstrip("\r\n") + if cur_section.kind != "section" and not cur_section.body.strip(): + raise ValueError(f"line {lineno}: empty doc body for {cur_section.tag}") + docs.append(cur_section) + cur_section = None + current_lines = [] + + for lineno, line in enumerate(text.splitlines(keepends=True), start=1): + new_section = get_heading(line, lineno=lineno) + if new_section: + # A heading always starts a new section, so flush any active one first. + if cur_section: + _finish(lineno=lineno) + cur_section = new_section + continue + + if cur_section is None: + raise ValueError(f"line {lineno}: text outside a tagged block: {line!r}") + + stripped = line.strip() + body_started = any(part.strip() for part in current_lines) + if not body_started and stripped.startswith("@syntax"): + key, _, value = stripped.partition(" ") + if key != "@syntax" or not value: + raise ValueError(f"line {lineno}: @syntax requires text") + if cur_section.syntax: + raise ValueError(f"line {lineno}: duplicate @syntax for {cur_section.tag}") + cur_section.syntax = value.strip() + continue + if not body_started and stripped.startswith("@"): + raise ValueError(f"line {lineno}: unknown metadata tag {stripped}") + if not body_started and not stripped: + continue + current_lines.append(line.rstrip("\r\n") + "\n") + + _finish(lineno=len(text.splitlines()) or 1) + return docs + + +def parse_tagged_text(text: str) -> dict[str, Any]: + out: dict[str, Any] = {} + out["all"] = [] + for section in parse_sections(text): + tag = section.tag + if tag in out["all"]: + raise ValueError(f"duplicate doc tag {tag}") + out["all"].append(tag) + out.setdefault(section.kind, OrderedDict()) + out[section.kind][section.name] = { + "short_help": section.short_help, + "syntax": section.syntax, + "description": section.body, + } + return out + + +def render_json(docs: dict[str, Any]) -> str: + return json.dumps(docs, indent=2, ensure_ascii=False) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Build the embedded docs JSON blob from tagged text.") + parser.add_argument("--source", type=Path, default=_SOURCE) + parser.add_argument("--output", type=Path, default=_OUTPUT) + parser.add_argument("--check", action="store_true", help="fail if OUTPUT is out of date") + args = parser.parse_args(argv) + + rendered = render_json(parse_tagged_text(args.source.read_text(encoding="utf-8"))) + if args.check: + current = args.output.read_text(encoding="utf-8") if args.output.is_file() else "" + if current != rendered: + sys.stderr.write(f"{args.output} is out of date; run scripts/build_doc_texts.py\n") + return 1 + return 0 + + args.output.write_text(rendered, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/fetch-ci-artifacts.sh b/scripts/fetch-ci-artifacts.sh deleted file mode 100755 index a196ba6..0000000 --- a/scripts/fetch-ci-artifacts.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh -# Fetch latest workflow artifacts for the given version tag using the GitHub API. -# Usage: GITHUB_TOKEN=... GITHUB_REPOSITORY=owner/repo ./scripts/fetch-ci-artifacts.sh -set -eu -VER=${1:?usage: fetch-ci-artifacts.sh } -DEST=${2:?usage: fetch-ci-artifacts.sh } -REPO=${GITHUB_REPOSITORY:?GITHUB_REPOSITORY not set} -[ -n "${GITHUB_TOKEN:-}" ] || { echo "GITHUB_TOKEN not set" >&2; exit 1; } - -# Find workflow run for this tag (latest by created_at) -# We assume workflow file name 'release.yml'. -RUNS_JSON=$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/$REPO/actions/runs?per_page=50") -RUN_ID=$(printf '%s' "$RUNS_JSON" | jq -r --arg ver "$VER" '.workflow_runs | map(select(.head_branch == $ver or .display_title == $ver or .head_sha != null)) | map(select(.name=="release")) | map(select(.head_branch==$ver)) | sort_by(.created_at) | last.id') -[ "$RUN_ID" != "null" ] || { echo "No workflow run found for tag $VER" >&2; exit 1; } - -ARTIFACTS=$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" "https://api.github.com/repos/$REPO/actions/runs/$RUN_ID/artifacts") -ART_ID=$(printf '%s' "$ARTIFACTS" | jq -r '.artifacts[] | select(.name | test("keychain-")) | .id' | tail -1) -[ -n "$ART_ID" ] || { echo "No artifacts found for run $RUN_ID" >&2; exit 1; } - -TMPZIP=$(mktemp) -curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" -L "https://api.github.com/repos/$REPO/actions/artifacts/$ART_ID/zip" -o "$TMPZIP" -mkdir -p "$DEST" -unzip -qo "$TMPZIP" -d "$DEST" -rm -f "$TMPZIP" - -echo "Fetched CI artifacts for $VER into $DEST" >&2 diff --git a/scripts/pyz_bootstrap.py b/scripts/pyz_bootstrap.py new file mode 100644 index 0000000..aedaa7d --- /dev/null +++ b/scripts/pyz_bootstrap.py @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Zipapp entry point with a re-exec bootstrap. + +This module is copied verbatim to ``build/pyz-stage/__main__.py`` by the +``keychain.pyz`` Makefile target. It runs *before* any keychain code is +imported, on whatever Python ``/usr/bin/env python3`` happens to resolve +to. Its job is to make sure we end up running on a Python that can +actually execute the package (currently 3.9+). + +Why a Python bootstrap instead of a shell shebang trick: + +* ``#!/usr/bin/env python3`` is the only shebang form that works on both + POSIX systems and Windows (via the ``py.exe`` PEP 397 launcher and + ``.pyz`` file association). Polyglot sh/python preambles do not run on + Windows at all. +* Hardcoding a fallback chain like ``python3.13 || python3.12 || ...`` + in shell grows linearly with each Python release and goes stale; this + module discovers ``python3.NN`` binaries dynamically so future releases + Just Work. +* On modern systems (where ``python3`` already satisfies the floor) this + costs one tuple comparison: no PATH walk, no re-exec. +* On RHEL 8 / Rocky 8 (where ``/usr/bin/python3`` is 3.6.8 but the user + installed ``python39``/``python311``/``python313`` via AppStream + modules), we ``os.execv`` into the newest available interpreter. +""" + +from __future__ import annotations + +import os +import sys + +# Must match the floor declared in pyproject.toml's ``requires-python`` +# and the version guard in ``keychain/__init__.py``. Bump all three +# together when the floor moves. +_FLOOR = (3, 9) + + +def _find_newer_python() -> str | None: + """Return the path to the newest ``python3.NN >= floor`` on PATH. + + Returns ``None`` if no suitable interpreter is found, in which case + the caller falls through to the version-floor guard in + ``keychain/__init__.py`` and exits with a friendly error. + """ + # Avoid importing shutil here -- it is ~150 lines of stdlib that we + # do not need on the fast path. os.environ + os.path is enough. + seen: set[str] = set() + best: tuple[tuple[int, int], str] | None = None + path_sep = os.pathsep + is_win = os.name == "nt" + exe_suffix = ".exe" if is_win else "" + + for directory in os.environ.get("PATH", "").split(path_sep): + if not directory or directory in seen: + continue + seen.add(directory) + try: + entries = os.listdir(directory) + except OSError: + continue + for entry in entries: + # Match python3.NN[.exe], reject python3.NN-config and friends. + stem = entry[: -len(exe_suffix)] if exe_suffix and entry.endswith(exe_suffix) else entry + if not stem.startswith("python3."): + continue + tail = stem[len("python3.") :] + if not tail.isdigit(): + continue + minor = int(tail) + version = (3, minor) + if version < _FLOOR: + continue + full = os.path.join(directory, entry) + if not (os.path.isfile(full) and os.access(full, os.X_OK)): + continue + if best is None or version > best[0]: + best = (version, full) + return best[1] if best else None + + +def _maybe_reexec() -> None: + """If the current interpreter is below the floor, exec into a newer one.""" + if sys.version_info[:2] >= _FLOOR: + return + newer = _find_newer_python() + if newer is None: + # Let keychain/__init__.py's import-time guard print the friendly + # remediation message and exit 2. + return + # Resolve to a real path so the loop-prevention check below catches + # the case where ``newer`` is a symlink to ``sys.executable``. + try: + if os.path.realpath(newer) == os.path.realpath(sys.executable): + return + except OSError: + return + # os.execv replaces this process; PID, signals, env, stdio, exit code + # all flow through cleanly. + os.execv(newer, [newer, *sys.argv]) + + +_maybe_reexec() + +from keychain.main import main # noqa: E402 -- must follow the bootstrap + +if __name__ == "__main__": + main() diff --git a/scripts/release-common.sh b/scripts/release-common.sh deleted file mode 100755 index 592e81c..0000000 --- a/scripts/release-common.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/sh -# Common helper functions for release automation. -# Requires: curl, jq (jq optional for nicer parsing; if absent we try raw parsing.) - -set -eu - -api() { - # api [datafile] - method=$1; shift - path=$1; shift - url="https://api.github.com/repos/${GITHUB_REPOSITORY}${path}" - if [ $# -gt 0 ]; then - datafile=$1; shift - curl -fsSL -X "$method" \ - -H "Authorization: Bearer ${GITHUB_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - --data "@${datafile}" \ - "$url" - else - curl -fsSL -X "$method" \ - -H "Authorization: Bearer ${GITHUB_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - "$url" - fi -} - -redact() { sed -E 's/[A-Za-z0-9_]{20,}/[REDACTED]/g'; } - -need() { command -v "$1" >/dev/null 2>&1 || { echo "Missing required tool: $1" >&2; exit 1; }; } - -extract_notes() { - ver=$1 - awk -v ver="$ver" 'BEGIN{printed=0} /^## keychain "ver" /{printed=1;print;next} /^## keychain / && printed {exit} printed {print}' ChangeLog.md -} - -fail() { echo "Error: $*" >&2; exit 1; } - -# Validate environment -[ -n "${GITHUB_TOKEN:-}" ] || fail "GITHUB_TOKEN not set" -[ -n "${GITHUB_REPOSITORY:-}" ] || fail "GITHUB_REPOSITORY not set (e.g. danielrobbins/keychain)" -need curl diff --git a/scripts/release-create.sh b/scripts/release-create.sh deleted file mode 100755 index a4f8f26..0000000 --- a/scripts/release-create.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/sh -# Create a new GitHub release (fails if it exists) and upload assets. -set -eu -VER=${1:?usage: release-create.sh } -GITHUB_REPOSITORY=${GITHUB_REPOSITORY:-danielrobbins/keychain} -. "$(dirname "$0")/release-common.sh" - -[ "$(cat VERSION)" = "$VER" ] || fail "VERSION file mismatch ($(cat VERSION) != $VER)" - -notes_file=$(mktemp) -./scripts/release-notes.sh "$VER" "$notes_file" - -# Artifact path vars (provided by orchestrator if using CI artifacts) -ASSET_KEYCHAIN=${KEYCHAIN_ASSET_KEYCHAIN:-keychain} -ASSET_MAN=${KEYCHAIN_ASSET_MAN:-keychain.1} -ASSET_TARBALL=${KEYCHAIN_ASSET_TARBALL:-dist/keychain-$VER.tar.gz} - -echo "Creating release $VER" -json=$(mktemp) -cat >"$json" </dev/null || fail "Failed to create release (maybe it already exists?)" - -echo "Uploading assets..." - -for f in "$ASSET_TARBALL" "$ASSET_KEYCHAIN" "$ASSET_MAN"; do - [ -f "$f" ] || fail "Missing asset file $f" - # Determine publish name (basename should remain canonical filenames) - case $(basename "$f") in - "keychain-$VER.tar.gz") pname="keychain-$VER.tar.gz";; - keychain) pname="keychain";; - keychain.1) pname="keychain.1";; - *) # If path is different (e.g., CI dir), map by type heuristics - if echo "$f" | grep -q "keychain-$VER.tar.gz"; then pname="keychain-$VER.tar.gz"; fi - if echo "$f" | grep -q "/keychain$"; then pname="keychain"; fi - if echo "$f" | grep -q "/keychain.1$"; then pname="keychain.1"; fi - [ -n "${pname:-}" ] || fail "Could not determine asset publish name for $f"; - ;; - esac - curl -sS -X POST \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "Content-Type: application/octet-stream" \ - --data-binary @"$f" \ - "https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/$VER | jq '.id')/assets?name=$pname" >/dev/null - echo " uploaded $pname (from $f)" -done - -echo "Release $VER created successfully." diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh deleted file mode 100755 index 3371571..0000000 --- a/scripts/release-notes.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/sh -# Generate release notes body (ChangeLog excerpt + provenance table). -# Usage: release-notes.sh -# Respects KEYCHAIN_ASSET_* path variables if set (for CI artifact selection). -set -eu -VER=${1:?usage: release-notes.sh } -OUT=${2:?usage: release-notes.sh } - -[ -f ChangeLog.md ] || { echo "ChangeLog.md not found" >&2; exit 1; } -[ "$(cat VERSION)" = "$VER" ] || { echo "VERSION mismatch ($(cat VERSION) != $VER)" >&2; exit 1; } - -awk -v ver="$VER" '/^## keychain '"$VER"' /{f=1;print;next} /^## keychain / && f && $0 !~ ver {exit} f' ChangeLog.md > "$OUT" -[ -s "$OUT" ] || { echo "Failed to extract section for $VER" >&2; exit 1; } - -ASSET_KEYCHAIN=${KEYCHAIN_ASSET_KEYCHAIN:-keychain} -ASSET_MAN=${KEYCHAIN_ASSET_MAN:-keychain.1} - -if [ -f "$ASSET_KEYCHAIN" ] && [ -f "$ASSET_MAN" ]; then - k_sha256=$(sha256sum "$ASSET_KEYCHAIN" | awk '{print $1}') - man_sha256=$(sha256sum "$ASSET_MAN" | awk '{print $1}') - commit_sha1=$(git rev-list -n1 "$VER" 2>/dev/null || true) - { - echo - echo '---' - echo - echo '### Build Provenance' - echo - echo '| Artifact | SHA256 |' - echo '|----------|--------|' - echo "| keychain | $k_sha256 |" - echo "| keychain.1 | $man_sha256 |" - echo - echo "Tag commit SHA1: \`$commit_sha1\`" - } >> "$OUT" -fi - -exit 0 diff --git a/scripts/release-orchestrate.sh b/scripts/release-orchestrate.sh deleted file mode 100755 index 028f131..0000000 --- a/scripts/release-orchestrate.sh +++ /dev/null @@ -1,233 +0,0 @@ -#!/bin/bash -# Orchestrated release creation/refresh with: -# 1. Local build presence check (already performed via Makefile prereqs) -# 2. CI artifact fetch (mandatory) -# 3. Digest comparison (local vs CI artifacts) with normalization rules -# 4. Selection of artifact SOURCE PATHS (never mutating local originals): -# * If all artifacts match (allowing normalized equality) AND no override -> USE CI PATHS -# * If KEYCHAIN_FORCE_LOCAL=1 -> USE LOCAL PATHS (even if mismatches) -# * Otherwise any real mismatch aborts -# 5. Display extracted release notes for confirmation -# 6. Create or refresh release using chosen artifact paths -# Usage: release-orchestrate.sh create|refresh -set -eu - -MODE=${1:?usage: release-orchestrate.sh create|refresh } -VER=${2:?usage: release-orchestrate.sh create|refresh } -REPO=${GITHUB_REPOSITORY:-danielrobbins/keychain} - -[ "$(cat VERSION)" = "$VER" ] || { echo "VERSION file mismatch ($(cat VERSION)) != $VER" >&2; exit 1; } -[ -n "${GITHUB_TOKEN:-}" ] || { echo "GITHUB_TOKEN not set" >&2; exit 1; } - -# Soft fail early if attempting to create a release that already exists. -if [ "$MODE" = create ]; then - existing_json=$(curl -fsS -H "Authorization: Bearer ${GITHUB_TOKEN}" -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/${REPO}/releases/tags/$VER" || true) - if printf '%s' "$existing_json" | grep -q '"id"'; then - echo "Release $VER already exists on GitHub." >&2 - echo >&2 - echo "Next steps:" >&2 - echo " - To update its assets and regenerate notes: make release-refresh" >&2 - echo " - To publish a new release: increment VERSION, update ChangeLog.md, retag, then run make release" >&2 - echo >&2 - exit 1 - fi -fi - -# 1. Ensure local assets exist -for f in dist/keychain-$VER.tar.gz keychain keychain.1; do - [ -f "$f" ] || { echo "Missing local asset: $f" >&2; exit 1; } -done - -# 2. Fetch CI artifacts (MANDATORY) -CI_DIR=".ci-artifacts-$VER" -rm -rf "$CI_DIR" -echo "Fetching CI artifacts for $VER (mandatory step)..." >&2 -if ! ./scripts/fetch-ci-artifacts.sh "$VER" "$CI_DIR"; then - echo "ERROR: Unable to retrieve CI artifacts for $VER. Release aborted." >&2 - echo "Hint: Ensure the GitHub Actions 'release' workflow for tag $VER has completed successfully." >&2 - echo " Re-run 'make release' once artifacts are available." >&2 - exit 1 -fi -echo "CI artifacts retrieved." >&2 - -calc_sha256() { sha256sum "$1" | awk '{print $1}'; } - -diff_flag=0 -echo "Digest comparison (normalized where applicable):" - -compare_tar_content() { - local local_tar=$1 ci_tar=$2 - local tmp_local tmp_ci - tmp_local=$(mktemp -d) - tmp_ci=$(mktemp -d) - # Extract quietly - tar xzf "$local_tar" -C "$tmp_local" 2>/dev/null || return 2 - tar xzf "$ci_tar" -C "$tmp_ci" 2>/dev/null || return 2 - # Determine root (expect exactly one directory named keychain-$VER) - local root="keychain-$VER" - if [ ! -d "$tmp_local/$root" ] || [ ! -d "$tmp_ci/$root" ]; then - echo " keychain-$VER.tar.gz: unexpected directory layout inside tar" >&2 - return 3 - fi - # List files (regular only) relative to root - local lf cf - lf=$( (cd "$tmp_local/$root" && find . -type f -print | LC_ALL=C sort) ) - cf=$( (cd "$tmp_ci/$root" && find . -type f -print | LC_ALL=C sort) ) - if [ "$lf" != "$cf" ]; then - echo " keychain-$VER.tar.gz: file list differs" >&2 - return 4 - fi - # Hash each file - local mismatch=0 - while IFS= read -r rel; do - [ -z "$rel" ] && continue - local h1 h2 - # For keychain.1 apply normalization (skip first line) before comparing to avoid Pod::Man header diffs. - if [ "$(basename "$rel")" = "keychain.1" ]; then - h1=$(tail -n +2 "$tmp_local/$root/$rel" | sha256sum | awk '{print $1}') - h2=$(tail -n +2 "$tmp_ci/$root/$rel" | sha256sum | awk '{print $1}') - if [ "$h1" != "$h2" ]; then - echo " keychain-$VER.tar.gz: content mismatch in $rel (beyond header)" >&2 - echo "--- LOCAL: $rel" >&2 - echo "+++ CI: $rel" >&2 - diff -u <(tail -n +2 "$tmp_local/$root/$rel") <(tail -n +2 "$tmp_ci/$root/$rel") | head -20 >&2 - mismatch=1 - fi - else - h1=$(sha256sum "$tmp_local/$root/$rel" | awk '{print $1}') - h2=$(sha256sum "$tmp_ci/$root/$rel" | awk '{print $1}') - if [ "$h1" != "$h2" ]; then - echo " keychain-$VER.tar.gz: content mismatch in $rel" >&2 - echo "--- LOCAL: $rel" >&2 - echo "+++ CI: $rel" >&2 - diff -u "$tmp_local/$root/$rel" "$tmp_ci/$root/$rel" | head -20 >&2 - mismatch=1 - fi - fi - done </dev/null 2>&1; then - printf ' %-20s (normalized match ignoring Pod::Man header)\n' "$basename_artifact" - else - printf ' %-20s LOCAL %s != CI %s *DIFF* (content mismatch beyond header)\n' "$basename_artifact" "$L" "$R" - diff_flag=1 - fi - fi - ;; - "keychain-$VER.tar.gz") - if compare_tar_content "$artifact" "$ci_artifact_path"; then - # If tar blob hash matches display it; else note normalized match. - L=$(calc_sha256 "$artifact"); R=$(calc_sha256 "$ci_artifact_path") - if [ "$L" = "$R" ]; then - printf ' %-20s %s (match)\n' "$basename_artifact" "$L" - else - printf ' %-20s (content match; tar/gzip metadata differ)\n' "$basename_artifact" - fi - else - printf ' %-20s *CONTENT DIFF* (see above messages)\n' "$basename_artifact" - diff_flag=1 - fi - ;; - esac -done - -if [ $diff_flag -ne 0 ]; then - echo - echo "Artifact mismatch detected between LOCAL build and CI (Debian) build." >&2 - echo "Release aborted (provenance mismatch) unless KEYCHAIN_FORCE_LOCAL=1 is set." >&2 - echo - echo "Copy/paste diff commands:" >&2 - echo " VER=$VER; CI_DIR=$CI_DIR" >&2 - echo " diff -u keychain \"$CI_DIR/keychain\"" >&2 - echo " diff -u keychain.1 \"$CI_DIR/keychain.1\"" >&2 - echo " diff -u <(tar tzf dist/keychain-$VER.tar.gz | sort) <(tar tzf $CI_DIR/dist/keychain-$VER.tar.gz | sort)" >&2 - echo " mkdir -p /tmp/kc-local /tmp/kc-ci && tar xzf dist/keychain-$VER.tar.gz -C /tmp/kc-local && tar xzf $CI_DIR/dist/keychain-$VER.tar.gz -C /tmp/kc-ci && diff -ru /tmp/kc-local/keychain-$VER /tmp/kc-ci/keychain-$VER" >&2 - echo - if [ "${KEYCHAIN_FORCE_LOCAL:-}" = 1 ]; then - echo "KEYCHAIN_FORCE_LOCAL=1 set: proceeding using LOCAL artifacts despite mismatches." >&2 - else - exit 1 - fi -fi - -# Decide which artifact paths to publish (never overwrite local originals) -if [ "${KEYCHAIN_FORCE_LOCAL:-}" = 1 ]; then - KEYCHAIN_ASSET_KEYCHAIN="keychain" - KEYCHAIN_ASSET_MAN="keychain.1" - KEYCHAIN_ASSET_TARBALL="dist/keychain-$VER.tar.gz" - echo "Source selection: USING LOCAL artifacts (override)." >&2 -else - # All artifacts matched (raw or normalized) -> use CI versions - KEYCHAIN_ASSET_KEYCHAIN="$CI_DIR/keychain" - KEYCHAIN_ASSET_MAN="$CI_DIR/keychain.1" - KEYCHAIN_ASSET_TARBALL="$CI_DIR/dist/keychain-$VER.tar.gz" - echo "Source selection: USING CI artifacts (canonical)." >&2 -fi -export KEYCHAIN_ASSET_KEYCHAIN KEYCHAIN_ASSET_MAN KEYCHAIN_ASSET_TARBALL - -# 3. Generate full release notes (ChangeLog excerpt + provenance) for preview -NOTES_FILE=$(mktemp) -./scripts/release-notes.sh "$VER" "$NOTES_FILE" || { echo "Failed to generate release notes preview" >&2; exit 1; } - -echo -echo "================ Release Notes Preview (generated) ======================" -sed 's/^/| /' "$NOTES_FILE" -echo "=========================================================================" - -printf 'Continue with %s of %s? (Y/N): ' "$MODE" "$VER" -read -r ans < /dev/tty || ans=N -case "$ans" in - Y|y) echo "Continuing...";; - *) echo "Aborted by user."; exit 1;; - esac - -# 4. Publish / refresh -if [ "$MODE" = create ]; then - ./scripts/release-create.sh "$VER" -else - ./scripts/release-refresh.sh "$VER" -fi - -echo "Done." diff --git a/scripts/release-refresh.sh b/scripts/release-refresh.sh deleted file mode 100755 index d947726..0000000 --- a/scripts/release-refresh.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/sh -# Refresh (update) assets of an existing release. If the release does not exist, fail. -set -eu -VER=${1:?usage: release-refresh.sh } -GITHUB_REPOSITORY=${GITHUB_REPOSITORY:-danielrobbins/keychain} -. "$(dirname "$0")/release-common.sh" - -[ "$(cat VERSION)" = "$VER" ] || fail "VERSION file mismatch ($(cat VERSION)" != "$VER)" - -rel_json=$(curl -fsSL -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/$VER || true) -[ -n "$rel_json" ] || fail "Release for tag $VER not found" -rel_id=$(printf '%s' "$rel_json" | jq '.id') -[ "$rel_id" != "null" ] || fail "Could not determine release id" - -echo "Deleting existing assets..." -printf '%s' "$rel_json" | jq -r '.assets[].id' | while read -r aid; do - [ -n "$aid" ] || continue - curl -fsSL -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/assets/$aid >/dev/null || fail "Failed to delete asset $aid" - echo " deleted asset id $aid" -done - -echo "Uploading replacement assets..." -ASSET_KEYCHAIN=${KEYCHAIN_ASSET_KEYCHAIN:-keychain} -ASSET_MAN=${KEYCHAIN_ASSET_MAN:-keychain.1} -ASSET_TARBALL=${KEYCHAIN_ASSET_TARBALL:-dist/keychain-$VER.tar.gz} - -# (Note: By default we do not modify existing release notes. Set KEYCHAIN_UPDATE_NOTES=1 to rebuild.) - -for f in "$ASSET_TARBALL" "$ASSET_KEYCHAIN" "$ASSET_MAN"; do - [ -f "$f" ] || fail "Missing asset file $f" - case $(basename "$f") in - "keychain-$VER.tar.gz") pname="keychain-$VER.tar.gz";; - keychain) pname="keychain";; - keychain.1) pname="keychain.1";; - *) if echo "$f" | grep -q "keychain-$VER.tar.gz"; then pname="keychain-$VER.tar.gz"; fi - if echo "$f" | grep -q "/keychain$"; then pname="keychain"; fi - if echo "$f" | grep -q "/keychain.1$"; then pname="keychain.1"; fi - [ -n "${pname:-}" ] || fail "Could not determine asset publish name for $f";; - esac - curl -sS -X POST \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "Content-Type: application/octet-stream" \ - --data-binary @"$f" \ - "https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/$rel_id/assets?name=$pname" >/dev/null - echo " uploaded $pname (from $f)" -done - -echo "Regenerating release notes (including provenance) ..." -tmp_notes=$(mktemp) -./scripts/release-notes.sh "$VER" "$tmp_notes" -patch_json=$(mktemp) -printf '{"body": %s}\n' "$(jq -Rs . < "$tmp_notes")" > "$patch_json" -api PATCH /releases/$rel_id "$patch_json" >/dev/null || echo "Warning: failed to PATCH release body" >&2 -rm -f "$tmp_notes" "$patch_json" -echo "Assets and notes refreshed for release $VER." diff --git a/scripts/test-completion.sh b/scripts/test-completion.sh deleted file mode 100644 index 784513c..0000000 --- a/scripts/test-completion.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/bin/bash -# Test script for bash completion functionality -# Tests that completion works correctly across different environments -# Returns 0 on success, 1 on any failure -# Usage: ./scripts/test-completion.sh - -set -u - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -EXIT_CODE=0 - -echo "=== Bash Completion Test ===" -echo - -# Source the completion script -echo "1. Sourcing completion script..." -if ! source "$REPO_DIR/completions/keychain.bash" 2>/dev/null; then - echo " ERROR: Failed to source completion script" - exit 1 -fi -echo " Done" -echo - -# Test if __keychain_command_line_options works -echo "2. Testing __keychain_command_line_options function..." -opts_output=$(__keychain_command_line_options) -if [ -z "$opts_output" ]; then - echo " ERROR: Function returned empty string" - echo " (This usually means 'keychain' is not in PATH or is keychain.sh instead of generated keychain)" - EXIT_CODE=1 -else - echo " SUCCESS: Function returned options" - echo " First 5 options: $(echo "$opts_output" | awk '{for(i=1;i<=5;i++) print $i}' | tr '\n' ' ')" -fi -echo - -# Test the array form -echo "3. Testing options as array..." -# shellcheck disable=SC2207 -opts_array=( $(__keychain_command_line_options) ) -echo " Array has ${#opts_array[@]} elements" -if [ ${#opts_array[@]} -eq 0 ]; then - echo " ERROR: Array is empty" - EXIT_CODE=1 -else - echo " SUCCESS: First 5 elements: ${opts_array[*]:0:5}" -fi -echo - -# Simulate completion for "keychain -" -echo "4. Simulating completion for 'keychain -'..." -COMP_WORDS=(keychain -) -COMP_CWORD=1 -_keychain -echo " COMPREPLY has ${#COMPREPLY[@]} items" -if [ ${#COMPREPLY[@]} -eq 0 ]; then - echo " ERROR: No completions returned" - EXIT_CODE=1 -else - echo " SUCCESS: First 5 completions:" - for i in "${COMPREPLY[@]:0:5}"; do - echo " - $i" - done -fi -echo - -# Check which keychain is being found -echo "5. Checking keychain executable location..." -if command -v keychain >/dev/null 2>&1; then - echo " Found: $(command -v keychain)" -elif [ -x "$REPO_DIR/keychain" ]; then - echo " Found: $REPO_DIR/keychain (local)" -elif [ -x "$REPO_DIR/keychain.sh" ]; then - echo " WARNING: Only found keychain.sh (needs 'make' to generate full keychain)" -else - echo " WARNING: keychain not found in PATH or local directory" -fi -echo - -# Test calling keychain --help -echo "6. Testing 'keychain --help' output..." -if keychain --help >/dev/null 2>&1; then - echo " SUCCESS: keychain --help works" - first_line=$(keychain --help 2>&1 | head -1) - if [ -n "$first_line" ]; then - echo " First line: $first_line" - fi -else - echo " ERROR: keychain --help failed" - EXIT_CODE=1 -fi -echo - -echo "=== Test Complete ===" -if [ $EXIT_CODE -eq 0 ]; then - echo "Result: SUCCESS - All tests passed" -else - echo "Result: FAILURE - Some tests failed" -fi - -exit $EXIT_CODE diff --git a/scripts/test-space-home.sh b/scripts/test-space-home.sh deleted file mode 100755 index 9b8bc34..0000000 --- a/scripts/test-space-home.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/bin/sh -# Minimal harness to simulate a HOME with spaces and exercise keychain behaviors -# Usage: scripts/test-space-home.sh [optional-extra-key] -# It creates a temp directory with a space, sets HOME, prepares dummy ssh keys, -# runs keychain, and reports whether pidfiles and ssh-add succeeded. -# Returns 0 on success, non-zero on failure. - -set -eu -VER=${1:-test} -EXTRA_KEY=${2:-} -FAILED=0 - -WORKBASE=$(mktemp -d) -SPACE_HOME="${WORKBASE}/User Space" -mkdir -p "${SPACE_HOME}/.ssh" "${SPACE_HOME}/bin" -cp keychain.sh "${SPACE_HOME}/bin/keychain" 2>/dev/null || cp ./keychain.sh "${SPACE_HOME}/bin/keychain" -chmod 700 "${SPACE_HOME}/.ssh" - -# Generate minimal throwaway key if ssh-keygen available -if command -v ssh-keygen >/dev/null 2>&1; then - ssh-keygen -t ed25519 -N '' -f "${SPACE_HOME}/.ssh/id_ed25519" >/dev/null 2>&1 || true -fi - -# Optional additional key copy -if [ -n "$EXTRA_KEY" ] && [ -f "$EXTRA_KEY" ]; then - cp "$EXTRA_KEY" "${SPACE_HOME}/.ssh/" 2>/dev/null || true -fi - -export HOME="${SPACE_HOME}" -export PATH="${SPACE_HOME}/bin:${PATH}" - -printf '\n[info] Simulated HOME with space: %s\n' "$HOME" -printf '[info] Running keychain (version stub %s) ...\n' "$VER" - -# Basic invocation: adopt or spawn then load one key -if output=$("${SPACE_HOME}/bin/keychain" -q --eval id_ed25519 2>&1); then - echo "$output" | sed 's/^/[keychain] /' -else - echo "$output" | sed 's/^/[keychain-err] /' >&2 - echo "ERROR: keychain command failed" >&2 - FAILED=1 -fi - -# Inspect pidfiles -PIDBASE="${HOME}/.keychain/$(uname -n 2>/dev/null || echo host)" -for suffix in -sh -csh -fish; do - f="${PIDBASE}${suffix}" - if [ -f "$f" ]; then - echo "[pidfile] Found $f"; head -n 2 "$f" | sed 's/^/[pidfile] /' - else - echo "ERROR: pidfile MISSING: $f" >&2 - FAILED=1 - fi -done - -# Verify that SSH_AUTH_SOCK has not been truncated -if eval "$(cat "${PIDBASE}-sh" 2>/dev/null || echo true)"; then - case "$SSH_AUTH_SOCK" in - *" "*) - echo "ERROR: SSH_AUTH_SOCK contains a space unexpectedly: $SSH_AUTH_SOCK" >&2 - FAILED=1 - ;; - *) - if [ -S "$SSH_AUTH_SOCK" ]; then - echo "[ok] SSH_AUTH_SOCK socket exists" - else - echo "ERROR: SSH_AUTH_SOCK path not a socket: $SSH_AUTH_SOCK" >&2 - FAILED=1 - fi - ;; - esac -else - echo "ERROR: Unable to eval sh pidfile" >&2 - FAILED=1 -fi - -# Attempt ssh-add -l to confirm agent access -if ssh-add -l >/dev/null 2>&1; then - echo "[ok] ssh-add -l succeeded" -else - echo "ERROR: ssh-add -l failed" >&2 - FAILED=1 -fi - -# Cleanup summary (keep workspace for inspection) - comment out to retain -# rm -rf "$WORKBASE" - -if [ $FAILED -eq 0 ]; then - echo "[done] All tests PASSED" - exit 0 -else - echo "[done] Tests FAILED - see errors above" >&2 - exit 1 -fi diff --git a/src/keychain/__init__.py b/src/keychain/__init__.py new file mode 100644 index 0000000..307f09e --- /dev/null +++ b/src/keychain/__init__.py @@ -0,0 +1,67 @@ +# keychain - Manager for ssh-agent, gpg-agent and private keys +# Copyright 2026 Daniel Robbins, BreezyOps +# SPDX-License-Identifier: GPL-3.0-only +"""Python-native keychain implementation. + +The package exposes :func:`keychain.main.main` as its entry point. The +``keychain`` console-script (declared in ``pyproject.toml``) and ``python -m +keychain`` both invoke that single coordinator. +""" + +from __future__ import annotations + +import sys + +# Hard floor: Python 3.9. Enforced before any other import so the message is +# the first thing the user sees regardless of which entry point they hit. The +# guard intentionally fires for runtimes BELOW the supported floor; ruff's +# UP036 wants this removed because the package metadata also enforces 3.9, but +# users running ``./keychain.pyz`` under an older system python never hit pip. +if sys.version_info < (3, 9): # noqa: UP036 + sys.stderr.write( + f"keychain requires Python 3.9 or newer " + f"(you are running {sys.version.split()[0]}).\n" + " RHEL 8 / Rocky 8 : dnf module install python39\n" + " Ubuntu 20.04 : apt install python3.9 " + "(or use the deadsnakes PPA)\n" + "Then re-run keychain under the newer interpreter, e.g.:\n" + " python3.9 keychain.pyz ...\n" + ) + sys.exit(2) + +from importlib.metadata import PackageNotFoundError, version # noqa: E402 +from importlib.resources import files # noqa: E402 +from pathlib import Path # noqa: E402 + +__all__ = ["__version__"] + + +def _resolve_version() -> str: + # 1. Prefer a VERSION file co-located with the package. + # + # This is what gets bundled into ``keychain.pyz`` (see the Makefile), + # so the zipapp always reports the version of the source tree it was + # built from -- never a stale value from an unrelated ``keychain`` + # package that happens to be installed system-wide. + try: + text = files(__package__ or "keychain").joinpath("VERSION").read_text(encoding="utf-8").strip() + if text: + return text + except (FileNotFoundError, ModuleNotFoundError, OSError): + pass + # 2. Fall back to installed-package metadata (pip install ., wheels, ...). + try: + return version("keychain") + except PackageNotFoundError: + pass + # 3. Fall back to the VERSION file at the source-tree root (running + # uninstalled from a checkout, e.g. ``python -m keychain``). + here = Path(__file__).resolve() + for parent in (here.parent, *here.parents): + candidate = parent / "VERSION" + if candidate.is_file(): + return candidate.read_text(encoding="utf-8").strip() or "0.0.0" + return "0.0.0" + + +__version__ = _resolve_version() diff --git a/src/keychain/__main__.py b/src/keychain/__main__.py new file mode 100644 index 0000000..53aae83 --- /dev/null +++ b/src/keychain/__main__.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""``python -m keychain`` entry point.""" + +from .main import main + +if __name__ == "__main__": + main() diff --git a/src/keychain/agents.py b/src/keychain/agents.py new file mode 100644 index 0000000..08fec01 --- /dev/null +++ b/src/keychain/agents.py @@ -0,0 +1,770 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""ssh-agent and gpg-agent: detection, lifecycle, key listing and loading.""" + +from __future__ import annotations + +import contextlib +import os +import re +import shlex +import signal +import stat +import subprocess +import sys +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +from keychain.state import KeychainState + +from .env import SshAgentRef +from .util import KeychainError, Output, current_uid, get_tty, pid_alive, run + +# --------------------------------------------------------------------------- +# Regex patterns +# --------------------------------------------------------------------------- + +_RE_FP_SHA256 = re.compile(r"^[A-Z0-9]+:[A-Za-z0-9+/=]+$") +_RE_FP_MD5 = re.compile(r"^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2})+$") + + +@dataclass(frozen=True) +class SocketValidation: + path: str + valid: bool + reason: str = "" + severity: str = "" + + +# --------------------------------------------------------------------------- +# Implementation detection +# --------------------------------------------------------------------------- + + +def detect_ssh() -> bool: + """Return True when ``ssh -V`` identifies OpenSSH.""" + try: + r = run(["ssh", "-V"]) + except (FileNotFoundError, OSError): + return False + return "OpenSSH" in (r.stdout + r.stderr) + + +def gpg_has_ssh_support() -> bool: + try: + r = run(["gpg-agent", "--help"]) + except (FileNotFoundError, OSError): + return False + return "enable-ssh-support" in (r.stdout + r.stderr) + + +def choose_gpg_prog(force_gpg2: bool, env: Mapping[str, str] | None = None) -> str: + """Decide which GnuPG binary to invoke.""" + env = os.environ if env is None else env + bin_override = env.get("GPG_BIN") + if bin_override: + return bin_override + return "gpg2" if force_gpg2 else "gpg" + + +# --------------------------------------------------------------------------- +# gpg-agent socket queries +# --------------------------------------------------------------------------- + + +def _gpg_query(name: str, env: Mapping[str, str] | None = None) -> str: + try: + r = run( + ["gpg-connect-agent", "--no-autostart"], + env=dict(env) if env is not None else None, + input_=f"GETINFO {name}\n", + timeout=5, + ) + except (FileNotFoundError, OSError, subprocess.TimeoutExpired): + return "" + for line in r.stdout.splitlines(): + if line.startswith("D "): + return line[2:].strip() + return "" + + +def gpg_ssh_socket(env: Mapping[str, str] | None = None) -> str: + return _gpg_query("ssh_socket_name", env) + + +def gpg_main_socket(env: Mapping[str, str] | None = None) -> str: + return _gpg_query("socket_name", env) + + +def gpg_user_homedirs(env: Mapping[str, str] | None = None, uid: int | None = None) -> list[Path]: + """Directories whose ``S.gpg-agent`` we consider "ours". + + Includes ``$GNUPGHOME``, ``$HOME/.gnupg``, and the XDG-style + ``/run/user//gnupg`` (used by GnuPG 2.1+ on Linux). Anything + outside this set -- e.g. a package-manager's ``--homedir /var/tmp/zypp.X`` + socket -- is treated as someone else's agent. + """ + env = os.environ if env is None else env + if uid is None: + uid = current_uid() + homes: list[Path] = [] + gh = env.get("GNUPGHOME") + if gh: + homes.append(Path(gh)) + home = env.get("HOME") or os.path.expanduser("~") + if home: + homes.append(Path(home) / ".gnupg") + if uid is not None: + homes.append(Path(f"/run/user/{uid}/gnupg")) + # Resolve to absolute paths; ignore homedirs that don't currently exist + # (Path.resolve(strict=False) is the default on 3.9+). + return [h.resolve() for h in homes] + + +def gpg_socket_is_primary(sock: str, env: Mapping[str, str] | None = None, uid: int | None = None) -> bool: + """True if *sock* lives under one of :func:`gpg_user_homedirs`. + + Used to refuse to adopt gpg-agents started by other tools (package + managers, build sandboxes) that happen to be running as our uid. + """ + if not sock: + return False + try: + sock_dir = Path(sock).resolve().parent + except (OSError, ValueError): + return False + return any(sock_dir == h for h in gpg_user_homedirs(env, uid)) + + +def validate_ssh_socket(sock: str) -> SocketValidation: + """Validate that *sock* is a UNIX socket owned by the current user. + + The owner check is the second line of defence after pidfile perms: + if ``SSH_AUTH_SOCK`` was poisoned (compromised env, attacker-writable + pidfile, ``/tmp`` race), we must not load keys into a foreign agent. + On platforms without ``os.getuid`` (e.g. native Windows, where keychain + refuses to operate anyway) the owner check is skipped. + """ + if not sock: + return SocketValidation(sock, False, "empty") + try: + if os.path.islink(sock): + return SocketValidation(sock, False, "symlink", "err") + st = os.stat(sock) + except FileNotFoundError: + return SocketValidation(sock, False, "missing") + except OSError: + return SocketValidation(sock, False, "stat-error", "warn") + if not stat.S_ISSOCK(st.st_mode): + return SocketValidation(sock, False, "not-socket", "warn") + uid = current_uid() + if uid is not None and st.st_uid != uid: + return SocketValidation(sock, False, "foreign-owner", "err") + return SocketValidation(sock, True) + + +def ssh_socket_valid(sock: str) -> bool: + """True if *sock* is a UNIX socket owned by the current user.""" + return validate_ssh_socket(sock).valid + + +# --------------------------------------------------------------------------- +# Fingerprints +# --------------------------------------------------------------------------- + + +def extract_fingerprints(text: str) -> list[str]: + fps: list[str] = [] + for line in text.splitlines(): + parts = line.split() + if len(parts) >= 2 and (_RE_FP_SHA256.match(parts[1]) or _RE_FP_MD5.match(parts[1])): + fps.append(parts[1]) + elif len(parts) >= 3 and _RE_FP_MD5.match(parts[2]): + fps.append(parts[2]) + return fps + + +def ssh_l(env: Mapping[str, str]) -> tuple[list[str], int]: + """Run ``ssh-add -l``; return (fingerprints, retcode).""" + try: + r = run(["ssh-add", "-l"], env=dict(env)) + except (FileNotFoundError, OSError): + return [], 2 + if r.returncode == 0: + return extract_fingerprints(r.stdout.strip()), 0 + rc = 2 if (r.returncode == 1 and "open a connection" in r.stdout) else r.returncode + return [], rc + + +def ssh_fingerprint(filename: str, out: Output) -> str | None: + """Return the fingerprint of private key *filename*, or None on failure.""" + fp = Path(filename) + resolved = fp.resolve() if fp.is_symlink() else fp + pub = Path(f"{resolved!s}.pub") + if not pub.is_file(): + alt = resolved.with_suffix(".pub") + if alt.is_file(): + pub = alt + else: + out.note(f"Cannot find separate public key for {filename}.") + pub = resolved + try: + r = run(["ssh-keygen", "-l", "-f", str(pub)]) + except (FileNotFoundError, OSError): + return None + if r.returncode != 0: + return None + fps = extract_fingerprints(r.stdout) + return fps[0] if fps else None + + +# --------------------------------------------------------------------------- +# Process scan (free function: heavily test-patched and platform-delegated) +# --------------------------------------------------------------------------- + + +def findpids(prog: str) -> list[int]: + """PIDs of running ``prog``-agent processes owned by the current user. + + Process enumeration is delegated to the resolved + :class:`keychain.runtime.Platform`, which knows how to list processes + on the host (or refuses to do so on unsupported platforms — but the + CLI aborts long before reaching this code path on those). + """ + from .runtime import platform + + # ``[a]gen`` avoids matching some test helper command lines verbatim. + pattern = re.compile(rf"{re.escape(prog)}-[a]gen", re.IGNORECASE) + uid = os.getuid() if hasattr(os, "getuid") else None + return platform.detect().process_list(pattern, uid) + + +# =========================================================================== +# Agent classes -- the OOP face used by the CLI. +# +# Stateful agent operations live as methods that pull configuration +# (gpg_prog/paths/user/args) from the bound KeychainState. This eliminates +# the random ``(env, out)``-style argument tuples that used to thread through +# every helper. +# +# The free functions above remain free because they are *host-system* +# probes (not configuration-dependent): test suites mock them at the module +# boundary to simulate alternate hosts. +# =========================================================================== + + +class SshAgent: + """ssh-agent operations bound to a :class:`~keychain.state.KeychainState`. + + ``self.env`` holds the live SSH_AUTH_SOCK / SSH_AGENT_PID pair that is + read from the pidfile or inherited from the user's shell, mutated by + :meth:`start`, and propagated to every child ``ssh-add`` invocation. + """ + + def __init__(self, state: KeychainState, out: Output) -> None: + self.keychain_state = state + self.out = out + self.env: SshAgentRef = state.find_active_agent_env + # Set by :meth:`start`; consumed by :meth:`envcheck` so per-call + # plumbing of these flags is not needed. + self._allow_gpg = False + self._allow_forwarded = False + + # ---- list / fingerprint probes ----------------------------------- + + def list_loaded(self) -> tuple[list[str], int]: + """Run ``ssh-add -l``; return ``(fingerprints, retcode)``.""" + return ssh_l(self.env.as_dict()) + + def fingerprint(self, filename: str) -> str | None: + """Return the fingerprint of private key *filename*, or None on failure.""" + return ssh_fingerprint(filename, self.out) + + def list_missing(self, ssh_keys: list[str]) -> list[str]: + have_set = set(self.list_loaded()[0]) + missing: list[str] = [] + for k in filter(None, ssh_keys): + fp = self.fingerprint(k) + if fp is None: + self.out.warn(f"Unable to extract fingerprint from keyfile {k}.pub, skipping") + continue + if fp in have_set: + self.out.info(f"Known ssh key: {self.out.id(k)}") + else: + missing.append(k) + return missing + + # ---- env validation ---------------------------------------------- + + def envcheck(self, source: str, agent_env: SshAgentRef, quick: bool) -> SshAgentRef | None: + """Validate ``SSH_AUTH_SOCK`` / ``SSH_AGENT_PID`` from *agent_env*.""" + out = self.out + sock = agent_env.sock + pid_str = agent_env.pid + # When the source is an explicit pidfile or inherited shell environment the + # user expects keychain to reuse that agent. Silently falling through to + # spawn a new one (because the socket has been rm'd or the process died) + # is exactly the "why didn't keychain find my agent?" surprise we want to + # avoid -- surface those rejections as notes. Other sources (forwarded + # sockets the user hasn't opted in to, gpg-agent's SSH socket) stay at + # debug to avoid noise on every invocation. + visible = source in ("pidfile", "env") and not quick + + sock_validation = validate_ssh_socket(sock) + if not sock_validation.valid: + if sock: + msg = f"SSH_AUTH_SOCK in {source} points to {sock}; rejected socket ({sock_validation.reason})" + if visible and sock_validation.severity: + out.warn(msg) + else: + (out.note if visible else out.debug)(msg) + return None + + if pid_str: + try: + if not pid_alive(int(pid_str)): + raise ValueError + except ValueError: + msg = ("SSH_AGENT_PID in {} ({}) is not a live process; ignoring it").format(source, pid_str) + (out.note if visible else out.debug)(msg) + pid_str = "" + + if not pid_str: + # No PID -- might be gpg-agent's SSH socket or a forwarded socket. + gsock = gpg_ssh_socket() + if gsock and gsock == sock: + if self._allow_gpg: + if not quick: + out.info(f"Using ssh-agent ({source}): {out.id(gsock)} (GnuPG)") + return SshAgentRef(sock) + out.debug("Ignoring SSH_AUTH_SOCK -- this is the GnuPG-supplied socket") + return None + if self._allow_forwarded: + if not quick: + out.info(f"Using {out.value('forwarded')} ssh-agent: {out.value(sock)}") + return SshAgentRef(sock, forwarded=True) + # No SSH_AGENT_PID, not GnuPG, and forwarding disallowed: could be a + # forwarded socket, a stale socket from a dead session, or some other + # unknown source. We can't tell which, so don't claim. (Issue #181.) + out.debug(f"Ignoring SSH_AUTH_SOCK ({sock}) -- no SSH_AGENT_PID set, source unknown") + return None + + if not quick: + out.info(f"Existing ssh-agent ({source}): {out.id(pid_str)}") + return SshAgentRef(sock, pid_str) + + # ---- lifecycle --------------------------------------------------- + + def _our_pid(self) -> int | None: + return self.env.pid_int + + def start(self, ssh_spawn_gpg: bool, ssh_allow_gpg: bool) -> bool: + """Find or spawn an ssh-agent. + + Returns True if a *quick* start succeeded (an existing agent was + found already populated and no further key-loading is needed). + Persists the resulting env to the pidfile when one was synthesised; + updates ``self.env`` in place. + """ + a = self.keychain_state.args + # Latch run-flag flags so :meth:`envcheck` can pull them from self. + self._allow_gpg = ssh_allow_gpg + self._allow_forwarded = bool(a.get_value("ssh_allow_forwarded")) + paths = self.keychain_state.paths + + # 1. Quick path: trust an existing pidfile if it is both valid AND + # already has keys loaded -- saves a full key reload on repeat invocations. + if bool(a.get_value("quick")): + env = self.keychain_state.pidfile_env + if env: + test_env = self.envcheck("quick", env, quick=True) + if test_env: + saved_env = self.env + self.env = test_env + fps, _ = self.list_loaded() + if fps: + self.out.info("Found existing populated ssh-agent (quick)") + return True + self.env = saved_env + self.out.note("Quick start unsuccessful -- no keys loaded...") + else: + self.out.note("Quick start unsuccessful -- no agent found...") + else: + self.out.note("Quick start unsuccessful -- no agent found...") + + # 2. Normal path. Try existing pidfile. + env = self.keychain_state.pidfile_env + if env: + test_env = self.envcheck("pidfile", env, quick=False) + if test_env: + self.out.debug("pidfile is valid") + self.env = test_env + return False + + # 3. Try inherited environment. + if not bool(a.get_value("no_inherit")): + inh = SshAgentRef.from_env(self.keychain_state.env) + valid_inh = self.envcheck("env", inh, quick=False) if inh else None + if valid_inh: + self.env = valid_inh + if not valid_inh.forwarded: + paths.write(valid_inh, self.out) + self.env = self.keychain_state.pidfile_env + return False + + # 4. Spawn a new agent. + paths.clear() + spawned: SshAgentRef | None + if ssh_spawn_gpg: + spawned = self.keychain_state.gpg.start(ssh_support=True) + else: + self.out.info("Starting ssh-agent...") + cmd = ["ssh-agent", "-s"] + timeout = a.get_value("timeout") + if timeout is not None: + cmd += ["-t", str(timeout * 60)] + ssh_agent_socket = a.get_value("ssh_agent_socket") + if ssh_agent_socket: + cmd += ["-a", ssh_agent_socket] + # User-supplied extra flags (issue #21). + # SECURITY: KEYCHAIN_SSH_AGENT_ARGS is injected by config.py only + # when --allow-env / -E is set. Direct env var access here is + # safe because the gate is enforced at the config layer. + cmd += shlex.split(self.keychain_state.env.get("KEYCHAIN_SSH_AGENT_ARGS", "")) + try: + r = run(cmd) + spawned = SshAgentRef.from_text(r.stdout) if r.returncode == 0 else None + except (FileNotFoundError, OSError): + spawned = None + if spawned: + paths.write(spawned, self.out) + self.env = self.keychain_state.pidfile_env + return False + + def stop(self, which: str) -> None: + out = self.out + out.info("Stopping ssh-agent(s)...") + if which != "all": + pidf_env = self.keychain_state.pidfile_env + if pidf_env: + self.env = pidf_env + pids = findpids("ssh") + if not pids: + out.info("No ssh-agent(s) found running") + elif which == "all": + for p in pids: + with contextlib.suppress(OSError): + os.kill(p, signal.SIGTERM) + out.info(f"All ssh-agents stopped: " f"{out.id(' '.join(map(str, pids)))}") + elif which == "mine": + for p in pids: + with contextlib.suppress(OSError): + os.kill(p, signal.SIGTERM) + out.info( + f"All {out.id(self.keychain_state.user)}'s ssh-agents stopped: " f"{out.id(' '.join(map(str, pids)))}" + ) + else: + our = self._our_pid() + if which == "pidfile" and our: + with contextlib.suppress(OSError): + os.kill(our, signal.SIGTERM) + out.info(f"Keychain ssh-agent stopped: {out.id(our)}") + elif which == "others" and our: + killed: list[str] = [] + for p in pids: + if p == our: + continue + with contextlib.suppress(OSError): + os.kill(p, signal.SIGTERM) + killed.append(str(p)) + out.info(f"Other ssh-agents stopped: " f"{out.id(' '.join(killed))}") + elif which == "others": + killed_pids: list[str] = [] + for p in pids: + with contextlib.suppress(OSError): + os.kill(p, signal.SIGTERM) + killed_pids.append(str(p)) + out.info(f"Other ssh-agents stopped: " f"{out.id(' '.join(killed_pids))}") + else: + out.info("No keychain ssh-agent found running") + if which != "others": + self.keychain_state.paths.clear() + + # ---- key operations ---------------------------------------------- + + def wipe(self) -> None: + try: + r = run(["ssh-add", "-D"], env=self.env.as_dict(), c_locale=False) + except (FileNotFoundError, OSError): + self.out.warn("ssh-add not found") + return + msg = (r.stdout + r.stderr).strip() + (self.out.info if r.returncode == 0 else self.out.warn)(f"ssh-agent: {msg}") + + def remove(self, ssh_keys: list[str]) -> None: + if not ssh_keys: + raise KeychainError("No ssh keys specified to remove.") + for k in ssh_keys: + try: + r = run(["ssh-add", "-d", k], env=self.env.as_dict(), c_locale=False) + except (FileNotFoundError, OSError): + raise KeychainError("ssh-add not found") + if r.returncode == 0: + self.out.info(f"ssh-agent key {k} removed.") + else: + raise KeychainError(f"keychain was unable to remove ssh-agent key {k}. output: {r.stderr}") + + def load(self, missing: list[str]) -> bool: + if not missing: + return True + a = self.keychain_state.args + out = self.out + # Re-validate the agent before loading keys to close the TOCTOU race + # between start() validation and actual key loading. If the agent + # died or was replaced, refuse to load keys into a foreign agent. + test = self.envcheck("pidfile", self.env, quick=True) + if not test: + out.warn("Agent disappeared; refusing to load keys") + return False + out.info(f"Adding {out.value(len(missing))} ssh key(s): " f"{out.value(' '.join(missing))}") + # ssh-add inherits stdio for passphrase prompts, so we cannot use util.run(). + run_env = self.env.overlay() + if bool(a.get_value("no_gui")) or not run_env.get("SSH_ASKPASS") or not run_env.get("DISPLAY"): + run_env.pop("DISPLAY", None) + run_env.pop("SSH_ASKPASS", None) + cmd = ["ssh-add"] + timeout = a.get_value("timeout") + if timeout is not None: + cmd += ["-t", str(timeout * 60)] + if bool(a.get_value("confirm")): + cmd.append("-c") + cmd.extend(missing) + try: + rc = subprocess.run(cmd, env=run_env, check=False).returncode + except (FileNotFoundError, OSError): + out.warn("ssh-add not found") + return False + if rc == 0: + bits = [] + timeout = a.get_value("timeout") + if timeout is not None: + bits.append(f"life={timeout}m") + if bool(a.get_value("confirm")): + bits.append("confirm") + suffix = f" ({','.join(bits)})" if bits else "" + out.info(f"ssh-add: Identities added: {' '.join(missing)}{suffix}") + else: + out.warn(f"ssh-add failed (return code: {rc})") + return rc == 0 + + def passthrough(self, arg: str) -> int: + """Run ``ssh-add `` inheriting stdio (legacy theme `list` fallback).""" + env = self.env.overlay() + try: + return subprocess.run(["ssh-add", arg], env=env, check=False).returncode + except (FileNotFoundError, OSError): + return 127 + + +class GpgAgent: + """gpg-agent operations bound to a :class:`~keychain.state.KeychainState`.""" + + def __init__(self, k, out: Output) -> None: + self.k = k + self.out = out + + # ---- lifecycle --------------------------------------------------- + + def start(self, ssh_support: bool) -> SshAgentRef | None: + """Start (or adopt) gpg-agent. Returns its agent env, or None. + + Adoption is restricted to agents whose socket lives under one of the + user's gpg homedirs (see :func:`gpg_socket_is_primary`). A foreign + gpg-agent owned by the same uid -- e.g. one spawned by a package + manager with ``--homedir /var/tmp/zypp.XXX`` -- is ignored. + """ + out = self.out + sock = gpg_main_socket(self.k.env) + if sock and gpg_socket_is_primary(sock, self.k.env) and ssh_socket_valid(sock): + if not ssh_support: + out.info(f"Using existing gpg-agent: {out.id(sock)}") + return SshAgentRef() + ssh_sock = gpg_ssh_socket(self.k.env) + if ssh_sock and ssh_socket_valid(ssh_sock): + out.info(f"Using existing gpg-agent: {out.id(ssh_sock)} (SSH)") + return SshAgentRef(ssh_sock) + if sock and not gpg_socket_is_primary(sock, self.k.env): + out.debug(f"ignoring non-primary gpg-agent socket: {sock}") + opts = ["--daemon"] + timeout = self.k.args.get_value("timeout") + if timeout is not None: + secs = timeout * 60 + opts += [f"--default-cache-ttl={secs}", f"--max-cache-ttl={secs}"] + if ssh_support: + opts.append("--enable-ssh-support") + # User-supplied extra flags (issue #21). Last so they win on duplicates. + opts += shlex.split(self.k.env.get("KEYCHAIN_GPG_AGENT_ARGS", "")) + out.info("Starting gpg-agent...") + try: + r = run(["gpg-agent", "--sh"] + opts) + except (FileNotFoundError, OSError): + return None + return SshAgentRef.from_text(r.stdout) if r.returncode == 0 else None + + # ---- key operations ---------------------------------------------- + + def wipe(self) -> None: + try: + r = run(["gpg-connect-agent", "--no-autostart"], input_="RELOADAGENT\n", timeout=5) + except (FileNotFoundError, OSError, subprocess.TimeoutExpired): + self.out.info("gpg-agent: Could not contact agent.") + return + if r.stdout.strip() == "OK": + self.out.info("gpg-agent: All identities removed.") + else: + self.out.info( + "gpg-agent: Could not remove identities; possibly not running. (output: {})".format(r.stdout.strip()) + ) + + def list_missing(self, gpg_keys: list[str], mode: str = "--sign") -> list[str]: + out = self.out + missing: list[str] = [] + tty = get_tty() + extra_env = {"GPG_TTY": tty} if tty else {} + for k in filter(None, gpg_keys): + try: + r = run( + [ + self.k.gpg_prog, + "--no-autostart", + "--no-options", + "--use-agent", + "--no-tty", + mode, + "--local-user", + k, + "-o-", + ], + input_="", + env=extra_env, + timeout=10, + ) + if r.returncode == 0: + out.info(f"Known gpg key: {out.id(k)}") + continue + except (FileNotFoundError, OSError, subprocess.TimeoutExpired): + pass + missing.append(k) + return missing + + def load(self, gpg_keys: list[str], mode: str = "--sign") -> bool: + out = self.out + extra_env: dict[str, str] = {} + tty = get_tty() + if tty: + extra_env["GPG_TTY"] = tty + drop_display = bool(self.k.args.get_value("no_gui")) or not self.k.env.get("DISPLAY") + for k in filter(None, gpg_keys): + out.info(f"Adding gpg key: {k}") + # util.run() copies os.environ then layers `env` on top; an empty + # value here doesn't unset, so do the unset on our local copy. + run_env = dict(self.k.env) + run_env.update(extra_env) + if drop_display: + run_env.pop("DISPLAY", None) + try: + r = subprocess.run( + [ + self.k.gpg_prog, + "--no-autostart", + "--no-options", + "--use-agent", + mode, + "--local-user", + k, + "-o-", + ], + input="", + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=run_env, + check=False, + ) + except (FileNotFoundError, OSError): + out.warn(f"{self.k.gpg_prog} not found") + return False + if r.returncode != 0: + err = (r.stdout + r.stderr).strip() + out.warn(f"Error adding gpg key (error code: {r.returncode}; output: {err})") + return False + return True + + +def render_list_table(kstate, out: Output) -> int: + """Render ``ssh-add -l`` as a TYPE/BITS/FINGERPRINT/COMMENT table.""" + if out.theme != "modern": + return kstate.ssh.passthrough("-l") + + from .output.tables import render_table + + try: + result = run(["ssh-add", "-l"], env=kstate.find_active_agent_env.overlay()) + except (FileNotFoundError, OSError): + out.error("ssh-add not found on PATH") + return 127 + if result.returncode != 0: + if result.returncode == 2: + out.note("No agent is currently running.") + return 0 + if result.stderr: + sys.stderr.write(result.stderr) + return result.returncode + + rows: list[list[str]] = [] + for line in result.stdout.splitlines(): + parts = line.strip().split() + if len(parts) < 2: + continue + bits, fingerprint = parts[0], parts[1] + key_type = "" + comment_parts = parts[2:] + if comment_parts and comment_parts[-1].startswith("(") and comment_parts[-1].endswith(")"): + key_type = comment_parts[-1][1:-1] + comment_parts = comment_parts[:-1] + rows.append([key_type, bits, fingerprint, " ".join(comment_parts)]) + if not rows: + out.line("No keys loaded.") + return 0 + + header_style = out.style("heading", "dim") + for line in render_table( + rows, headers=["type", "bits", "fingerprint", "comment"], indent=2, header_style=header_style + ).splitlines(): + print(line) + return 0 + + +def render_list_json(agent_env: SshAgentRef) -> None: + """Emit ``ssh-add -L`` output as a JSON array of key objects.""" + import json + + try: + result = run(["ssh-add", "-L"], env=agent_env.overlay()) + lines = result.stdout.splitlines() if result.returncode == 0 else [] + except (FileNotFoundError, OSError): + lines = [] + + keys = [] + for line in lines: + parts = line.strip().split(None, 2) + if len(parts) >= 2: + keys.append( + { + "type": parts[0], + "key": parts[1], + "comment": parts[2] if len(parts) > 2 else "", + } + ) + print(json.dumps(keys)) diff --git a/src/keychain/doc_texts.py b/src/keychain/doc_texts.py new file mode 100644 index 0000000..f54167e --- /dev/null +++ b/src/keychain/doc_texts.py @@ -0,0 +1,34 @@ +# This source file provides an API for accessing _doc_texts.json efficiently, with +# cached loading and parsing. The primary consumer of this API is the actions.py module, +# as the primary linkage should be from Option() and Action() objects to their corresponding +# doc text. + +from __future__ import annotations + +import json +from functools import cache +from importlib.resources import files + + +class DocText: + @cache # noqa: B019 + def _data(self) -> dict[str, dict[str, dict[str, str]]]: + blob = files("keychain").joinpath("docs").joinpath("_doc_texts.json").read_text(encoding="utf-8") + data = json.loads(blob) + data.setdefault("option", {}).update(data.pop("global", {})) + return data + + def _entry(self, doc_tag: str) -> dict[str, str]: + section, _, key = doc_tag.partition(":") + if not section or not key: + return {} + return self._data().get(section, {}).get(key, {}) + + def short_help(self, doc_tag: str) -> str: + return self._entry(doc_tag).get("short_help", "") + + def description(self, doc_tag: str) -> str: + return self._entry(doc_tag).get("description", "") + + +DOC_TEXT = DocText() diff --git a/src/keychain/docs/__init__.py b/src/keychain/docs/__init__.py new file mode 100644 index 0000000..8884da7 --- /dev/null +++ b/src/keychain/docs/__init__.py @@ -0,0 +1,463 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Embedded documentation runtime for ``keychain man`` and ``--explain``. + +This module intentionally stays small: the authored documentation already lives +in ``_doc_texts.json`` and the action tree already knows the valid action names. +The runtime layer here just resolves targets and streams the pre-generated text +back out. +""" + +from __future__ import annotations + +import json +import os +import shutil +import sys +from functools import cache +from importlib.resources import files +from typing import Any + +from ..output.tables import render_panel + + +@cache +def _payload() -> dict[str, Any]: + blob = files("keychain").joinpath("docs").joinpath("_doc_texts.json").read_text(encoding="utf-8") + return json.loads(blob) + + +def _entry(tag: str) -> dict[str, str]: + section, _, key = tag.partition(":") + if not section or not key: + return {} + return _payload().get(section, {}).get(key, {}) + + +def _resolve_tags(topics: list[str]) -> list[str]: + if not topics: + return list(_payload().get("all", ())) + + from ..runtime.actions import ROOT_ACTION + from ..util import KeychainError + + action = ROOT_ACTION.find_action(topics) + if action is not None and action != ROOT_ACTION: + return [f"action:{action.fq_name}"] + + tags: list[str] = [] + data = _payload() + for token in topics: + if ":" in token and _entry(token): + tags.append(token) + continue + if token == "keychain": + tags.append("tool:keychain") + continue + for section in ("topic", "option", "global", "action"): + if token in data.get(section, {}): + tags.append(f"{section}:{token}") + break + else: + raise KeychainError(f"man: unknown topic: {token}") + return tags + + +def _render_tags(tags: list[str]) -> str: + parts = [entry.get("description", "") for tag in tags if (entry := _entry(tag))] + return "\n\n".join(part for part in parts if part) + + +def _authored_label(tag: str) -> str: + from ..runtime.actions import ROOT_ACTION + + if tag == "tool:keychain": + return "keychain" + if tag.startswith("section:"): + return tag.split(":", 1)[1] + if tag.startswith("topic:"): + return tag + if tag.startswith("global:"): + key = tag.split(":", 1)[1] + for opt in ROOT_ACTION.options.values(): + if opt.varname == key or opt.doc_tag == f"option:{key}": + return opt.option_formats + return f"--{key.replace('_', '-')}" + + def _walk(action) -> str | None: + if action.doc_tag == tag: + return action.command + for opt in action.options.values(): + if opt.doc_tag == tag: + return opt.option_formats + for child in action.sub_actions.values(): + found = _walk(child) + if found is not None: + return found + return None + + found = _walk(ROOT_ACTION) + if found is not None: + return found + if tag.startswith("action:"): + return f"keychain {tag.split(':', 1)[1]}" + if tag.startswith("option:"): + name = tag.split(":", 1)[1] + if name.endswith("-json"): + return "--json" + return f"--{name}" + return tag + + +def _render_manual_section(tag: str, width: int, out) -> str: + entry = _entry(tag) + if not entry: + return "" + + heading = _authored_label(tag) + lines: list[str] = [str(out.head(heading))] + if tag.startswith("section:"): + return "\n".join(lines).rstrip() + short_help = entry.get("short_help", "") + if short_help: + lines.extend(out.wrap_doc(short_help, width) or [out.format_doc(short_help)]) + syntax = _syntax_for(tag) + if syntax: + lines.append("") + lines.extend(out.wrap_doc(f"Syntax: {syntax}", width) or [out.format_doc(f"Syntax: {syntax}")]) + body = _render_manual_text(entry.get("description", ""), width, out) + if body: + lines.append("") + lines.extend(body) + return "\n".join(lines).rstrip() + + +def _render_manual_text(text: str, width: int, out) -> list[str]: + source_lines = _dedupe_doc_source_lines(text) + rendered: list[str] = [] + paragraph: list[str] = [] + + def _flush_paragraph() -> None: + nonlocal paragraph + if not paragraph: + return + joined = " ".join(line.strip() for line in paragraph) + if joined.startswith("* "): + rendered.extend(out.wrap_doc(joined[2:], width - 2, prefix="* ", continuation=" ") or ["* "]) + else: + rendered.extend(out.wrap_doc(joined, width) or [""]) + paragraph = [] + + for line in source_lines: + if line == "": + _flush_paragraph() + if rendered and rendered[-1] != "": + rendered.append("") + continue + if line.startswith(" "): + _flush_paragraph() + rendered.append(" " + out.format_doc(line[4:])) + continue + paragraph.append(line) + + _flush_paragraph() + while rendered and rendered[-1] == "": + rendered.pop() + return rendered + + +def _dedupe_doc_source_lines(text: str) -> list[str]: + lines: list[str] = [] + previous: str | None = None + for raw in text.splitlines(): + if raw.startswith("== @") or raw.startswith("@syntax "): + continue + line = raw.rstrip() + if not line.strip(): + if lines and lines[-1] != "": + lines.append("") + previous = "" + continue + if line == previous: + continue + lines.append(line) + previous = line + while lines and lines[0] == "": + lines.pop(0) + while lines and lines[-1] == "": + lines.pop() + return lines + + +def _syntax_for(tag: str | None) -> str: + if not tag: + return "" + entry = _entry(tag) + syntax = entry.get("syntax", "").strip() + if syntax: + return syntax + for line in entry.get("description", "").splitlines(): + if line.startswith("@syntax "): + return line[len("@syntax ") :].strip() + return "" + + +def _normalise_doc_lines(text: str) -> list[str]: + lines: list[str] = [] + previous = None + for raw in text.splitlines(): + stripped = raw.strip() + if raw.startswith("== @") or raw.startswith("@syntax "): + continue + if not stripped: + if lines and lines[-1] != "": + lines.append("") + previous = "" + continue + if stripped == previous: + continue + lines.append(stripped) + previous = stripped + while lines and lines[0] == "": + lines.pop(0) + while lines and lines[-1] == "": + lines.pop() + return lines + + +def _wrap_doc_text(text: str, width: int, out) -> list[str]: + lines = _normalise_doc_lines(text) + wrapped_lines: list[str] = [] + paragraph: list[str] = [] + out_obj = out + + def _flush() -> None: + nonlocal paragraph + if not paragraph: + return + joined = " ".join(paragraph) + if joined.startswith("* "): + wrapped_lines.extend(out_obj.wrap_doc(joined[2:], width - 2, prefix="* ", continuation=" ") or ["* "]) + else: + wrapped_lines.extend(out_obj.wrap_doc(joined, width) or [""]) + paragraph = [] + + for line in lines + [""]: + if line == "": + _flush() + if wrapped_lines and wrapped_lines[-1] != "": + wrapped_lines.append("") + continue + paragraph.append(line) + + while wrapped_lines and wrapped_lines[-1] == "": + wrapped_lines.pop() + return wrapped_lines + + +def _panel_body(short_help: str, description: str, syntax: str, width: int, out) -> list[str]: + body: list[str] = [] + if short_help: + body.extend(out.wrap_doc(short_help, width) or [out.format_doc(short_help)]) + if syntax: + if body: + body.append("") + body.extend(out.wrap_doc(f"Syntax: {syntax}", width) or [out.format_doc(f"Syntax: {syntax}")]) + wrapped = _wrap_doc_text(description, width, out) + if wrapped: + if body: + body.append("") + body.extend(wrapped) + return body or ["(no documentation record found)"] + + +def _classify_positional(action_name: str, value: str) -> tuple[str, str]: + if action_name in ("add", "forget", "inspect"): + if value.startswith("sshk:"): + return f"Key: {value}", f"SSH key file: {value[5:]}" + if value.startswith("gpgk:"): + return f"Key: {value}", f"GPG key ID: {value[5:]}" + if value.startswith("host:"): + return f"Key: {value}", f"Every IdentityFile from ssh -G {value[5:]}" + if action_name == "add": + return ( + f"Literal Agent Key: '{value}'", + "A literal SSH or GnuPG key specification to load into the agent.", + ) + return f"Key: {value}", f"Key argument for the {action_name} action." + if action_name == "help": + return f"Help target: {value}", "Action or topic that the help action will render documentation for." + if action_name == "man": + return f"Doc target: {value}", "Manual-page target selected for the man action." + return f"Argument: {value}", f"Positional argument for the {action_name} action." + + +def run_man(args, out) -> int: + if bool(args.get_value("list")): + rows = [] + for tag in _payload().get("all", ()): + if tag.startswith("section:"): + continue + entry = _entry(tag) + rows.append(f"{_authored_label(tag):<28} {out.format_doc(entry.get('short_help', ''))}") + out.write("\n".join(rows) + "\n") + return 0 + + topics = list(args.get_value("topics") or []) + width = int(args.get_value("width") or shutil.get_terminal_size((96, 24)).columns) + tags = _resolve_tags(topics) if topics else list(_payload().get("all", ())) + sections = [_render_manual_section(tag, width, out) for tag in tags] + out.write("\n\n".join(section for section in sections if section) + "\n") + return 0 + + +def run_explain(argv: list[str]) -> int: + from ..runtime.actions import ROOT_ACTION + from ..runtime.compat import COMPAT + from ..runtime.config import RuntimeConfig + from ..util import Output + + color = sys.stdout.isatty() and not os.environ.get("NO_COLOR") + if "--nocolor" in argv or "--no-color" in argv: + color = False + + filtered = [token for token in argv if token not in ("--explain", "--nocolor", "--no-color")] + legacy_equivalent: str | None = None + legacy_note: str | None = None + compat_used = False + + probe = RuntimeConfig() + probe._reset_all_cli() + pre_action_node, _pre_active_options, pre_consumed_sequence = probe._prescan_actions(filtered) + adapted = probe._adapt_action_argv(filtered, pre_action_node, pre_consumed_sequence) + compat_used = adapted is None and pre_action_node == ROOT_ACTION + + if compat_used: + compat_explain = COMPAT.explain(filtered) + if compat_explain is not None: + parse_argv, legacy_equivalent, legacy_note = compat_explain + else: + parse_argv = COMPAT.translate(filtered) + legacy_equivalent = COMPAT.equivalent_command(parse_argv) + else: + parse_argv = probe._canonicalize_argv(filtered) + + parser = RuntimeConfig() + parser._reset_all_cli() + action_node, _active_options, consumed_sequence = parser._prescan_actions(parse_argv) + if action_node == ROOT_ACTION: + action_node = ROOT_ACTION.find_action("add") or ROOT_ACTION + visible = parser._visible_options(action_node) + + out = Output.build(quiet=False, debug=False, eval_mode=False, color=color) + title_style = out.style("heading") + note_style = out.style("dim") + box_inner = max(40, min(shutil.get_terminal_size((96, 24)).columns - 6, 80)) + + panels: list[str] = [] + if compat_used: + compat_body = _wrap_doc_text( + "No match for any new-style action; legacy keychain 2.x parsing invoked.", + box_inner, + out, + ) + if legacy_note: + compat_body.extend([""] + _wrap_doc_text(legacy_note, box_inner, out)) + if legacy_equivalent: + compat_body.extend(["", "Equivalent keychain 3 command:", legacy_equivalent]) + panels.append( + render_panel( + "Legacy invocation", + compat_body, + title_style=title_style, + note="compat", + note_style=note_style, + min_width=box_inner, + ) + ) + + if action_node != ROOT_ACTION: + action_body = _panel_body( + action_node.short_help, + action_node.doc_description, + _syntax_for(action_node.doc_tag), + box_inner, + out, + ) + panels.append( + render_panel( + f"keychain {action_node.fq_name}", + action_body, + title_style=title_style, + note="action", + note_style=note_style, + min_width=box_inner, + ) + ) + + remaining_action_tokens = list(consumed_sequence) + i = 0 + while i < len(parse_argv): + tok = parse_argv[i] + if tok == "--": + i += 1 + continue + + if tok.startswith("-"): + opt = parser._resolve_alias(tok, visible) + value: str | None = None + title = tok + if opt is None: + body = _wrap_doc_text( + "No documentation record matches this token. It would be rejected during normal parsing.", + box_inner, + out, + ) + panels.append(render_panel(f"Unrecognised: {tok}", body, title_style=title_style, min_width=box_inner)) + i += 1 + continue + + if opt.takes_value: + if "=" in tok: + title = tok + value = tok.split("=", 1)[1] + elif i + 1 < len(parse_argv) and parse_argv[i + 1] != "--": + value = parse_argv[i + 1] + title = f"{tok} {value}" + i += 1 + + body = _panel_body(opt.short_help, opt.doc_description, _syntax_for(opt.doc_tag), box_inner, out) + details: list[str] = [f"Accepted spellings: {opt.option_formats}"] + if value is not None: + details.append(f"Value on this command line: {value}") + if opt.config_section: + details.append(f"Config key: [{opt.config_section}] {opt.effective_config_key}") + if details: + body = details + ([""] if body else []) + body + + label = "global option" if opt.actions == {ROOT_ACTION} else f"option for {action_node.fq_name}" + panels.append( + render_panel( + title, + body, + title_style=title_style, + note=label, + note_style=note_style, + min_width=box_inner, + ) + ) + i += 1 + continue + + if remaining_action_tokens and tok == remaining_action_tokens[0]: + remaining_action_tokens.pop(0) + i += 1 + continue + + title, body_text = _classify_positional(action_node.fq_name if action_node != ROOT_ACTION else "add", tok) + panels.append( + render_panel(title, _wrap_doc_text(body_text, box_inner, out), title_style=title_style, min_width=box_inner) + ) + i += 1 + + sys.stdout.write("\n".join(panels) + ("\n" if panels else "")) + return 0 diff --git a/src/keychain/env.py b/src/keychain/env.py new file mode 100644 index 0000000..fc31716 --- /dev/null +++ b/src/keychain/env.py @@ -0,0 +1,129 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Agent environment values. + +:class:`SshAgentRef` is a small frozen record that captures the two environment +variables an *ssh-agent* process publishes when it starts:: + + SSH_AUTH_SOCK=/run/user/1000/keychain/h/agent.sock + SSH_AGENT_PID=12345 + +Keychain reads these from three sources, each handled by a separate factory: + +* **pidfile** (``~/.keychain//-sh``) — use :meth:`SshAgentRef.from_text`, + which parses the sh-syntax lines. +* **inherited shell environment** — use :meth:`SshAgentRef.from_env`, which reads + the two keys directly from an env dict (e.g. the process's ``os.environ``). +* **spawning a new agent** — the agent subprocess writes its own pidfile; + the caller re-parses it with :meth:`SshAgentRef.from_text`. + +Once constructed, :meth:`SshAgentRef.overlay` produces a copy of a base +environment with the agent variables set correctly, ready to pass to +subprocesses or to emit as shell eval output. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass + +_ENV_KEYS = ("SSH_AUTH_SOCK", "SSH_AGENT_PID") + + +@dataclass(frozen=True) +class SshAgentRef: + """Immutable snapshot of a running ssh-agent's identity. + + An agent is identified by the UNIX-domain socket it listens on + (``SSH_AUTH_SOCK``) and, optionally, its PID (``SSH_AGENT_PID``). + A forwarded agent (``SSH_AGENT_PID=forwarded``) has no usable PID; + :attr:`forwarded` is set to ``True`` in that case and :attr:`pid` is + stored as ``""``. + + An empty :class:`SshAgentRef` (all defaults) is falsy; any instance with + a socket path is truthy. This lets callers use it in a boolean context + to test whether a reachable agent was found. + """ + + sock: str = "" + pid: str = "" + forwarded: bool = False + + @classmethod + def from_env(cls, env: Mapping[str, str]) -> SshAgentRef: + """Build an :class:`SshAgentRef` from a dict-like environment mapping. + + Typically called with the slice of ``os.environ`` that contains the + two agent keys, or with the dict returned by :meth:`as_dict`. + """ + pid = env.get("SSH_AGENT_PID", "") + return cls(env.get("SSH_AUTH_SOCK", ""), "" if pid == "forwarded" else pid, pid == "forwarded") + + @classmethod + def from_text(cls, text: str) -> SshAgentRef: + """Parse the sh-syntax lines in a keychain pidfile. + + Handles the ``VAR=value; export VAR`` form. Quoted values are stripped. + Lines starting with ``echo `` are skipped (keychain emits those for + the ``--eval`` output path but they carry no new data). + """ + values: dict[str, str] = {} + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("echo "): + continue + for part in line.split(";"): + key, sep, value = part.strip().partition("=") + if sep and key in _ENV_KEYS: + values[key] = _strip_quotes(value.strip()) + return cls.from_env(values) + + @property + def display_pid(self) -> str: + return "forwarded" if self.forwarded else self.pid + + @property + def pid_int(self) -> int | None: + if not self.pid: + return None + try: + return int(self.pid) + except ValueError: + return None + + def with_sock(self, sock: str) -> SshAgentRef: + return SshAgentRef(sock, self.pid, self.forwarded) + + def as_dict(self) -> dict[str, str]: + """Return only the agent keys that are set, as a plain dict. + + Suitable for passing to :func:`subprocess.run` via ``env=`` after + merging with the full process environment. + """ + env: dict[str, str] = {} + if self.sock: + env["SSH_AUTH_SOCK"] = self.sock + if self.pid: + env["SSH_AGENT_PID"] = self.pid + return env + + def overlay(self, base: Mapping[str, str] | None = None) -> dict[str, str]: + """Return *base* with the agent variables replaced by this instance's values. + + Clears any pre-existing ``SSH_AUTH_SOCK`` / ``SSH_AGENT_PID`` from + *base* before injecting, so stale inherited values can never + accidentally survive. Defaults to ``os.environ`` when *base* is + ``None``. + """ + env = dict(os.environ if base is None else base) + for key in _ENV_KEYS: + env.pop(key, None) + env.update(self.as_dict()) + return env + + def __bool__(self) -> bool: + return bool(self.sock) + + +def _strip_quotes(value: str) -> str: + return value.strip().strip('"').strip("'") diff --git a/src/keychain/keys.py b/src/keychain/keys.py new file mode 100644 index 0000000..998180f --- /dev/null +++ b/src/keychain/keys.py @@ -0,0 +1,163 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Requested-key resolution (``sshk:``, ``gpgk:``, ``host:``).""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from .util import Output, dedupe_sorted, run + + +@dataclass +class ResolvedKeys: + ssh: list[str] + gpg: list[str] + gpg_s: list[str] + gpg_e: list[str] + gpg_a: list[str] + missing: list[str] + + def extend(self, other: ResolvedKeys) -> None: + self.ssh.extend(other.ssh) + self.gpg.extend(other.gpg) + self.gpg_s.extend(other.gpg_s) + self.gpg_e.extend(other.gpg_e) + self.gpg_a.extend(other.gpg_a) + self.missing.extend(other.missing) + + def deduped(self) -> ResolvedKeys: + return ResolvedKeys( + dedupe_sorted(self.ssh), + dedupe_sorted(self.gpg), + dedupe_sorted(self.gpg_s), + dedupe_sorted(self.gpg_e), + dedupe_sorted(self.gpg_a), + dedupe_sorted(self.missing), + ) + + +def all_host_identities(out: Output) -> ResolvedKeys: + """Return resolved IdentityFiles for every Host block in ``~/.ssh/config``. + + Per-host expansion is delegated to ``ssh -G`` (via :func:`expand_host`) + so ``~``, ``${VAR}``, ``%d``/``%u``/``%h``, quoted args, ``Match``, and + ``Include`` are handled exactly as OpenSSH does -- no parallel parser. + """ + config = Path.home() / ".ssh" / "config" + if not config.is_file(): + out.warn("No ~/.ssh/config -- can't extract host identities") + return ResolvedKeys([], [], [], [], [], []) + hosts: set[str] = set() + for line in config.read_text(encoding="utf-8", errors="replace").splitlines(): + s = line.strip() + if not s or s.startswith("#") or not s.lower().startswith("host "): + continue + for pat in s.split()[1:]: + # Skip negations and pure wildcards: ssh -G needs a concrete name. + if not any(c in pat for c in "*?!"): + hosts.add(pat) + result = ResolvedKeys([], [], [], [], [], []) + for h in sorted(hosts): + result.extend(expand_host(h)) + return result + + +def _resolve_bare_keys(keys: list[str], gpg_prog: str, gpg_lookup: bool) -> ResolvedKeys: + """Resolve plain command-line key names.""" + result = ResolvedKeys([], [], [], [], [], []) + home_ssh = Path.home() / ".ssh" + for k in filter(None, keys): + if Path(k).is_file(): + result.ssh.append(k) + continue + ssh_path = home_ssh / k + if ssh_path.is_file(): + result.ssh.append(str(ssh_path)) + continue + if gpg_lookup: + try: + r = run([gpg_prog, "--list-secret-keys", k], timeout=5) + if r.returncode == 0: + result.gpg.append(k) + continue + except (FileNotFoundError, OSError): + pass + result.missing.append(k) + return result + + +def _add_ssh_key(result: ResolvedKeys, key: str) -> None: + home_ssh = Path.home() / ".ssh" + path = Path(key) + if path.is_file(): + result.ssh.append(key) + elif (home_ssh / key).is_file(): + result.ssh.append(str(home_ssh / key)) + else: + result.missing.append(key) + + +def keyf_expand(paths: list[str]) -> ResolvedKeys: + """Resolve plain SSH key paths.""" + result = ResolvedKeys([], [], [], [], [], []) + for path in paths: + _add_ssh_key(result, path) + return result + + +def expand_host(hostname: str) -> ResolvedKeys: + """Expand a ``host:`` extkey into resolved SSH keys via ``ssh -nG``.""" + try: + r = run(["ssh", "-nG", hostname], timeout=10) + except (FileNotFoundError, OSError): + return ResolvedKeys([], [], [], [], [], []) + paths: list[str] = [] + for line in r.stdout.splitlines(): + if line.startswith("identityfile "): + paths.append(line.split(None, 1)[1]) + return keyf_expand(paths) + + +def extkey_expand(extkeys: list[str], out: Output) -> ResolvedKeys: + """Expand public extended-key syntax; warn on unknown prefixes.""" + result = ResolvedKeys([], [], [], [], [], []) + for ek in filter(None, extkeys): + if ek.startswith("host:"): + result.extend(expand_host(ek.removeprefix("host:"))) + elif ek.startswith("sshk:"): + _add_ssh_key(result, ek.removeprefix("sshk:")) + elif ek.startswith("gpgk:"): + result.gpg.append(ek.removeprefix("gpgk:")) + elif ek.startswith("gpgs:"): + result.gpg_s.append(ek.removeprefix("gpgs:")) + elif ek.startswith("gpge:"): + result.gpg_e.append(ek.removeprefix("gpge:")) + elif ek.startswith("gpga:"): + result.gpg_a.append(ek.removeprefix("gpga:")) + else: + out.warn(f'Unrecognized extended key "{ek}". Should have a sshk:, gpgk:, gpgs:, gpge:, gpga: or host: prefix.') + return result + + +def _is_extkey(key: str) -> bool: + return key.startswith(("sshk:", "gpgk:", "gpgs:", "gpge:", "gpga:", "host:")) + + +def resolve_requested_keys( + confallhosts: bool, + extended: bool, + cmdline_keys: list[str], + gpg_prog: str, + out: Output, + *, + gpg_lookup: bool = True, +) -> ResolvedKeys: + result = ResolvedKeys([], [], [], [], [], []) + if confallhosts: + result.extend(all_host_identities(out)) + # ``--extended`` is a compatibility no-op. Prefixes are always accepted, + # and bare keys keep their normal SSH/GPG lookup behaviour even when mixed. + result.extend(extkey_expand([k for k in cmdline_keys if _is_extkey(k)], out)) + result.extend(_resolve_bare_keys([k for k in cmdline_keys if not _is_extkey(k)], gpg_prog, gpg_lookup)) + return result.deduped() diff --git a/src/keychain/main.py b/src/keychain/main.py new file mode 100644 index 0000000..48a478b --- /dev/null +++ b/src/keychain/main.py @@ -0,0 +1,453 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Command-line entry point: argument parsing + thin coordinator. + +The user-visible interface is an action tree +(``keychain {add,agent,list,wipe,forget,inspect,status,env,version,help}``). +Legacy keychain 2.x flat-flag invocations (``keychain --stop all``, +``keychain --list``, plain ``keychain``) are translated to the new form +by :mod:`keychain.compat` before parsing, so a single internal parser +handles every entry point. + +Targets Python 3.9+. +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys + +from . import __version__, agents, keys, state +from .env import SshAgentRef +from .runtime import platform +from .runtime.actions import NO_BANNER_ACTIONS, OUTPUT_ACTIONS, ROOT_ACTION +from .runtime.config import OptionError, RuntimeConfig +from .util import KeychainError, LockFile, Output + + +def _emit_eval_failure(enabled: bool) -> None: + if enabled: + sys.stdout.write("\nfalse;\n") + + +_HELP_PROJECT_URL = "https://kernel-seeds.org/projects/keychain/" + + +def banner(out: Output) -> None: + """One-line visual identifier: ``▌ keychain VER · URL`` (see + ``docs/output-design.md``). Replaces the historical multi-line ``* keychain`` + block; ``keychain version`` still prints the full GPL preamble. + """ + out.line() + # Mid-dot when stderr is utf-capable (matches the unicode bar glyph); + # plain hyphen otherwise so legacy/ascii consoles still align cleanly. + sep = "·" if out.theme == "modern" else "-" + out.banner(f"{out.id('keychain')} {out.id(__version__)} {sep} {out.dim(_HELP_PROJECT_URL)}") + + +def versinfo(out: Output) -> None: + out.line() + out.line(" Copyright 2026 Daniel Robbins, BreezyOps") + out.line() + out.line(" Keychain is free software: you can redistribute it and/or modify") + out.line(f" it under the terms of the {out.id('GNU General Public License version 3')} as") + out.line(" published by the Free Software Foundation.") + out.line() + + +def helpinfo(action: str | list[str] | None = None, out: Output | None = None) -> int: + """Print top-level help when *action* is None, otherwise per-action help. + + *action* may be a single name or a list of tokens that are joined with + spaces to form a full action name (so the caller can pass argparse's + ``help_target`` list directly). Lookup is exact: unknown names emit + ``help: unknown action: ...`` and return ``2``. + """ + if out is None: + out = Output.build(quiet=False, debug=False, eval_mode=False, color=False) + if action is None: + ROOT_ACTION.help(out) + else: + target = ROOT_ACTION.find_action(action) + if target is None: + label = " ".join(action) if isinstance(action, list) else str(action) + sys.stderr.write(f"help: unknown action: {label}\n") + return 2 + target.help(out) + return 0 + + +class KeychainApp: + """Thin coordinator: owns ``args``, ``out``, and a lazy ``kstate``.""" + + def __init__(self, args: RuntimeConfig, out: Output) -> None: + self.args = args + self.out = out + self._kstate: state.KeychainState | None = None + + @property + def kstate(self) -> state.KeychainState: + if self._kstate is None: + self._kstate = state.KeychainState.build(self.args, out=self.out) + return self._kstate + + def run(self) -> int: + action = self._resolve_action() + if action not in OUTPUT_ACTIONS: + os.umask(0o077) + if action not in NO_BANNER_ACTIONS: + banner(self.out) + handler = getattr(self, f"_handle_{action}_action", None) + if handler is None: # pragma: no cover + raise KeychainError(f"unknown action: {action}") + return handler() + + def _resolve_action(self) -> str: + """Validate run-time constraints and derive the concrete handler name. + + Why this exists: + the parser now owns action discovery through ``ROOT_ACTION`` and + ``RuntimeConfig.action_node``. The entrypoint should therefore stop + reconstructing action identity from old registries or ad hoc + ``subaction`` fields and instead consume the authored terminal node + directly. + + How it is used: + ``run()`` calls this exactly once before banner emission and handler + lookup. The returned string is the suffix used to locate methods like + ``_handle_add_action`` or ``_handle_agent_start_action``. + + How it resolves and why: + we first ask the terminal action node for ``dispatch_name`` so the tree + defines what is dispatchable. Only after a concrete node is established + do we enforce cross-option rules such as ``--quick`` versus ``--clear`` + and validate runtime-only constraints such as timeout bounds. This keeps + parse-time structure decisions in the parser and run-time policy checks + in the coordinator. + """ + action_node = self.args.action_node + if action_node is None: + raise KeychainError(f"unknown action: {self.args.action}") + + try: + action = action_node.dispatch_name + except ValueError as exc: + if action_node.sub_actions: + expected = "|".join(action_node.sub_actions.keys()) + raise KeychainError(f"{action_node.fq_name}: missing subcommand ({expected})") from exc + raise KeychainError(str(exc)) from exc + + try: + self.args.apply_option_policies(self.out) + except OptionError as exc: + raise KeychainError(str(exc)) from exc + + if bool(self.args.get_value("quick")) and bool(self.args.get_value("clear")): + raise KeychainError("--quick and --clear are not compatible") + + return action + + # ---- Output-only Handlers (no KeychainState) ------------------------------------ + + def _handle_man_action(self) -> int: + # lazy-load to avoid loading all documentation-related code and data structures when not needed + from . import docs + + return docs.run_man(self.args, self.out) + + def _handle_version_action(self) -> int: + if self.out.json: + import json + + print( + json.dumps( + { + "name": "keychain", + "implementation": "python", + "version": __version__, + "url": _HELP_PROJECT_URL, + } + ) + ) + else: + banner(self.out) + versinfo(self.out) + return 0 + + def _handle_help_action(self) -> int: + help_target = self.args.get_value("help_target") + if help_target is None: + banner(self.out) + versinfo(self.out) + return helpinfo(help_target, self.out) + + # ---- state handlers ----------------------------------------------- + + def _handle_list_action(self) -> int: + if self.out.json: + agents.render_list_json(self.kstate.find_active_agent_env) + return 0 + return agents.render_list_table(self.kstate, self.out) + + def _handle_env_action(self) -> int: + target = "json" if self.out.json else (self.kstate.args.get_value("shell") or "env") + self.out.write(self.kstate.paths.render_env(self.kstate.find_active_agent_env, target, os.environ)) + return 0 + + def _handle_inspect_action(self) -> int: + from .output import inspect as inspect_view + + if self.out.json: + inspect_view.render_inspect_json(self.kstate) + else: + inspect_view.render_inspect(self.kstate, self.out) + return 1 if any(sev in ("warn", "err") for *_, sev in self.kstate.security_audit) else 0 + + def _handle_agent_stop_action(self) -> int: + self._verify_keydir() + target = self.args.get_value("target") or "pidfile" + self.kstate.ssh.stop(target) + self.out.line() + return 0 + + def _handle_agent_start_action(self) -> int: + self._verify_keydir() + ssh_spawn_gpg, ssh_allow_gpg = self._agent_settings() + return self._do_add([], [], [], [], [], ssh_spawn_gpg, ssh_allow_gpg) + + def _handle_wipe_action(self) -> int: + self._verify_keydir() + only_ssh = bool(self.args.get_value("wipe_ssh")) and not bool(self.args.get_value("wipe_gpg")) + only_gpg = bool(self.args.get_value("wipe_gpg")) and not bool(self.args.get_value("wipe_ssh")) + if not only_gpg: + self.kstate.ssh.wipe() + if not only_ssh: + self.kstate.gpg.wipe() + self.out.line() + return 0 + + def _handle_forget_action(self) -> int: + self._verify_keydir() + keys_arg = self.args.get_value("keys") or [] + conf_arg = bool(self.args.get_value("confallhosts")) + if not keys_arg and not conf_arg: + return 0 + resolved = self._resolve_requested_keys(gpg_lookup=False) + if resolved.gpg: + raise KeychainError("forget only supports SSH keys; use wipe --gpg to remove all gpg-agent identities.") + self.kstate.ssh.remove(resolved.ssh) + self.out.line() + return 0 + + def _handle_add_action(self) -> int: + self._verify_keydir() + resolved = self._resolve_requested_keys() + requested_keys = list(self.args.get_value("keys") or []) + if requested_keys and not resolved.ssh and not any((resolved.gpg, resolved.gpg_s, resolved.gpg_e, resolved.gpg_a)) and resolved.missing: + raise KeychainError( + "No requested keys could be resolved; refusing to start an agent. " + "Run 'keychain help add' for more information." + ) + ssh_spawn_gpg, ssh_allow_gpg = self._agent_settings() + return self._do_add( + resolved.ssh, + resolved.gpg, + resolved.gpg_s, + resolved.gpg_e, + resolved.gpg_a, + ssh_spawn_gpg, + ssh_allow_gpg, + ) + + # ---- Shared helpers ----------------------------------------------- + + def _verify_keydir(self) -> None: + self.kstate.paths.verify_keydir(self.kstate.user, self.out) + + def _agent_settings(self) -> tuple[bool, bool]: + ssh_spawn_gpg = bool(self.args.get_value("ssh_spawn_gpg")) + if ssh_spawn_gpg and not self.kstate.gpg_has_ssh_support: + self.out.warn("gpg-agent ssh functionality not available; not using...") + ssh_spawn_gpg = False + ssh_allow_gpg = bool(self.args.get_value("ssh_allow_gpg")) + return ssh_spawn_gpg, ssh_allow_gpg or ssh_spawn_gpg + + def _resolve_requested_keys(self, *, gpg_lookup: bool = True) -> keys.ResolvedKeys: + resolved = self.kstate.resolve_requested_keys(self.out, gpg_lookup=gpg_lookup) + if not bool(self.args.get_value("ignore_missing")): + for missing in resolved.missing: + self.out.warn(f'Can\'t find key "{self.out.value(missing)}"') + return resolved + + def _do_add( + self, + ssh_keys: list[str], + gpg_keys: list[str], + gpg_s_keys: list[str], + gpg_e_keys: list[str], + gpg_a_keys: list[str], + ssh_spawn_gpg: bool, + ssh_allow_gpg: bool, + ) -> int: + """Lockfile-protected flow used for keychain 'add' and 'agent start' actions.""" + paths = self.kstate.paths + + lockwait = self.args.get_value("lockwait") + if lockwait is None: + lockwait = 5 + no_lock = bool(self.args.get_value("no_lock")) + with LockFile(paths.lockf, no_lock, lockwait, self.out) as lock: + wipe_pending = bool(self.args.get_value("clear")) + if wipe_pending: + signal.signal(signal.SIGINT, signal.SIG_IGN) # disallow ^C until we've had a chance to --clear + for sig in (getattr(signal, "SIGHUP", None), signal.SIGTERM): + _safe_signal(sig, lambda *_: _signal_exit(lock)) # drop the lock on signal + else: + for sig in (getattr(signal, "SIGHUP", None), signal.SIGINT, signal.SIGTERM): + _safe_signal(sig, lambda *_: _signal_exit(lock)) # drop the lock on signal + + quick_succeeded = self.kstate.ssh.start(ssh_spawn_gpg, ssh_allow_gpg) + + # gpg-agent is started separately when GPG keys are wanted and the + # ssh-agent is *not* the gpg-agent itself (--ssh-spawn-gpg). + if (gpg_keys or gpg_s_keys or gpg_e_keys or gpg_a_keys) and not ssh_spawn_gpg: + gpg_env = self.kstate.gpg.start(ssh_support=False) + if gpg_env and gpg_env.sock: + self.kstate.ssh.env = self.kstate.ssh.env.with_sock(gpg_env.sock) + + if bool(self.args.get_value("eval")): + self.out.write(paths.render_env(self.kstate.ssh.env, "eval", os.environ)) + + if bool(self.args.get_value("systemd")): + _systemd_set_env(self.kstate.ssh.env, self.out) + + if bool(self.args.get_value("noask")) or quick_succeeded: + self.out.line() + return 0 + + if wipe_pending: + self.kstate.ssh.wipe() + if gpg_keys or gpg_s_keys or gpg_e_keys or gpg_a_keys: + self.kstate.gpg.wipe() + signal.signal(signal.SIGINT, lambda *_: _signal_exit(lock)) # done clearing, safe to ctrl-c + + missing_ssh = self.kstate.ssh.list_missing(ssh_keys) + if not self.kstate.ssh.load(missing_ssh): + raise KeychainError("Unable to add keys") + + if gpg_keys: + missing_gpg = self.kstate.gpg.list_missing(gpg_keys) + self.kstate.gpg.load(missing_gpg) + if gpg_s_keys: + missing_gpg = self.kstate.gpg.list_missing(gpg_s_keys, mode="--sign") + self.kstate.gpg.load(missing_gpg, mode="--sign") + if gpg_e_keys: + missing_gpg = self.kstate.gpg.list_missing(gpg_e_keys, mode="--encrypt") + self.kstate.gpg.load(gpg_e_keys, mode="--encrypt") + if gpg_a_keys: + missing_gpg = self.kstate.gpg.list_missing(gpg_a_keys, mode="--armor") + self.kstate.gpg.load(gpg_a_keys, mode="--armor") + + self.out.line() + return 0 + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> None: + if argv is None: + argv = sys.argv[1:] + + args = RuntimeConfig.resolve(argv) + + if bool(args.get_value("explain")): + from . import docs + + sys.exit(docs.run_explain(argv)) + + no_color_env = bool(os.environ.get("NO_COLOR")) + out = Output.build( + quiet=bool(args.get_value("quiet")) or args.action == "env", + debug=bool(args.get_value("debug")), + eval_mode=bool(args.get_value("eval")), + color=not bool(args.get_value("no_color")) and not no_color_env, + theme=(args.get_value("theme") or "modern"), + json=bool(args.get_value("json")), + ) + + for warning in args.rc_warnings: + out.warn(warning) + + if args.parse_error: + out.error(args.parse_error) + out.line() + _emit_eval_failure(bool(args.get_value("eval"))) + sys.exit(2) + + plat = platform.detect() + if not plat.supported and args.action not in ("help", "version", "inspect", "env", "man"): + banner(out) + out.error(f"Unsupported platform: {plat.name}") + out.line(f" {plat.reason}") + out.line() + _emit_eval_failure(bool(args.get_value("eval"))) + sys.exit(2) + + try: + sys.exit(KeychainApp(args, out).run()) + except KeychainError as e: + msg = str(e) + if msg: + out.error(msg) + out.line() + _emit_eval_failure(bool(args.get_value("eval"))) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Signals & systemd +# --------------------------------------------------------------------------- + + +def _safe_signal(sig, handler): + if sig is None: + return + try: + signal.signal(sig, handler) + except (ValueError, OSError, AttributeError): + # SIGHUP doesn't exist on Windows; non-main threads can't install. + pass + + +def _signal_exit(lock: LockFile) -> None: + lock.release() + sys.exit(1) + + +def _systemd_set_env(agent_env: SshAgentRef, out: Output) -> None: + assignments = [] + if agent_env.sock: + assignments.append(f"SSH_AUTH_SOCK={agent_env.sock}") + if agent_env.pid: + assignments.append(f"SSH_AGENT_PID={agent_env.pid}") + if assignments: + try: + subprocess.run( + ["systemctl", "--user", "set-environment", *assignments], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + check=False, + ) + except subprocess.TimeoutExpired: + out.warn("Timed out while updating the systemd user environment") + except (OSError, ValueError): + pass + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/keychain/output/__init__.py b/src/keychain/output/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/keychain/output/core.py b/src/keychain/output/core.py new file mode 100644 index 0000000..ecba822 --- /dev/null +++ b/src/keychain/output/core.py @@ -0,0 +1,595 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""User-facing output: themes, role-tagged spans, emitters. + +Three layers, smallest first: + +* :class:`Span` -- a typed coloured fragment carrying a *role* name. Its + ``__str__`` looks up the active :class:`Theme` from a thread-local set + by :meth:`Output.build` and renders the wrapped text with that theme's + ANSI prefix and reset. +* :class:`Theme` -- ``role -> ANSI prefix`` plus a glyph map plus a + legacy palette mapping (kept so older tests / ``out.c('CYANN')`` + callers continue to work during the deprecation window). +* :class:`Output` -- emitters (``info``/``warn``/``note``/``error``/ + ``debug``/``line``/``heading``/``banner``/``write``) plus role + helpers (``id``/``path``/``value``/``flag``/``warn_text``/ + ``err_text``/``dim``/``head``/``note_text``/``kbd``). + +Targets Python 3.9+. +""" + +from __future__ import annotations + +import os +import re +import sys +import threading +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from typing import Union + +# --------------------------------------------------------------------------- +# Roles +# --------------------------------------------------------------------------- + +# Canonical list of fragment roles. Adding a new role requires updating +# every theme's role map; ``_validate_themes`` enforces that at import time. +ROLES: tuple[str, ...] = ( + "plain", # passthrough; no styling + "identifier", # hostnames, paths, key files, PIDs + "path", # filesystem paths (often == identifier) + "value", # newly-set or "good" values; loaded keys + "flag", # CLI flag tokens (--quick) in prose + "warn", # inline warning words + "err", # inline error words + "note", # inline emphasis (purple) + "dim", # parentheticals, "(none)", placeholder text + "heading", # section / panel titles + "kbd", # shell snippets in prose +) + +DEFAULT_THEME = "modern" + + +# --------------------------------------------------------------------------- +# Palettes (legacy palette names retained for back-compat ``out.c('CYANN')``) +# --------------------------------------------------------------------------- + +_LEGACY_PALETTE: dict[str, str] = { + "BLUE": "\033[34;01m", + "CYAN": "\033[36;01m", + "CYANN": "\033[36m", + "GREEN": "\033[32;01m", + "RED": "\033[31;01m", + "PURP": "\033[35;01m", + "YEL": "\033[33;01m", + "DIM": "\033[2m", + "OFF": "\033[0m", +} +_MODERN_PALETTE: dict[str, str] = { + "BLUE": "\033[38;5;75m", # cornflower blue + "CYAN": "\033[38;5;87m", # bright aqua + "CYANN": "\033[38;5;81m", # deep sky blue + "GREEN": "\033[38;5;114m", # soft sage + "RED": "\033[38;5;203m", # warm salmon + "PURP": "\033[38;5;141m", # muted mauve + "YEL": "\033[38;5;221m", # gold + "DIM": "\033[38;5;245m", # neutral grey + "OFF": "\033[0m", +} +_NO_ANSI: dict[str, str] = {k: "" for k in _LEGACY_PALETTE} + +_DOC_INLINE_MARKUP_RE = re.compile(r"``([^`]+)``|`([^`]+)`|(? dict[str, str]: + """Map :data:`ROLES` onto a palette. Single source of truth for the + role -> colour binding (changing a role's colour is a one-line edit).""" + return { + "plain": "", + "identifier": palette["CYANN"], + "path": palette["CYANN"], + "value": palette["GREEN"], + "flag": palette["CYANN"], + "warn": palette["YEL"], + "err": palette["RED"], + "note": palette["PURP"], + "dim": palette["DIM"], + "heading": palette["CYANN"], + "kbd": palette["CYANN"], + } + + +# --------------------------------------------------------------------------- +# Glyphs +# --------------------------------------------------------------------------- + +_GLYPHS_MODERN: dict[str, str] = { + "info": "\u25b8", # ▸ + "ok": "\u25cf", # ● + "warn": "\u25b2", # ▲ + "err": "\u2716", # ✖ + "note": "\u203a", # › + "debug": "\u22ef", # ⋯ + "bar": "\u258c", # ▌ + "arrow": "\u21b3", # ↳ +} +_GLYPHS_ASCII: dict[str, str] = { + "info": "*", + "ok": "*", + "warn": "!", + "err": "x", + "note": "-", + "debug": ":", + "bar": "|", + "arrow": ">", +} +# Glyph -> palette colour role (used to colour the glyph itself). +_GLYPH_COLOR: dict[str, str] = { + "info": "CYANN", + "ok": "GREEN", + "warn": "YEL", + "err": "RED", + "note": "GREEN", + "debug": "DIM", + "bar": "CYANN", + "arrow": "DIM", +} + + +# --------------------------------------------------------------------------- +# Theme +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Theme: + """A complete styling profile. Every role in :data:`ROLES` MUST resolve.""" + + name: str + palette: Mapping[str, str] + roles: Mapping[str, str] + glyphs: Mapping[str, str] + reset: str = "\x1b[0m" + + def render(self, role: str, text: str) -> str: + """Wrap *text* in the role's ANSI prefix + reset. No-op when prefix is empty.""" + prefix = self.roles.get(role, "") + if not prefix: + return text + return f"{prefix}{text}{self.reset}" + + +THEMES: dict[str, Theme] = { + "legacy": Theme( + name="legacy", + palette=_LEGACY_PALETTE, + roles=_roles_for(_LEGACY_PALETTE), + glyphs=_GLYPHS_ASCII, + ), + "modern": Theme( + name="modern", + palette=_MODERN_PALETTE, + roles=_roles_for(_MODERN_PALETTE), + glyphs=_GLYPHS_MODERN, + ), +} + +# A theme that renders nothing (every role -> empty prefix). Used for +# no-color output, JSON mode, and silent probes. +_NULL_THEME = Theme( + name="none", + palette=_NO_ANSI, + roles=_roles_for(_NO_ANSI), + glyphs=_GLYPHS_ASCII, +) + + +def _validate_themes() -> None: + """Catch role/theme drift at import time (acceptance criterion).""" + for theme in THEMES.values(): + for role in ROLES: + if role not in theme.roles: + raise RuntimeError(f"theme {theme.name!r} missing role {role!r}") + + +_validate_themes() + + +def resolve_theme_name(name: str | None) -> str: + """Return a known theme name (case-insensitive); fall back to default.""" + if name: + key = name.strip().lower() + if key in THEMES: + return key + return DEFAULT_THEME + + +def stderr_supports_unicode() -> bool: + """True when stderr's encoding can carry the modern glyphs.""" + enc = (getattr(sys.stderr, "encoding", "") or "").lower() + return "utf" in enc + + +# --------------------------------------------------------------------------- +# Span +# --------------------------------------------------------------------------- + +# One active theme per process, set by :meth:`Output.build`. Stored on a +# thread-local so :meth:`Span.__str__` can render without taking the +# ``Output`` instance as an argument -- which is what makes f-string +# interpolation (``f"hi {out.id(name)}"``) ergonomic. +_active = threading.local() + + +def _active_theme() -> Theme: + return getattr(_active, "theme", _NULL_THEME) + + +@dataclass(frozen=True) +class Span: + """A coloured run of text tagged with a role. + + Renders against the *active* theme via :meth:`__str__`, so f-string + interpolation works without leaking ANSI past the span: + + out.info(f"Known ssh key: {out.id(name)}") + """ + + text: str + role: str = "plain" + + def __str__(self) -> str: + return _active_theme().render(self.role, str(self.text)) + + +# --------------------------------------------------------------------------- +# Output +# --------------------------------------------------------------------------- + +# Type accepted by emitters: a plain string, or a Span, or a sequence of either. +Renderable = Union[str, Span, Iterable[Union[str, Span]]] + + +def _stringify(parts: Renderable) -> str: + """Coerce a Renderable to a single string. Spans render via their + ``__str__`` against the active theme.""" + if isinstance(parts, (str, Span)): + return str(parts) + return "".join(str(p) for p in parts) + + +def _split_doc_inline(text: str) -> list[tuple[str, str]]: + parts: list[tuple[str, str]] = [] + pos = 0 + for match in _DOC_INLINE_MARKUP_RE.finditer(text): + if match.start() > pos: + parts.append((text[pos : match.start()], "text")) + if match.group(1) is not None: + parts.append((match.group(1), "code")) + elif match.group(2) is not None: + parts.append((match.group(2), "code")) + else: + parts.append((match.group(3), "emph")) + pos = match.end() + if pos < len(text): + parts.append((text[pos:], "text")) + return parts or [("", "text")] + + +def _strip_doc_inline(text: str) -> str: + return "".join(chunk for chunk, _kind in _split_doc_inline(text)) + + +@dataclass(frozen=True) +class Output: + """Stateless output sink (one per process). + + ``Output.build()`` is the one true constructor for normal use; it + parses theme/colour/policy arguments and installs the active theme on + the thread-local that :class:`Span` reads from. ``Output.silent()`` + returns a no-op sink for probes that must not emit anything. + """ + + quiet: bool = False + debug_on: bool = False + eval_mode: bool = False + json: bool = False + theme: str = DEFAULT_THEME + # Active Theme. Internal; call sites use role helpers / emitters. + _theme: Theme = field(default=_NULL_THEME, repr=False) + # Back-compat: legacy palette dict for ``out.c('CYANN')`` callers. + colors: Mapping[str, str] = field(default_factory=lambda: _NO_ANSI) + # Back-compat: glyph map. Internal; new code uses ``out.glyph(role)``. + glyphs: Mapping[str, str] = field(default_factory=lambda: _GLYPHS_ASCII) + # When True, every emitter (including warn/error) is a no-op. + # Used by :meth:`silent` for state probes; not user-tunable. + _silent: bool = field(default=False, repr=False) + + # ---- construction -------------------------------------------------- + + @classmethod + def build( + cls, + quiet: bool, + debug: bool, + eval_mode: bool, + color: bool, + theme: str | None = None, + json: bool = False, + ) -> Output: + # Theme is set exclusively via --theme CLI flag; no env var override. + chosen = resolve_theme_name(theme) + if color: + try: + color = bool(os.isatty(sys.stderr.fileno())) + except (OSError, ValueError): + color = False + # JSON mode is silent on stderr -- the only useful output is the JSON + # document on stdout. Force quiet so banners/notes don't pollute logs. + if json: + quiet = True + color = False + if color: + active = THEMES[chosen] + # Modern theme uses unicode glyphs; fall back to ASCII when stderr + # can't render them so legacy/ascii consoles still align cleanly. + if active.glyphs is _GLYPHS_MODERN and not stderr_supports_unicode(): + active = Theme(active.name, active.palette, active.roles, _GLYPHS_ASCII) + else: + active = _NULL_THEME + # Install the active theme for Span.__str__ to read. There is one + # active output per process (set at startup); the thread-local is + # an implementation detail of role-helper interpolation. + _active.theme = active + return cls( + quiet=quiet, + debug_on=debug, + eval_mode=eval_mode, + json=json, + theme=chosen, + _theme=active, + colors=active.palette, + glyphs=active.glyphs, + ) + + @classmethod + def silent(cls) -> Output: + """Return a no-op :class:`Output`. Every emitter discards. + + Replaces the ``_NullOut(Output)`` smell from earlier revisions of + :mod:`keychain.state`: state probes that need an ``Output`` + argument but mustn't print anything pass ``Output.silent()``. + """ + return cls( + quiet=True, + json=False, + theme=DEFAULT_THEME, + _theme=_NULL_THEME, + colors=_NO_ANSI, + glyphs=_GLYPHS_ASCII, + _silent=True, + ) + + # ---- back-compat colour accessor (deprecated) ---------------------- + def c(self, name: str) -> str: + """Return the legacy palette ANSI prefix for *name* (e.g. 'CYANN'). + + Deprecated: new code should use role helpers (``out.id``, + ``out.value``, ``out.dim``, ...) instead. Kept so the test suite + and any out-of-tree callers keep working through the deprecation + window described in ``docs/output-api.md`` (step 6). + """ + return self.colors.get(name, "") + + # ---- glyph accessor ------------------------------------------------ + def glyph(self, role: str) -> str: + """Return *role*'s glyph wrapped in its theme colour (or bare).""" + g = self.glyphs.get(role, "*") + col = _GLYPH_COLOR.get(role, "") + if col and self.colors.get(col): + return f"{self.colors[col]}{g}{self.colors.get('OFF', '')}" + return g + + # ---- role helpers (Span constructors) ----------------------------- + def id(self, s: object) -> Span: + """Identifier highlight: hostnames, key paths, user names, PIDs.""" + return Span(str(s), "identifier") + + def path(self, p: object) -> Span: + """Filesystem path in prose.""" + return Span(str(p), "path") + + def value(self, s: object) -> Span: + """Newly-set / 'good' value; loaded keys; sockets.""" + return Span(str(s), "value") + + def flag(self, s: object) -> Span: + """CLI flag (e.g. ``--quick``) embedded in prose.""" + return Span(str(s), "flag") + + def warn_text(self, s: object) -> Span: + """Inline warning word inside a sentence.""" + return Span(str(s), "warn") + + def err_text(self, s: object) -> Span: + """Inline error word inside a sentence.""" + return Span(str(s), "err") + + def dim(self, s: object) -> Span: + """Parenthetical aside, ``(none)``, placeholder text.""" + return Span(str(s), "dim") + + def head(self, s: object) -> Span: + """Section / panel title fragment (use :meth:`heading` for full lines).""" + return Span(str(s), "heading") + + def note_text(self, s: object) -> Span: + """Inline emphasis (purple).""" + return Span(str(s), "note") + + def kbd(self, s: object) -> Span: + """Shell snippet in prose.""" + return Span(str(s), "kbd") + + def style(self, *roles: str) -> str: + """Return the concatenated ANSI prefix for one or more roles. + + Used by structural helpers (``tables.render_table``'s + ``header_style``) that need a raw prefix string rather than a + wrapped span. Reset is the renderer's job. + """ + return "".join(self._theme.roles.get(r, "") for r in roles) + + def format_doc(self, text: str) -> str: + """Render the minimal inline doc markup used by embedded help text. + + Supported today: + - ``code`` / `code` + - *emphasis* + + When colour is disabled the markup is stripped but the text remains. + """ + if not text: + return "" + if not self.colors.get("OFF"): + return _strip_doc_inline(text) + + code_on = self.colors.get("YEL", "") + emph_on = "\x1b[1m" + off = self.colors.get("OFF", "") + out: list[str] = [] + for chunk, kind in _split_doc_inline(text): + if kind == "code": + out.append(f"{code_on}{chunk}{off}") + elif kind == "emph": + out.append(f"{emph_on}{chunk}{off}") + else: + out.append(chunk) + return "".join(out) + + def wrap_doc(self, text: str, width: int, *, prefix: str = "", continuation: str = "") -> list[str]: + """Wrap minimal inline-markup text while preserving rendered styling. + + Width calculations are based on the plain-text form so wrapped output + stays stable whether colour is enabled or not. + """ + lines: list[str] = [] + line = prefix + line_len = len(prefix) + line_has_text = bool(prefix) + pending_space = "" + + for chunk, kind in _split_doc_inline(text): + for token in re.findall(r"\s+|\S+", chunk): + if token.isspace(): + pending_space = " " + continue + spacer = pending_space if line_has_text and pending_space else "" + added_len = len(spacer) + len(token) + if line_has_text and line_len + added_len > width: + lines.append(line.rstrip()) + line = continuation + line_len = len(continuation) + line_has_text = bool(continuation) + spacer = "" + if spacer: + line += spacer + line_len += len(spacer) + if kind == "text": + line += token + else: + line += ( + self.format_doc(token if kind != "emph" else f"*{token}*") + if kind == "emph" + else self.format_doc(f"``{token}``") + ) + line_len += len(token) + line_has_text = True + pending_space = "" + + if line: + lines.append(line.rstrip()) + return lines + + # ---- emitters ------------------------------------------------------ + + def write(self, msg: str = "") -> None: + """Stdout payload (machine-consumed: shell-eval, ``env``, JSON). + + Never suppressed by quiet/json -- this is protocol output. + """ + sys.stdout.write(msg) + + def line(self, msg: Renderable = "") -> None: + """Plain stderr line. Suppressed by quiet / json.""" + if self._silent or self.quiet: + return + print(_stringify(msg), file=sys.stderr) + + def info(self, msg: Renderable) -> None: + """Informational message (▸). Suppressed by quiet / json.""" + if self._silent or self.quiet: + return + print(f" {self.glyph('info')} {_stringify(msg)}", file=sys.stderr) + + def warn(self, msg: Renderable) -> None: + """Inline warning. Suppressed by json (and by silent()).""" + if self._silent or self.json: + return + prefix = self.colors.get("YEL", "") + off = self.colors.get("OFF", "") + print(f" {self.glyph('warn')} {prefix}Warning{off}: {_stringify(msg)}", file=sys.stderr) + + def note(self, msg: Renderable) -> None: + """Notice (›). Suppressed by quiet / json.""" + if self._silent or self.quiet: + return + prefix = self.colors.get("PURP", "") + off = self.colors.get("OFF", "") + print(f" {self.glyph('note')} {prefix}Note{off}: {_stringify(msg)}", file=sys.stderr) + + def error(self, msg: Renderable) -> None: + """Inline error. Suppressed by json (and by silent()).""" + if self._silent or self.json: + return + prefix = self.colors.get("RED", "") + off = self.colors.get("OFF", "") + print(f" {self.glyph('err')} {prefix}Error{off}: {_stringify(msg)}", file=sys.stderr) + + def debug(self, msg: Renderable) -> None: + """Debug trace. Suppressed unless ``debug_on`` and not json.""" + if self._silent or self.json or not self.debug_on: + return + prefix = self.colors.get("DIM", "") + off = self.colors.get("OFF", "") + print(f" {self.glyph('debug')} {prefix}{_stringify(msg)}{off}", file=sys.stderr) + + def heading(self, title: Renderable) -> None: + """Section heading: ``▌ Title`` (cyan bar + cyan label).""" + if self._silent or self.quiet: + return + self.line() + self.line(f" {self.glyph('bar')} {Span(_stringify(title), 'heading')}") + + def banner(self, body: Renderable) -> None: + """Single ``▌ body`` line (used by the version banner).""" + if self._silent or self.quiet: + return + self.line(f" {self.glyph('bar')} {_stringify(body)}") + + # ---- deprecated emitter aliases ------------------------------------ + # Old names kept so the existing test suite and out-of-tree callers + # continue to work for one release. Step 6 of the migration plan + # deletes these. + def mesg(self, msg: str) -> None: + self.info(msg) + + def qprint(self, msg: str = "") -> None: + self.line(msg) + + def section(self, title: str) -> None: + self.heading(title) + + def banner_line(self, body: str) -> None: + self.banner(body) diff --git a/src/keychain/output/inspect.py b/src/keychain/output/inspect.py new file mode 100644 index 0000000..90fb4b5 --- /dev/null +++ b/src/keychain/output/inspect.py @@ -0,0 +1,265 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Inspect renderers and related presentation helpers.""" + +from __future__ import annotations + +import json +import os +import shutil +import stat +from typing import Any + +from ..state import KeychainState +from ..util import Output, get_owner + + +def _format_kv_rows(rows: list, out: Output) -> list[str]: + """Return formatted kv lines (without indent) for *rows*. + + Each row is ``(label, value, hint)`` or ``(label, value, hint, severity)``. + Severity is ``""`` (info), ``"warn"`` (yellow) or ``"err"`` (red); it + colours the value and the hint together so security-relevant rows stand + out without a separate badge column. Boolean values render as + ``● yes`` / ``✖ no`` in the severity colour (green/red by default). + """ + if not rows: + return [] + width = max(len(r[0]) for r in rows) + lines: list[str] = [] + for row in rows: + label, value, hint = row[0], row[1], row[2] + sev = row[3] if len(row) > 3 else "" + if isinstance(value, bool): + text = "yes" if value else "no" + if sev == "warn": + disp = f"{out.glyph('warn')} {out.warn_text(text)}" + elif sev == "err": + disp = f"{out.glyph('err')} {out.err_text(text)}" + else: + disp = f"{out.glyph('ok')} {out.value('yes')}" if value else f"{out.glyph('err')} {out.err_text('no')}" + else: + if sev == "warn": + disp = str(out.warn_text(value)) + elif sev == "err": + disp = str(out.err_text(value)) + else: + disp = str(out.id(value)) + lines.append(f"{label:<{width}} {disp}") + # Inline hints are for neutral annotations only (e.g. ``(you)``); + # warn/err hints are surfaced as out.warn()/out.error() lines after + # the panels render, matching the format used by other code paths. + if hint and sev == "": + lines[-1] += f" {out.dim(hint)}" + return lines + + +def _owner_row(label: str, path: Any, me: str) -> tuple[str, str, str, str]: + """Build a (label, value, hint, severity) row for a path's owner check.""" + owner = get_owner(path) + if not owner: + return (label, "(unknown)", "", "") + if me and owner != me: + return (label, owner, f"owned by {owner}, not you ({me}); refusing to use this file", "warn") + return (label, owner, "(you)", "") + + +def _mode_row(label: str, path: Any, lax_hint: str) -> tuple[str, str, str, str]: + """Build a (label, value, hint, severity) row for a path's permission bits.""" + try: + mode = stat.S_IMODE(os.stat(str(path)).st_mode) + except OSError: + return (label, "(unreadable)", "", "") + octal = f"0{mode:o}" + hint = lax_hint if mode & (stat.S_IRWXG | stat.S_IRWXO) else "" + return (label, octal, hint, "warn" if hint else "") + + +def render_inspect(state: KeychainState, out: Output) -> None: + """Print a structured snapshot of every probe in *state* to *out*.""" + from .tables import compose_columns, render_panel, render_table + + sections: list[tuple[str, list[tuple]]] = [] + + platform_rows: list[tuple] = [ + ("hostname", state.hostname, f"- via {state.hostname_source}"), + ("platform", state.platform.name, ""), + ("supported", state.platform.supported, "" if state.platform.supported else state.platform.reason), + ] + sections.append(("Platform", platform_rows)) + + ssh_rows: list[tuple] = [ + ("ssh impl", state.ssh_implementation, ""), + ("ssh version", state.ssh_version or "(unknown)", ""), + ("ssh path", state.ssh_path or "(not found)", ""), + ] + sections.append(("SSH", ssh_rows)) + primary_hint = "" + if state.gpg_main_socket and not state.gpg_primary_socket_is_ours: + primary_hint = "socket is outside our gpg homedir; keychain will NOT adopt this agent" + gpg_rows: list[tuple] = [ + ("gpg version", state.gpg_version or "(unknown)", ""), + ("gpg path", state.gpg_path or "(not found)", ""), + ("gpg ssh support", state.gpg_has_ssh_support, ""), + ("gpg ssh socket", state.gpg_ssh_socket or "(none)", ""), + ("gpg main socket", state.gpg_main_socket or "(none)", primary_hint), + ] + sections.append(("GPG", gpg_rows)) + + keyd_rows: list[tuple] = [ + ("keydir path", str(state.paths.keydir), ""), + ("keydir exists", state.keydir_exists, ""), + ] + if state.keydir_exists: + keyd_rows.append(("keydir writable", state.keydir_writable, "")) + sections.append(("Keychain Directory", keyd_rows)) + + perms_rows: list[tuple] = [] + for lbl, val, hint, sev in state.security_audit: + perms_rows.append((lbl.replace("_", " "), val, hint, sev)) + sections.append(("Permissions", perms_rows)) + + pidf_rows: list[tuple] = [ + ("pidfile path", str(state.pidfile_path), ""), + ("pidfile exists", state.pidfile_exists, ""), + ] + if state.pidfile_exists: + pidf_rows.append(("SSH_AUTH_SOCK", state.pidfile_socket or "(unset)", "")) + pidf_rows.append(("SSH_AGENT_PID", state.pidfile_pid or "(unset)", "")) + socket_validation = state.pidfile_socket_validation + sock_hint = "" if socket_validation.valid else f"rejected socket ({socket_validation.reason})" + pidf_rows.append(("socket valid", socket_validation.valid, sock_hint, socket_validation.severity)) + pid_hint = "" if state.pidfile_pid_alive else ("process is not running" if state.pidfile_pid else "") + pidf_rows.append(("pid alive", state.pidfile_pid_alive, pid_hint)) + if not state.process_listing_supported: + pidf_rows.append(("processes", "listing not available on this platform", "")) + else: + pidf_rows.append(("ssh-agent pids", _fmt_pids(state.ssh_agent_pids), "")) + gpg_hint = "" + if state.gpg_foreign_agents_present: + gpg_hint = "at least one is foreign (e.g. package-manager with --homedir); these are ignored by keychain" + pidf_rows.append(("gpg-agent pids", _fmt_pids(state.gpg_agent_pids), gpg_hint)) + sections.append(("Pidfile and Processes", pidf_rows)) + + term_w = shutil.get_terminal_size((80, 24)).columns + title_style = out.style("heading") + panels = [render_panel(title, _format_kv_rows(rows, out), title_style=title_style) for title, rows in sections] + out.line() + for line in compose_columns(panels, max(term_w - 2, 40)).splitlines(): + out.line(" " + line) + + out.heading("Loaded SSH keys (best available agent)") + fps = state.loaded_ssh_fingerprints + if fps: + header_style = out.style("heading", "dim") + table = render_table( + [[str(i + 1), fp] for i, fp in enumerate(fps)], + headers=["#", "fingerprint"], + indent=2, + header_style=header_style, + ) + for line in table.splitlines(): + out.line(line) + else: + if state.has_reachable_agent: + out.line(f" {out.dim('(none loaded)')}") + else: + out.line(f" {out.dim('(no agent reachable)')}") + + if state.cmdline_keys or state.confallhosts: + cli_repr = " ".join(state.cmdline_keys) or "(--confallhosts)" + miss = state.missing_keys + body = _format_kv_rows( + [ + ("ssh keys", ", ".join(state.ssh_keys) or "(none)", ""), + ("gpg keys", ", ".join(state.gpg_keys) or "(none)", ""), + ("missing", ", ".join(miss) or "(none)", "these keys could not be located" if miss else ""), + ], + out, + ) + out.line() + for line in render_panel(f"Resolved keys ({cli_repr})", body, title_style=title_style).splitlines(): + out.line(" " + line) + + out.line() + seen: set[tuple[str, str]] = set() + for _lbl, _val, hint, sev in state.security_audit: + if not hint or (sev, hint) in seen: + continue + seen.add((sev, hint)) + if sev == "warn": + out.warn(hint) + elif sev == "err": + out.error(hint) + out.line() + + +def _fmt_pids(pids: Any) -> str: + return ", ".join(str(p) for p in pids) if pids else "(none)" + + +def render_inspect_json(state: KeychainState) -> None: + """JSON form of :func:`render_inspect`. Prints one object on stdout.""" + payload: dict[str, Any] = { + "platform": { + "name": state.platform.name, + "supported": state.platform.supported, + "reason": state.platform.reason if not state.platform.supported else "", + "hostname": state.hostname, + "hostname_source": state.hostname_source, + }, + "ssh": { + "openssh": state.openssh, + "implementation": state.ssh_implementation, + "version": state.ssh_version, + "path": state.ssh_path, + }, + "gpg": { + "version": state.gpg_version, + "path": state.gpg_path, + "ssh_support": state.gpg_has_ssh_support, + "ssh_socket": state.gpg_ssh_socket or "", + "main_socket": state.gpg_main_socket or "", + "primary_socket_is_ours": state.gpg_primary_socket_is_ours, + }, + "pidfile": { + "path": str(state.pidfile_path), + "exists": state.pidfile_exists, + "ssh_auth_sock": state.pidfile_socket or "", + "ssh_agent_pid": state.pidfile_pid or "", + "socket_valid": state.pidfile_socket_valid, + "socket_reason": state.pidfile_socket_validation.reason, + "socket_severity": state.pidfile_socket_validation.severity, + "pid_alive": state.pidfile_pid_alive, + }, + "inherited": { + "ssh_auth_sock": state.inherited_socket or "", + "ssh_agent_pid": state.inherited_pid or "", + "socket_valid": state.inherited_socket_valid, + "socket_reason": state.inherited_socket_validation.reason, + "socket_severity": state.inherited_socket_validation.severity, + "pid_alive": state.inherited_pid_alive, + }, + "loaded_ssh_fingerprints": list(state.loaded_ssh_fingerprints), + "permissions": { + "keydir_path": str(state.paths.keydir), + "keydir_exists": state.keydir_exists, + "keydir_writable": state.keydir_writable if state.keydir_exists else False, + "keydir_lax_perms": state.keydir_lax_perms if state.keydir_exists else False, + "audit": [ + {"label": label, "value": value, "hint": hint} for label, value, hint, _sev in state.security_audit + ], + }, + } + if state.process_listing_supported: + payload["processes"] = { + "ssh_agent_pids": list(state.ssh_agent_pids), + "gpg_agent_pids": list(state.gpg_agent_pids), + "gpg_foreign_agents_present": state.gpg_foreign_agents_present, + } + if state.cmdline_keys or state.confallhosts: + payload["resolved_keys"] = { + "ssh": list(state.ssh_keys), + "gpg": list(state.gpg_keys), + "missing": list(state.missing_keys), + } + print(json.dumps(payload, default=str)) diff --git a/src/keychain/output/tables.py b/src/keychain/output/tables.py new file mode 100644 index 0000000..dc455ff --- /dev/null +++ b/src/keychain/output/tables.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Lightweight table renderer. + +Single public function :func:`render_table`. Box-drawing characters when the +terminal is UTF-8 capable; ASCII fallback otherwise. ANSI colour codes inside +cells are tolerated when computing column widths via :func:`visible_width`. + +Kept dependency-free and small on purpose -- a third-party ``tabulate`` / +``rich`` would dwarf the rest of ``src/keychain/`` and bloat ``keychain.pyz``. +""" + +from __future__ import annotations + +import re +import sys +from collections.abc import Iterable, Sequence + +# Strip ANSI CSI sequences when measuring column widths so coloured cells +# still align. We don't need a perfect parser -- ``\x1b[...m`` covers SGR. +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + +# (top, mid, bottom, vertical, horizontal, junction-h, junction-v, cross) +_BOX_UNICODE = ("─", "│", "╭┬╮", "├┼┤", "╰┴╯") +_BOX_ASCII = ("-", "|", "+++", "+++", "+++") + + +def visible_width(text: str) -> int: + """Length of *text* with ANSI SGR escapes removed.""" + return len(_ANSI_RE.sub("", text)) + + +def _use_unicode() -> bool: + enc = (getattr(sys.stderr, "encoding", "") or "").lower() + return "utf" in enc + + +def render_table( + rows: Sequence[Sequence[str]], headers: Sequence[str] | None = None, indent: int = 2, header_style: str = "" +) -> str: + """Render *rows* (and optional *headers*) as an aligned text table. + + Returns the rendered string (no trailing newline). Empty input yields ``""``. + Cells are coerced to ``str``; ANSI colour codes inside cells are honoured + and excluded from width calculations so coloured tables still align. + *header_style* is an ANSI escape applied to each header cell (cleared + with ``\\x1b[0m``); pass ``""`` for plain headers. + """ + body = [[str(c) for c in row] for row in rows] + if not body and not headers: + return "" + head = [str(c).upper() for c in headers] if headers else None + ncols = max((len(r) for r in body), default=0) + if head is not None: + ncols = max(ncols, len(head)) + # Pad short rows so zip-style logic works uniformly. + for r in body: + if len(r) < ncols: + r.extend([""] * (ncols - len(r))) + if head is not None and len(head) < ncols: + head = head + [""] * (ncols - len(head)) + + widths = [0] * ncols + for r in ([head] if head else []) + body: + for i, cell in enumerate(r): + w = visible_width(cell) + if w > widths[i]: + widths[i] = w + + box = _BOX_UNICODE if _use_unicode() else _BOX_ASCII + h, v, top, mid, bot = box + pad = " " * indent + + def hline(left_mid_right: str) -> str: + return pad + left_mid_right[0] + left_mid_right[1].join(h * (w + 2) for w in widths) + left_mid_right[2] + + def fmt_row(cells: Iterable[str]) -> str: + parts: list[str] = [] + for cell, w in zip(cells, widths): + extra = w - visible_width(cell) + parts.append(" " + cell + " " * extra + " ") + return pad + v + v.join(parts) + v + + lines: list[str] = [hline(top)] + if head is not None: + if header_style: + styled = [f"{header_style}{c}\x1b[0m" for c in head] + lines.append(fmt_row(styled)) + else: + lines.append(fmt_row(head)) + lines.append(hline(mid)) + for r in body: + lines.append(fmt_row(r)) + lines.append(hline(bot)) + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Panels (titled boxes) and column composition for control-panel layouts. +# Used by ``keychain inspect`` to render section blocks side-by-side when +# the terminal is wide enough; see ``docs/output-design.md``. +# --------------------------------------------------------------------------- + + +def render_panel( + title: str, + body_lines: Sequence[str], + title_style: str = "", + note: str = "", + note_style: str = "", + min_width: int = 0, + indent: int = 1, +) -> str: + """Render *body_lines* inside a rounded box with a titled top border. + + Output looks like:: + + ╭─ Title ───────────╮ + │ row 1 │ + │ row 2 │ + ╰───────────────────╯ + + ASCII fallback uses ``+``/``-``/``|`` like :func:`render_table`. ANSI + colour codes inside *body_lines* are honoured for width calculations. + """ + box = _BOX_UNICODE if _use_unicode() else _BOX_ASCII + h, v, top, _mid, bot = box + pad = " " * indent + + title_text = f" {title_style}{title}\x1b[0m " if title_style else f" {title} " + if note: + note_text = f"({note})" + if note_style: + title_text += f"{note_style}{note_text}\x1b[0m " + else: + title_text += f"{note_text} " + title_w = visible_width(title_text) + body_w = max([visible_width(ln) for ln in body_lines] + [title_w + 2, min_width]) + + fill = h * (body_w - title_w) + top_line = pad + top[0] + h + h + title_text + fill + top[2] + bot_line = pad + bot[0] + h * (body_w + 2) + bot[2] + rows = [top_line] + for ln in body_lines: + rows.append(pad + v + " " + ln + " " * (body_w - visible_width(ln)) + " " + v) + rows.append(bot_line) + return "\n".join(rows) + + +def compose_columns(panels: Sequence[str], term_width: int, gap: int = 2) -> str: + """Lay out pre-rendered *panels* into as many columns as fit *term_width*. + + Greedy first-fit: walks *panels* left to right, packing each into the + current row until adding the next would overflow, then starts a new row. + Within a row, panels are aligned at the top (shorter ones get blank + padding underneath) so column boundaries stay clean. + Returns a single string; rows are separated by a blank line. + """ + if not panels: + return "" + panel_lines = [p.splitlines() for p in panels] + widths = [max((visible_width(ln) for ln in pl), default=0) for pl in panel_lines] + + rows: list[list[int]] = [] + cur: list[int] = [] + cur_w = 0 + for i, w in enumerate(widths): + added = w if not cur else cur_w + gap + w + if cur and added > term_width: + rows.append(cur) + cur, cur_w = [i], w + else: + cur.append(i) + cur_w = added + if cur: + rows.append(cur) + + gap_str = " " * gap + out: list[str] = [] + for row in rows: + row_panels = [panel_lines[i] for i in row] + row_widths = [widths[i] for i in row] + n = max(len(p) for p in row_panels) + for line_idx in range(n): + parts: list[str] = [] + for pl, w in zip(row_panels, row_widths): + if line_idx < len(pl): + line = pl[line_idx] + parts.append(line + " " * (w - visible_width(line))) + else: + parts.append(" " * w) + out.append(gap_str.join(parts).rstrip()) + out.append("") + return "\n".join(out).rstrip() diff --git a/src/keychain/paths.py b/src/keychain/paths.py new file mode 100644 index 0000000..285cde0 --- /dev/null +++ b/src/keychain/paths.py @@ -0,0 +1,268 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Path & pidfile bundle for one (keydir, host) pair.""" + +from __future__ import annotations + +import os +import tempfile +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar + +from .env import SshAgentRef +from .util import ( + KeychainError, + Output, + get_owner, + lax_perm_warning, + lax_perms, + unlink_quiet, +) + + +@dataclass(frozen=True) +class Pidfile: + """A lightweight abstraction for a specific pidfile sequence.""" + + suffix: ClassVar[str] = "" + path: Path + ext: str + + def render(self, env: SshAgentRef) -> str: + """Subclasses override this.""" + return "" + + def write(self, env: SshAgentRef) -> None: + """Write the pidfile atomically via temp file + rename.""" + fd, tmp_name = tempfile.mkstemp(prefix=f".{self.path.name}.", suffix=".tmp", dir=self.path.parent, text=True) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(self.render(env)) + Path(tmp_name).replace(self.path) + except Exception: + unlink_quiet(tmp_name) + raise + + +class ShPidfile(Pidfile): + suffix = "-sh" + + def render(self, env: SshAgentRef) -> str: + parts = [] + if env.sock: + parts.append(f'SSH_AUTH_SOCK="{env.sock}"; export SSH_AUTH_SOCK') + if env.pid: + parts.append(f"SSH_AGENT_PID={env.pid}; export SSH_AGENT_PID;") + return ("\n".join(parts) + "\n") if parts else "" + + +class CshPidfile(Pidfile): + suffix = "-csh" + + def render(self, env: SshAgentRef) -> str: + parts = [] + if env.sock: + parts.append(f'setenv SSH_AUTH_SOCK "{env.sock}";') + if env.pid: + parts.append(f"setenv SSH_AGENT_PID {env.pid};") + return ("\n".join(parts) + "\n") if parts else "" + + +class FishPidfile(Pidfile): + suffix = "-fish" + + def render(self, env: SshAgentRef) -> str: + parts = [] + if env.sock: + parts.append(f'set -e SSH_AUTH_SOCK; set -x -U SSH_AUTH_SOCK "{env.sock}";') + if env.pid: + parts.append(f"set -e SSH_AGENT_PID; set -x -U SSH_AGENT_PID {env.pid};") + return ("\n".join(parts) + "\n") if parts else "" + + +class EnvfilePidfile(Pidfile): + suffix = "-envfile" + + def render(self, env: SshAgentRef) -> str: + parts = [] + if env.sock: + parts.append(f"SSH_AUTH_SOCK={env.sock}") + if env.pid: + parts.append(f"SSH_AGENT_PID={env.pid}") + return ("\n".join(parts) + "\n") if parts else "" + + +class JsonPidfile(Pidfile): + suffix = "-json" + + def render(self, env: SshAgentRef) -> str: + import json + + return json.dumps({"SSH_AUTH_SOCK": env.sock, "SSH_AGENT_PID": env.pid}) + "\n" + + +_PID_FACTORIES = { + "sh": ShPidfile, + "csh": CshPidfile, + "fish": FishPidfile, + "envfile": EnvfilePidfile, + "json": JsonPidfile, +} + + +def resolve_pidfile_class(shell_name: str) -> type[Pidfile]: + """Resolve a fuzzy shell name to the correct Pidfile subclass.""" + pf = _PID_FACTORIES.get(shell_name) + if pf: + return pf + if "fish" in shell_name: + return FishPidfile + if "csh" in shell_name: + return CshPidfile + if shell_name in ("env", "systemd"): + return EnvfilePidfile + return ShPidfile + + +@dataclass(frozen=True) +class KeychainPaths: + """All on-disk artefacts for a single keychain (keydir, host) pair.""" + + keydir: Path + host: str + pid_formats: tuple[str, ...] = ("sh", "csh", "fish", "envfile") + + # ---- construction -------------------------------------------------- + @classmethod + def build(cls, dir_opt: str | None, absolute: bool, host: str, pid_formats: str | None = None) -> KeychainPaths: + """Resolve the keychain directory from ``--dir`` / ``--absolute`` and *host*. + + The keydir is determined as follows: + + * No ``--dir``: use ``~/.keychain``. + * ``--dir PATH`` with ``--absolute``, or where *PATH* contains ``/.`` + (e.g. ``/tmp/.keychain``): use *PATH* verbatim (after ``~`` + expansion) — the caller is overriding the conventional layout. + * ``--dir PATH`` otherwise: append ``.keychain`` to the expanded + path, preserving the 2.x convention that ``--dir /tmp`` stored + files under ``/tmp/.keychain``. + """ + if dir_opt: + expanded = _expand_home(dir_opt) + # Preserve historic behaviour: a path containing "/." is taken verbatim, + # likewise --absolute. Otherwise we append ".keychain". + if absolute or "/." in dir_opt or dir_opt.startswith("/."): + base = expanded + else: + base = expanded / ".keychain" + else: + base = Path.home() / ".keychain" + + formats = tuple(fmt.strip() for fmt in (pid_formats or "sh,csh,fish,envfile").split(",") if fmt.strip()) + if "sh" not in formats: + formats = ("sh",) + formats + + return cls(keydir=base, host=host, pid_formats=formats) + + # ---- pidfile paths ------------------------------------------------- + def pidfile_path(self, fmt: str) -> Path: + """Construct the full path to a pidfile for a given format and host, AttributeError if no such pidfile""" + pidf_cls = _PID_FACTORIES.get(fmt) + if pidf_cls is None: + raise AttributeError(f"unknown pidfile format: {fmt}") + return self.keydir / f"{self.host}{pidf_cls.suffix}" + + @property + def all_pidfiles(self) -> tuple[Path, ...]: + """All supported process cache files for this host""" + return tuple(self.keydir / f"{self.host}{pf_cls.suffix}" for pf_cls in _PID_FACTORIES.values()) + + @property + def lockf(self): + return self.keydir / f"{self.host}-lockf" + + def render_env( + self, env: SshAgentRef | Mapping[str, str], shell: str = "env", shell_env: Mapping[str, str] | None = None + ) -> str: + """Render *env* in one of keychain's documented output formats.""" + agent_env = env if isinstance(env, SshAgentRef) else SshAgentRef.from_env(env) + shell = shell or "env" + if shell == "eval": + shell = os.path.basename((shell_env or os.environ).get("SHELL", "sh")) or "sh" + + pidf_cls = resolve_pidfile_class(shell) + return pidf_cls(Path(), shell).render(agent_env) + + def clear(self) -> None: + """Remove all runtime files for this keychain.""" + unlink_quiet(*self.all_pidfiles) + + def write(self, agent_env: SshAgentRef, out: Output) -> None: + """Write shell-specific pidfiles from the canonical agent env.""" + if not agent_env: + out.debug("skipping creation of pidfiles!") + return + + self.clear() + + for fmt in self.pid_formats: + pidf_cls = _PID_FACTORIES.get(fmt) + if pidf_cls: + pidf_cls(self.keydir / f"{self.host}-{fmt}", fmt).write(agent_env) + + # ---- directory verification --------------------------------------- + def verify_keydir(self, me: str, out: Output) -> None: + if self.keydir.is_file(): + raise KeychainError(f"{self.keydir} is a file (it should be a directory)") + if not self.keydir.is_dir(): + try: + self.keydir.mkdir(mode=0o700, parents=True) + except OSError as e: + raise KeychainError(f"can't create {self.keydir}: {e}") + + owner = get_owner(self.keydir) + if owner and owner != me: + raise KeychainError( + f"{self.keydir} is owned by {owner}, not {me}. " + "Remove or chown the directory and re-run keychain." + ) + if owner and lax_perms(self.keydir): + raise KeychainError(lax_perm_warning(self.keydir)) + + # Probe write permission inside keydir. + probe = self.pidfile_path("sh").with_suffix(f"{self.pidfile_path('sh').suffix}.probe") + try: + probe.touch() + except OSError: + raise KeychainError(f"can't write inside {self.keydir}") + unlink_quiet(probe) + + def check_pidfile_perms(self, me: str, out: Output) -> None: + """Verify pidfile ownership and hard-fail on lax permissions. + + Pidfile contents are ``eval``'d by the user's shell (via + ``keychain --eval``). A pidfile owned by another user is therefore + an arbitrary-code-execution vector and is treated as a hard error. + Group/world permissions on the pidfile or its directory make + replacement attacks possible, so they are also treated as hard + errors. + """ + for p in self.all_pidfiles: + if not p.is_file(): + continue + owner = get_owner(p) + if owner and owner != me: + raise KeychainError( + "{} is owned by {}, not {}; refusing to use it. " + "Remove or chown the file and re-run keychain.".format(p, owner, me) + ) + if owner and lax_perms(p): + raise KeychainError(lax_perm_warning(self.keydir)) + + +def _expand_home(path: str) -> Path: + # Use standard Path.expanduser() which correctly parses ~ and ~user on all platforms. + # It reads $HOME on POSIX if available before falling back to pwd. + p = Path(path).expanduser() + return p diff --git a/src/keychain/runtime/__init__.py b/src/keychain/runtime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/keychain/runtime/actions.py b/src/keychain/runtime/actions.py new file mode 100644 index 0000000..9ac1e49 --- /dev/null +++ b/src/keychain/runtime/actions.py @@ -0,0 +1,652 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Authored embedded-doc records for keychain. + +This is the single source of truth for every doc string keychain emits: +``--help`` cheat sheets, argparse ``help=`` strings, ``keychain man``, +``keychain --explain``, and the generated ``keychain.1`` man page. + +**Formatting rules:** + +To make keychain-specific principals stand out from the surrounding prose and +secondary commands, keychain documentation follows these formatting conventions: + +* Keychain actions and options/arguments should be in double-backticks (``like this``). +* External commands directly related to keychain (e.g. ``ssh-agent``, ``ssh-add``, ``ssh``, ``scp``) should also be in double-backticks. +* Ancillary commands that users are expected to know but aren't the focus of the docs (e.g. ``eval``, ``systemctl``) should be in asterisks (e.g. *eval*, *systemctl*). +* Configuration files and directories exclusive to keychain should be in + double-backticks (e.g. ``~/.keychain/``, ``~/.keychainrc``) +* More general files like ``~/.ssh/config`` should be emphasized with asterisks (e.g. *~/.ssh/config*, *~/.bash_profile*). +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, Optional + +from keychain.output.core import Output + +from ..doc_texts import DOC_TEXT + +OUTPUT_ACTIONS = frozenset(("man", "version", "help")) +NO_BANNER_ACTIONS = frozenset(("inspect", "list", "env")) + +UNSET = object() +ActionAdapter = Callable[[list[str], int, "Action", tuple[str, ...]], Optional[list[str]]] + + +def _help_action_adapter( + tokens: list[str], + index: int, + _action_node: Action, + consumed_sequence: tuple[str, ...], +) -> list[str]: + """Rewrite ``--help`` forms into canonical ``help ...`` argv. + + Why this exists: + ``--help`` is part of the public CLI surface, but internally ``help`` is a + real action with a normal positional ``help_target`` argument. Rewriting the + argv keeps that action as the single implementation path instead of teaching + the parser a separate help-only side channel. + + How it resolves: + if the user already selected an action path (for example ``add --help``), + that path becomes the help target. Otherwise the adapter consumes the + successive non-flag tokens after ``--help`` (for example ``--help agent + stop``) and turns them into the help target. + """ + if consumed_sequence: + return ["help", *consumed_sequence] + + target: list[str] = [] + i = index + 1 + while i < len(tokens): + tok = tokens[i] + if tok == "--" or tok.startswith("-"): + break + target.append(tok) + i += 1 + return ["help", *target] + + +def _version_action_adapter( + _tokens: list[str], + _index: int, + _action_node: Action, + _consumed_sequence: tuple[str, ...], +) -> list[str]: + """Rewrite version flags into canonical ``version`` argv. + + ``version`` does not take a positional target, so the canonical form is the + bare action token. + """ + return ["version"] + + +@dataclass(eq=False) +class Element: + varname: str = "" # variable name used to reference internal RuntimeConfig property + doc_tag: str | None = None # embedded-doc catalog key; derived from name if omitted + see_also: tuple[str, ...] = () # related action names or topic keys + + @property + def short_help(self) -> str: + return DOC_TEXT.short_help(self.doc_tag or "") + + @property + def doc_description(self) -> str: + return DOC_TEXT.description(self.doc_tag or "") + + +@dataclass(eq=False) +class Option(Element): + option: str | None = None # the primary CLI flag spelling, e.g. "--eval" + cli_aliases: tuple[str, ...] = () # additional flag spellings, e.g. ("-q",) + actions: set[Action] = field(default_factory=set) # action(s) this option belongs to + type: str = "bool" # value type: "bool" (store_true), "int", or "str" + default: object = None # default value injected into the args Namespace + choices: tuple[str, ...] = () # restricts argparse to this set of values + metavar: str | None = None # display name for the value in usage output + argparse_action: str | None = None # explicit argparse action= override (e.g. "store_true", "append") + exclusive_group: str | None = None # bucket key for add_mutually_exclusive_group() + env: str | None = None # environment variable to set when option is specified + hidden: bool = False # skip this option in visible help output; used for deprecated options + config_section: str | None = None # INI section name for config file binding + config_key: str | None = None # INI key override; defaults to name if omitted + examples: tuple[tuple[str, str], ...] = () # (description, command) pairs for docs + action_adapter: ActionAdapter | None = None # canonical argv rewriter for structural action-equivalent flags + deprecated: bool = False # deprecated options are auto-hidden and emit policy feedback when used + deprecation_message: str | Callable[[Any], str] | None = None # warning/error text for deprecated usage + deprecation_error: bool = False # deprecated options can hard-fail instead of warning + validator: tuple[Callable[[Any], bool], str | Callable[[Any], str]] | None = None # value rule + failure text + + @property + def option_formats(self): + out = self.option + for a in self.cli_aliases: + out += f", {a}" + return out + + @property + def argparse_flags(self) -> list[str]: + flags = [self.option] if self.option else [] + flags.extend(self.cli_aliases) + return flags + + @property + def takes_value(self) -> bool: + return self.type != "bool" + + @property + def effective_config_key(self) -> str: + return self.config_key or self.varname + + def __post_init__(self) -> None: + if not self.varname and self.option: + self.varname = self.option.lstrip("-").replace("-", "_") + if not self.doc_tag: + key = self.option.lstrip("-") if self.option else self.varname.replace("_", "-") + self.doc_tag = f"option:{key}" + if self.deprecated: + self.hidden = True + + self._cli_value: Any = UNSET + + for act in self.actions: + act.options[self.varname] = self + + def deprecation_notice(self, value: Any) -> str | None: + """Return the message to emit when this deprecated option is used. + + Why this exists: + the refactor goal is for options to own their own lifecycle policy. + Callers should not need separate switch statements just to remember + which legacy flags still exist and what guidance to print for them. + + How it is used: + ``RuntimeConfig`` calls this after a CLI value is accepted. The returned + text is either emitted as a warning or promoted to an error depending on + ``deprecation_error``. + + Why it resolves this way: + static strings cover common cases, while a callable allows a deprecated + option to tailor its guidance to the supplied value. + """ + if not self.deprecated: + return None + if callable(self.deprecation_message): + return self.deprecation_message(value) + if self.deprecation_message: + return self.deprecation_message + return f"{self.option or self.varname} is deprecated." + + def adapt_argv( + self, + tokens: list[str], + index: int, + action_node: Action, + consumed_sequence: list[str], + ) -> list[str] | None: + """Rewrite raw argv into canonical action-first form when requested. + + Why this exists: + some root flags are really alternate spellings of actions. Rewriting the + full argv into canonical action-first form lets the normal parser bind + action arguments and options without adding one-off parser state. + + How it is used: + ``RuntimeConfig`` scans root options before compat translation and asks + the matched structural option, if any, to return canonical argv. + + Why it resolves this way: + the option owns the knowledge of how its flag maps into action syntax, + so the parser only needs to apply the rewrite and then continue with one + ordinary parse flow. + """ + if self.action_adapter is None: + return None + return self.action_adapter(tokens, index, action_node, tuple(consumed_sequence)) + + def validate_value(self, value: Any) -> str | None: + """Return a validation error string when *value* violates option policy. + + Why this exists: + rules like "positive integer" are part of an option's authored meaning + and should live beside the option declaration, not inside unrelated + runtime coordinator code. + + How it is used: + ``RuntimeConfig`` calls this after coercing a CLI value. ``None`` means + the value is acceptable; any returned string becomes the user-facing + error for that option. + + Why it resolves this way: + a predicate plus message keeps validation lightweight and declarative + without inventing a larger schema or type system. + """ + if self.validator is None: + return None + predicate, message = self.validator + if predicate(value): + return None + if callable(message): + return message(value) + return message + + def _coerce(self, raw: str) -> Any: + """Coerce raw strings to the appropriate Python type based on self.type.""" + if self.type == "bool": + return raw.strip().lower() in ("true", "yes", "on", "1") + if self.type == "int": + try: + return int(raw.strip()) + except ValueError: + return None + return raw.strip() + + def resolve_value(self, rc_data: dict[str, dict[str, str]], environ: dict[str, str]) -> Any: + """O(1) Dynamic Lookup respecting the config hierarchy.""" + + # 1. CLI (Highest Priority) + if self._cli_value is not UNSET: + return self._cli_value + + # 2. Environment Variables + if self.env and self.env in environ: + return self._coerce(environ[self.env]) + + # 3. .keychainrc + if self.config_section and rc_data: + section = rc_data.get(self.config_section, {}) + effective_key = self.config_key or self.varname + if effective_key in section: + return self._coerce(section[effective_key]) + + # 4. Fallback Default + if self.default is not None: + return self.default + return False if self.type == "bool" else None + + def reset_cli(self) -> None: + """Reset the CLI-provided value, usually invoked before a compat fallback parse.""" + self._cli_value = UNSET + + +@dataclass(eq=False) +class Action(Element): + fq_name: str = "" + arguments: tuple[dict, ...] = () + examples: tuple[tuple[str, str], ...] = () + parent: Action | None = None + sub_actions: dict[str, Action] = field(default_factory=dict) + options: dict[str, Option] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.fq_name: + raise ValueError("Action requires a non-empty fq_name") + if not self.varname: + self.varname = self.fq_name.split()[-1] + if not self.doc_tag: + self.doc_tag = f"action:{self.fq_name}" + + if self.parent is not None: + self.parent.sub_actions[self.varname] = self + + def add_action(self, **kwargs) -> Action: + return Action(parent=self, **kwargs) + + def add_option(self, **kwargs) -> Option: + return Option(actions={self}, **kwargs) + + def lineage(self) -> tuple[Action, ...]: + """Return the authored action path from ``ROOT_ACTION`` to this node. + + Why this exists: + runtime option semantics should come from the action tree itself, not + from whole-tree searches that can accidentally pick up sibling options. + The lineage is the exact structural context the user selected. + + How it is used: + ``RuntimeConfig`` walks this chain to collect the options that are + actually visible for the active command: global options first, then each + ancestor node, then the terminal action. + + Why it resolves this way: + the parent pointers already encode the unique path through the action + tree. Reconstructing the path from parents avoids maintaining a second + registry or cache for "visible option sets". + """ + nodes: list[Action] = [] + node: Action | None = self + while node is not None: + nodes.append(node) + node = node.parent + nodes.reverse() + return tuple(nodes) + + def find_action(self, target: str | list[str] | None) -> Action | None: + """Resolve an authored action path by walking the tree from this node. + + Why this exists: + the refactor is deliberately moving away from side registries such as + ``ACTIONS`` and toward ``ROOT_ACTION`` as the single source of truth. + Callers should not have to reconstruct lookup logic or keep a second + mapping alive just to answer "what action does this token path name?". + + How it is used: + ``main.helpinfo()`` and option-promotion code can pass either a joined + path like ``"agent stop"`` or tokenized input like + ``["agent", "stop"]`` and receive the terminal authored + :class:`Action` node. + + How it resolves: + the method normalizes the incoming representation into tokens, then + walks ``sub_actions`` one token at a time. Returning ``None`` for an + unknown hop keeps lookup failures explicit and avoids reintroducing the + old global-registry fallback behavior. + """ + if target is None: + return self + + if isinstance(target, str): + tokens = [token for token in target.split() if token] + else: + tokens = [token for token in target if token] + + node: Action = self + for token in tokens: + next_node = node.sub_actions.get(token) + if next_node is None: + return None + node = next_node + return node + + @property + def dispatch_name(self) -> str: + """Return the concrete handler suffix that ``KeychainApp`` should call. + + Why this exists: + ``main.py`` still dispatches to methods with names like + ``_handle_agent_start_action``. That method naming is an implementation + detail, but the logic for translating an authored action node into that + suffix belongs with the action tree itself, not in a separate block of + string handling in the entrypoint. + + How it is used: + ``KeychainApp._resolve_action()`` asks the resolved terminal + :class:`Action` for its dispatch name and then looks up the matching + handler method. + + Why it resolves this way: + output-only top-level actions already have one-to-one handler names, so + they keep their fq-name unchanged. Nested actions collapse whitespace to + underscores because that matches the historical handler surface. The + property raises for ``ROOT_ACTION`` and grouping nodes such as + ``agent`` so callers cannot silently dispatch a non-terminal node. + """ + if self.fq_name == "global": + raise ValueError("ROOT_ACTION does not map to a concrete handler") + if self.sub_actions and self.fq_name not in OUTPUT_ACTIONS: + raise ValueError(f"{self.fq_name} is a grouping action and needs a subcommand") + if self.fq_name in OUTPUT_ACTIONS: + return self.fq_name + return self.fq_name.replace(" ", "_") + + @property + def command(self) -> str: + if self.fq_name == "global": + return "keychain" + return f"keychain {self.fq_name}" + + def _option_rows(self) -> list[tuple[str, str]]: + rows: list[tuple[str, str]] = [] + for opt in self.options.values(): + if opt.hidden: + continue + rows.append((opt.option_formats, opt.short_help)) + return rows + + def _print_option_section(self, title: str, out: Output) -> None: + rows = self._option_rows() + if not rows: + return + print() + print(f" {out.head(title)}") + width = max((len(label) for label, _ in rows), default=0) + for label, desc in rows: + print(f" {out.head(f'{label:<{width}}')} {out.format_doc(desc)}") + + def _print_child_option_sections(self, out: Output) -> None: + for child in self.sub_actions.values(): + child._print_option_section(f"Options for {child.varname}", out) + child._print_child_option_sections(out) + + def help(self, out: Output) -> None: + """Render the cheat-sheet view for this action. + + ``ROOT_ACTION`` keeps a dedicated top-level layout because users expect + the command overview to lead with action names and global flags rather + than the per-action command banner used by nested action help. + """ + if self.fq_name == "global": + print("Actions") + width = max((len(child.varname) for child in self.sub_actions.values()), default=0) + for child in self.sub_actions.values(): + print(f" {out.head(f'{child.varname:<{width}}')} {out.format_doc(child.short_help)}") + + rows = self._option_rows() + if rows: + print() + print("Global options") + width = max((len(label) for label, _ in rows), default=0) + for label, desc in rows: + print(f" {out.head(f'{label:<{width}}')} {out.format_doc(desc)}") + + print() + print(f"See {out.kbd('keychain man')} for full documentation, or") + print(f" {out.kbd('keychain man --list')} to list all available manual pages.") + print() + return + + print() + print(f" {out.head(self.command)} {out.format_doc(self.short_help)}") + + if self.sub_actions: + print() + print(f" {out.head('Sub-commands')}") + width = max((len(child.varname) for child in self.sub_actions.values()), default=0) + for child in self.sub_actions.values(): + print(f" {out.head(f'{child.varname:<{width}}')} {out.format_doc(child.short_help)}") + + self._print_option_section("Options", out) + self._print_child_option_sections(out) + + print() + print( + f" All global options also apply (e.g. {out.flag('--debug')}, " + f"{out.flag('--dir')}, {out.flag('--host')})." + ) + print(f" Run {out.kbd('keychain --help')} for action-specific flags.") + print(f" See {out.kbd('keychain man')} for full documentation, or") + print(f" {out.kbd('keychain man --list')} to list all available manual pages.") + print() + + +# ------------------------------------------------------------------------------- +# Global tree registry +# ------------------------------------------------------------------------------- +ROOT_ACTION = Action(fq_name="global", varname="global") + +ROOT_ACTION.add_option(option="--help", cli_aliases=("-h",), action_adapter=_help_action_adapter) +ROOT_ACTION.add_option(option="--version", cli_aliases=("-V",), action_adapter=_version_action_adapter) +ROOT_ACTION.add_option(option="--explain", see_also=("man",)) + +# Security gate: all KEYCHAIN_* env var ingestion is disabled by default. +# Set --allow-env (or -E) to permit KEYCHAIN_CONFIG, KEYCHAIN_THEME, +# KEYCHAIN_SSH_AGENT_ARGS, and KEYCHAIN_GPG_AGENT_ARGS to take effect. +ROOT_ACTION.add_option(option="--allow-env", cli_aliases=("-E",), type="bool", default=False) + +ROOT_ACTION.add_option(option="--quiet", cli_aliases=("-q",), config_section="output") +ROOT_ACTION.add_option(option="--debug", cli_aliases=("-D",), config_section="output") +ROOT_ACTION.add_option(option="--nocolor", cli_aliases=("--no-color",), config_section="output") +ROOT_ACTION.add_option(option="--theme", type="str", hidden=True, config_section="output") +ROOT_ACTION.add_option(option="--no-gui", cli_aliases=("--nogui",), config_section="output", hidden=True) + +# Deprecated NO-OPs +ROOT_ACTION.add_option(option="--gpg2", hidden=True, config_section="agent") +ROOT_ACTION.add_option(option="--absolute", hidden=True, config_section="paths") +ROOT_ACTION.add_option(option="--dir", type="str", default="~/.keychain", config_section="paths") +ROOT_ACTION.add_option(option="--host", type="str", config_section="paths") +ROOT_ACTION.add_option(option="--pid-formats", type="str", default="sh", config_section="paths") + +cmd_add = ROOT_ACTION.add_action( + fq_name="add", + examples=( + ("Add an SSH key (start agent if needed)", "keychain add ~/.ssh/id_ed25519"), + ("Just spawn an agent and emit shell env", "eval `$(keychain add --eval)`"), + ("Load a GPG key by ID", "keychain add gpgk:ABCDEF1234567890"), + ), + arguments=({"name": "keys", "nargs": "*"},), + see_also=("ssh-add(1)", "topic:extkeys", "topic:agents"), +) + +cmd_agent = ROOT_ACTION.add_action(fq_name="agent", see_also=("add", "topic:agents")) + +agent_start = cmd_agent.add_action( + fq_name="agent start", + examples=(("Start agent and emit env in current shell", "eval `$(keychain agent start --eval)`"),), + see_also=("add", "env", "topic:agents"), +) + +agent_stop = cmd_agent.add_action( + fq_name="agent stop", + examples=(("Stop only my own agents", "keychain agent stop --mine"),), + see_also=("topic:agents",), +) + +cmd_list = ROOT_ACTION.add_action(fq_name="list") +cmd_wipe = ROOT_ACTION.add_action(fq_name="wipe") +cmd_forget = ROOT_ACTION.add_action(fq_name="forget", arguments=({"name": "keys", "nargs": "*"},)) +cmd_env = ROOT_ACTION.add_action(fq_name="env") +cmd_inspect = ROOT_ACTION.add_action(fq_name="inspect", arguments=({"name": "keys", "nargs": "*"},)) +cmd_help = ROOT_ACTION.add_action(fq_name="help", arguments=({"name": "help_target", "nargs": "*"},), see_also=("man",)) +cmd_man = ROOT_ACTION.add_action(fq_name="man", arguments=({"name": "topics", "nargs": "*"},), see_also=("help",)) +cmd_version = ROOT_ACTION.add_action(fq_name="version") + +# Add shared options +Option( + option="--eval", actions={cmd_add, agent_start}, config_section="output", config_key="eval", see_also=("--systemd",) +) +Option(option="--systemd", actions={cmd_add, agent_start}, config_section="agent", see_also=("--eval",)) + +cmd_add.add_option(option="--quick", cli_aliases=("-Q",), config_section="agent") +cmd_add.add_option(varname="noask", option="--no-passphrase", cli_aliases=("--noask",), config_section="agent") +cmd_add.add_option(option="--confirm", config_section="agent", doc_tag="option:confirm") +cmd_add.add_option( + option="--timeout", + type="int", + config_section="agent", + doc_tag="option:timeout", + validator=(lambda value: value > 0, "--timeout requires a numeric argument greater than zero"), +) +cmd_add.add_option(varname="ignore_missing", option="--ignore-missing", config_section="keys") +cmd_add.add_option(option="--clear", hidden=True, config_section="agent", doc_tag="option:clear") +cmd_add.add_option(option="--extended", cli_aliases=("--ext", "-e"), hidden=True, config_section="keys") +cmd_add.add_option(option="--confallhosts", config_section="keys", doc_tag="option:confallhosts") + +Option(option="--ssh-allow-gpg", actions={cmd_add, agent_start}, hidden=True, config_section="agent") +Option(option="--ssh-spawn-gpg", actions={cmd_add, agent_start}, hidden=True, config_section="agent") +Option(option="--ssh-allow-forwarded", actions={cmd_add, agent_start}, hidden=True, config_section="agent") +Option( + varname="no_inherit", + option="--no-inherit", + cli_aliases=("--noinherit",), + actions={cmd_add, agent_start}, + hidden=True, + config_section="agent", +) +Option(option="--ssh-agent-socket", actions={cmd_add, agent_start}, type="str", hidden=True, config_section="agent") +Option( + option="--ssh-agent-args", + varname="ssh_args", + type="str", + config_section="agent.env", + actions={cmd_add, agent_start}, +) +Option( + option="--gpg-agent-args", + varname="gpg_args", + type="str", + config_section="agent.env", + actions={cmd_add, agent_start}, +) + +# Add/Agent start misslabeled globals +Option( + option="--lockwait", + type="int", + default=5, + actions={cmd_add, agent_start}, + config_section="lock", + validator=(lambda value: value >= 0, "--lockwait requires an argument zero or greater."), +) +Option( + option="--agents", + type="str", + argparse_action="append", + actions={cmd_add, agent_start}, + deprecated=True, + deprecation_message="--agents is deprecated, ignoring.", +) +Option( + option="--inherit", + type="str", + doc_tag="option:inherit", + actions={cmd_add, agent_start}, + deprecated=True, + deprecation_message="--inherit is deprecated, ignoring. Use --ssh-allow-forwarded, --noinherit as needed instead.", +) +Option( + option="--confhost", + type="str", + doc_tag="option:confhost", + actions={cmd_add, agent_start}, + deprecated=True, + deprecation_error=True, + deprecation_message=lambda value: f"--confhost is deprecated; use --extended host:{value} instead.", +) +Option( + option="--attempts", + type="str", + doc_tag="option:attempts", + actions={cmd_add, agent_start}, + deprecated=True, + deprecation_message="--attempts is now deprecated.", +) +Option(option="--no-lock", cli_aliases=("--nolock",), actions={cmd_add, agent_start}, config_section="lock") + +agent_stop.add_option(option="--mine", exclusive_group="target") +agent_stop.add_option(option="--others", exclusive_group="target") +agent_stop.add_option(option="--all", exclusive_group="target") + +cmd_list.add_option(varname="json", option="--json", doc_tag="option:list-json") +cmd_wipe.add_option(varname="wipe_ssh", option="--ssh", doc_tag="option:wipe-ssh") +cmd_wipe.add_option(varname="wipe_gpg", option="--gpg", doc_tag="option:wipe-gpg") + +cmd_env.add_option( + option="--shell", + type="str", + default="env", + cli_aliases=("--target",), + doc_tag="option:env-shell", + choices=("env", "sh", "csh", "fish", "systemd", "json", "eval"), +) +cmd_env.add_option(option="--json", doc_tag="option:env-json") + +cmd_inspect.add_option(option="--json", doc_tag="option:inspect-json") +cmd_version.add_option(option="--json", doc_tag="option:version-json") + +cmd_man.add_option(varname="list", option="--list", doc_tag="option:man-list") +cmd_man.add_option(varname="no_pager", option="--no-pager", doc_tag="option:man-no-pager") +cmd_man.add_option(varname="width", option="--width", type="int", doc_tag="option:man-width") +cmd_man.add_option(varname="man_groff", option="--groff", doc_tag="option:man-groff") diff --git a/src/keychain/runtime/compat.py b/src/keychain/runtime/compat.py new file mode 100644 index 0000000..3805b97 --- /dev/null +++ b/src/keychain/runtime/compat.py @@ -0,0 +1,238 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Compatibility shim: translate keychain 2.x flat-flag argv into new-style actions. + +The ``cli`` module exposes a single action-driven argparse tree (verbs like +``add``, ``stop``, ``wipe``). To preserve backwards compatibility with the +long-standing keychain 2.x flat-flag interface (``keychain --stop all``, +``keychain --wipe ssh``, ``keychain --list`` ...) we sniff incoming argv for +a recognized action token. When none is present we run the argv through +:func:`translate` -- which rewrites the *legacy action flags* into their +verb-equivalent positional form -- and re-invoke the parser on the translated +argv. The translation is otherwise a pass-through, so every non-action option +keeps working unchanged. + +This keeps a single internal parser (no branching on "old vs new" further down) +and lets us surface a hint showing the equivalent new command. +""" + +from __future__ import annotations + +from .actions import ROOT_ACTION + + +class Compat: + bool_actions = { + "--list": "list", + "-l": "list", + "--list-fp": "list", + "-L": "list", + "--query": "env", + "--ssh-rm": "forget", + "-r": "forget", + "--inspect": "inspect", + "--help": "help", + "-h": "help", + "--version": "version", + "-V": "version", + } + value_actions = ("--stop", "-k", "--wipe") + incomplete_explain_actions = { + "--wipe": (("wipe",), "--wipe expects one of: ssh, gpg, all."), + "--stop": (("agent", "stop"), "--stop expects one of: all, mine, others."), + "-k": (("agent", "stop"), "-k/--stop expects one of: all, mine, others."), + } + + def __init__(self, actions: tuple[str, ...]) -> None: + """Pass the tuple of known action verbs (e.g. ``('add', 'list', 'wipe', ...)``). + Only tokens in this set are treated as action names during translation.""" + self.actions = actions + self.short_actions = frozenset(flag[1:] for flag in self.bool_actions if len(flag) == 2) + self.short_actions |= frozenset(flag[1:] for flag in self.value_actions if len(flag) == 2) + + @classmethod + def build(cls) -> Compat: + """Create a ``Compat`` instance pre-loaded with the actions defined in ``actions.py``. + This is the normal entry point; use ``__init__`` directly only in tests.""" + return cls(tuple(ROOT_ACTION.sub_actions.keys())) + + def split_eq(self, token: str) -> tuple[str, str | None]: + """Split ``--flag=value`` into ``("--flag", "value")``, or return ``(token, None)`` unchanged. + Used internally by ``translate`` and ``explain`` to handle both ``--wipe=ssh`` and ``--wipe ssh`` forms.""" + if token.startswith("--") and "=" in token: + key, _, value = token.partition("=") + return key, value + return token, None + + def split_short_cluster(self, token: str) -> list[str] | None: + """Split a short-flag cluster like ``-qL`` into ``["-q", "-L"]``, but only when + the cluster contains at least one legacy action letter (e.g. ``L``, ``k``). + Returns ``None`` for clusters that contain no action letters, leaving them for argparse.""" + if not (len(token) > 2 and token.startswith("-") and not token.startswith("--")): + return None + letters = token[1:] + if not letters.isalpha() or not any(letter in self.short_actions for letter in letters): + return None + return [f"-{letter}" for letter in letters] + + def looks_new_style(self, argv: list[str]) -> bool: + """Return ``True`` if argv already starts with a known action verb (e.g. ``['add', ...]``).""" + for token in argv: + if token == "--": + return False + if token.startswith("-"): + continue + return token in self.actions + return False + + def translate(self, argv: list[str]) -> list[str]: + """Convert a legacy keychain 2.x argv into the new action-first form. + + Examples: ``['--list']`` -> ``['list']``, + ``['--wipe', 'ssh']`` -> ``['wipe', '--ssh']``, + ``['--stop', 'mine']`` -> ``['agent', 'stop', '--mine']``, + ``['id_ed25519']`` -> ``['add', 'id_ed25519']``. + Unrecognised tokens are passed through so argparse can reject them.""" + out_opts: list[str] = [] + out_keys: list[str] = [] + subcmd: str | None = None + sub_args: list[str] = [] + expanded: list[str] = [] + seen_dashdash = False + for token in argv: + if seen_dashdash: + expanded.append(token) + continue + if token == "--": + seen_dashdash = True + expanded.append(token) + continue + split = self.split_short_cluster(token) + if split is not None: + expanded.extend(split) + else: + expanded.append(token) + i = 0 + after_dashdash = False + while i < len(expanded): + token = expanded[i] + if after_dashdash: + out_keys.append(token) + i += 1 + continue + if token == "--": + after_dashdash = True + out_opts.append(token) + i += 1 + continue + key, eq_value = self.split_eq(token) + if key in self.bool_actions and eq_value is None: + if subcmd is None: + subcmd = self.bool_actions[key] + else: + out_opts.append(token) + i += 1 + continue + if key in self.value_actions: + value = eq_value + if value is None: + if i + 1 >= len(expanded): + out_opts.append(token) + i += 1 + continue + value = expanded[i + 1] + i += 2 + else: + i += 1 + if key in ("--stop", "-k"): + if subcmd is None: + subcmd = "agent" + if value == "all": + sub_args = ["stop"] + elif value in ("mine", "others"): + sub_args = ["stop", f"--{value}"] + else: + sub_args = ["stop", value] + else: + out_opts.append(token) + if eq_value is None: + out_opts.append(value) + else: + if subcmd is None: + subcmd = "wipe" + if value == "all": + sub_args = [] + elif value in ("ssh", "gpg"): + sub_args = [f"--{value}"] + else: + sub_args = [value] + else: + out_opts.append(token) + if eq_value is None: + out_opts.append(value) + continue + if token.startswith("-"): + out_opts.append(token) + else: + out_keys.append(token) + i += 1 + result = [subcmd or "add", *sub_args, *out_opts] + if out_keys: + result.extend(out_keys) + return result + + def equivalent_command(self, translated_argv: list[str]) -> str: + """Format a translated argv list as a shell-safe ``keychain `` string. + Used to show users the modern equivalent of the legacy command they ran.""" + import shlex + + return f"keychain {shlex.join(translated_argv)}" + + def explain(self, argv: list[str]) -> tuple[list[str], str | None, str | None] | None: + """Translate legacy argv for ``--explain`` mode, returning ``(argv, display_cmd, note)``. + + Unlike ``translate``, a bare value-taking action (e.g. ``--wipe`` with no argument) + is returned as-is with a note rather than consuming the next token as its value. + Returns ``None`` when argv is already in new-style form.""" + if self.looks_new_style(argv): + return None + for i, token in enumerate(argv): + if token == "--": + break + key, eq_value = self.split_eq(token) + mapped = self.incomplete_explain_actions.get(key) + if mapped is None or eq_value not in (None, ""): + continue + next_token = argv[i + 1] if eq_value is None and i + 1 < len(argv) else None + if next_token is not None and next_token != "--" and not next_token.startswith("-"): + continue + replacement, note = mapped + translated = argv[:i] + list(replacement) + argv[i + 1 :] + return translated, self.equivalent_command(list(replacement)), note + translated = self.translate(argv) + if translated != ["add", *argv]: + return translated, self.equivalent_command(translated), None + return None + + def action_after_key_hint(self, argv: list[str]) -> tuple[str, str] | None: + """Detect the keychain 2.x mistake of placing a legacy action flag after a key argument, + e.g. ``keychain id_ed25519 --list``. Returns ``(legacy_flag, new_action_name)`` so the + caller can show a targeted hint, or ``None`` if no such pattern is found.""" + seen_key = False + for i, token in enumerate(argv): + if token == "--": + return None + if token.startswith("-"): + key, _eq_value = self.split_eq(token) + if seen_key: + if key in self.bool_actions: + return key, self.bool_actions[key] + if key in self.value_actions: + translated = self.translate(argv[i:]) + return key, " ".join(translated) if translated else key + continue + if token: + seen_key = True + return None + + +COMPAT = Compat.build() diff --git a/src/keychain/runtime/config.py b/src/keychain/runtime/config.py new file mode 100644 index 0000000..2ab7833 --- /dev/null +++ b/src/keychain/runtime/config.py @@ -0,0 +1,598 @@ +# SPDX-License-Identifier: GPL-3.0-only +# keychain argument parser — no argparse dependency. +# Owns CLI parsing, .keychainrc layering, and effective-environment assembly. + +from __future__ import annotations + +import configparser +import os +from collections import defaultdict +from pathlib import Path +from typing import Any + +from .actions import ROOT_ACTION, UNSET, Action, Option +from .compat import COMPAT + + +class _CaseInsensitiveConfigParser(configparser.ConfigParser): + """ConfigParser that normalizes section names and keys to lowercase. + + This prevents user confusion when .keychainrc uses mixed-case section + or key names (e.g. ``[Agent]`` vs ``[agent]``) that the underlying + option tree normalizes to lowercase. + """ + + def optionxform(self, optionstr: str) -> str: + return optionstr.lower() + + +class ParserError(Exception): + """Raised when strict parsing hits an invalid flag or missing argument.""" + + pass + + +class OptionError(Exception): + """Raised when declarative option policy rejects a supplied option value. + + ``ParserError`` covers command-shape failures such as unknown flags. + ``OptionError`` is reserved for options whose authored policy says the + command is recognized but still invalid, such as a rejected value or a + deprecated option that now hard-fails. + """ + + pass + + +class RuntimeConfig: + """ + Fully-resolved Keychain configuration, taking into account CLI args. + (ENV and keychainrc layering to be bolted on in later phases). + + See docs/parser-design.md for architecture. + """ + + def __init__(self) -> None: + self.action_node: Action | None = None + self.action: str = "help" + self.parse_error: str | None = None + + # State stores for lazy lookup by Options + self.environ: dict[str, str] = {} + self.rc_data: dict[str, dict[str, str]] = {} + + self.positionals: list[str] = [] + self._parsed_positionals: dict[str, Any] = {} + self.rc_warnings: list[str] = [] + self.option_warnings: list[str] = [] + self._pending_option_errors: list[str] = [] + self.env: dict[str, str] = {} + + def apply_keychainrc(self, environ_overrides: dict[str, str] | None = None) -> None: + """Read .keychainrc and build the effective env mapping.""" + self.environ = environ_overrides if environ_overrides is not None else dict(os.environ) + self.env = dict(self.environ) + + # SECURITY: KEYCHAIN_* env vars are gated by --allow-env / -E. + # Direct access to os.environ for KEYCHAIN_* vars is prohibited — + # all such access must flow through the actions API so the + # --allow-env gate is enforced. + allow_env = self.get_value("allow_env") + + # Locate configuration file (KEYCHAIN_CONFIG is gated) + config_path = self.environ.get("KEYCHAIN_CONFIG") if allow_env else None + if config_path: + rc_path = Path(config_path) + else: + rc_path = Path(self.environ.get("HOME", "~")).expanduser() / ".keychainrc" + + # Validate sections layout using AST — keys stored lowercase for + # case-insensitive matching against the parser output. + all_options_by_section: dict[str, set[str]] = defaultdict(set) + + def _scan_sections(node: Action): + for opt in node.options.values(): + if opt.config_section: + all_options_by_section[opt.config_section.lower()].add( + opt.effective_config_key.lower() + ) + for child in node.sub_actions.values(): + _scan_sections(child) + + _scan_sections(ROOT_ACTION) + + # Parse .keychainrc with case-insensitive key/section matching + parser = _CaseInsensitiveConfigParser() + if rc_path.is_file(): + try: + parser.read(rc_path) + for section in parser.sections(): + if section not in all_options_by_section: + self.rc_warnings.append(f"Ignoring unknown section [{section}] in .keychainrc") + continue + + self.rc_data[section] = {} + for key, val in parser.items(section): + if key not in all_options_by_section[section]: + self.rc_warnings.append(f"Ignoring unknown key '{key}' in section [{section}]") + continue + self.rc_data[section][key] = val + except configparser.Error as e: + self.rc_warnings.append(f"Failed to parse {rc_path}: {e}") + + # Inject KEYCHAIN_ envs derived from runtime values (gated by --allow-env) + if allow_env: + ssh_args = self.get_value("ssh_args") + if ssh_args and "KEYCHAIN_SSH_AGENT_ARGS" not in self.env: + self.env["KEYCHAIN_SSH_AGENT_ARGS"] = str(ssh_args) + + gpg_args = self.get_value("gpg_args") + if gpg_args and "KEYCHAIN_GPG_AGENT_ARGS" not in self.env: + self.env["KEYCHAIN_GPG_AGENT_ARGS"] = str(gpg_args) + + def get_option(self, varname: str, action_node: Action | None = None) -> Option | None: + """Return the authored option visible from an action context. + + Why this exists: + multiple actions intentionally reuse varnames like ``json``. A pure + global lookup is therefore ambiguous and was one of the reasons the old + registry-based approach leaked the wrong option into callers. + + How it is used: + value reads, explicit-option checks, and output-mode selection all call + this helper instead of reaching into the tree ad hoc. + + How it resolves and why: + lookup walks the action lineage from ``ROOT_ACTION`` to the terminal + node. That yields exactly the options visible for the selected command: + globals first, then ancestor nodes, then the terminal action. We do not + search sibling branches because runtime semantics should come from the + chosen action path, not from unrelated parts of the tree. + """ + node = self.action_node if action_node is None else action_node + visible = self._visible_options(node or ROOT_ACTION) + return visible.get_by_varname(varname) + + def get_value(self, varname: str) -> Any: + """Resolve a parsed value dynamically (CLI -> Env -> RC -> Default).""" + # 1. Quick check if it's a positional argument mapped by name + if varname in self._parsed_positionals: + return self._parsed_positionals[varname] + + # 2. Check the active options mapped to this action + if self.action_node: + active_options = self._visible_options(self.action_node) + opt = self.get_option(varname, self.action_node) + if opt: + return opt.resolve_value(self.rc_data, self.environ) + + # 2.1 Check for exclusive group projection (e.g. "target" -> "mine") + for active_opt in active_options.values(): + if ( + active_opt.exclusive_group == varname + and active_opt.resolve_value(self.rc_data, self.environ) is True + ): + return active_opt.varname + + return None + + def _gather_options(self, node: Action): + class ActiveOptions: + def __init__(self, opts_by_flag, opts_by_varname): + self.by_flag = opts_by_flag + self.by_varname = opts_by_varname + + def get(self, flag: str) -> Option | None: + return self.by_flag.get(flag) + + def get_by_varname(self, varname: str) -> Option | None: + return self.by_varname.get(varname) + + def update(self, other): + self.by_flag.update(other.by_flag) + self.by_varname.update(other.by_varname) + + def values(self): + return self.by_varname.values() + + opts_flag = {} + opts_var = {} + for opt in node.options.values(): + if opt.option: + opts_flag[opt.option] = opt + for alias in opt.cli_aliases: + opts_flag[alias] = opt + opts_var[opt.varname] = opt + return ActiveOptions(opts_flag, opts_var) + + def _visible_options(self, node: Action): + """Return the options visible along the authored path to *node*. + + Why this exists: + runtime lookup should answer "which options are visible for this + command?" rather than "where is the first matching option anywhere in + the tree?". The visible set is therefore the union of option scopes + encountered while descending from ``ROOT_ACTION`` to the terminal node. + + How it is used: + ``get_option()``, ``get_value()``, and ``has_option()`` all build their + semantics from this merged view. + + Why it resolves this way: + later nodes on the lineage overwrite earlier ones, so an action-local + option cleanly overrides a same-named ancestor option without opening a + sibling-branch ambiguity. + """ + lineage = node.lineage() + visible = self._gather_options(lineage[0]) + for entry in lineage[1:]: + visible.update(self._gather_options(entry)) + return visible + + def has_option(self, name: str) -> bool: + """Check if an option was explicitly provided in the active context. + + The active action tree path wins over a same-named option elsewhere in + the tree, and exclusive-group projections remain visible through their + synthetic group name. + """ + opt = self.get_option(name) + if opt is not None and opt._cli_value is not UNSET: + return True + + if self.action_node: + active_options = self._visible_options(self.action_node) + for active_opt in active_options.values(): + if active_opt.exclusive_group == name and active_opt._cli_value is not UNSET: + return True + + return False + + def apply_option_policies(self, out) -> None: + """Emit recorded option warnings and raise the next recorded option error. + + Why this exists: + option declarations decide what is deprecated or invalid, but human- + facing output should still flow through the same coordinator and + ``Output`` object as the rest of the application. + + How it is used: + ``KeychainApp._resolve_action()`` calls this once after dispatch is + known. Warnings are emitted in order and the first pending hard failure + is raised as ``OptionError``. + + Why it resolves this way: + parse-time policy collection keeps validation close to the parser while + deferring presentation until runtime, which avoids printing from deep + inside parsing code and prevents duplicate warnings on repeated reads. + """ + while self.option_warnings: + out.warn(self.option_warnings.pop(0)) + if self._pending_option_errors: + raise OptionError(self._pending_option_errors.pop(0)) + + def _record_option_policy(self, opt: Option, value: Any) -> None: + """Convert declarative option metadata into pending warnings or errors. + + Validation and deprecation are both authored on ``Option`` objects. + This helper is called when an option is explicitly supplied so that the + parser records policy outcomes once and the runtime can later present + them without re-implementing option-specific logic. + """ + error = opt.validate_value(value) + if error: + self._pending_option_errors.append(error) + return + + notice = opt.deprecation_notice(value) + if not notice: + return + if opt.deprecation_error: + self._pending_option_errors.append(notice) + return + if notice not in self.option_warnings: + self.option_warnings.append(notice) + + def _adapt_action_argv( + self, tokens: list[str], action_node: Action, consumed_sequence: list[str] + ) -> list[str] | None: + """Rewrite structural action flags into canonical action-first argv. + + Why this exists: + flags like ``--help`` are user-facing aliases for real actions. Rather + than short-circuiting parse state and manually injecting fields like + ``help_target``, we rewrite argv into the same canonical shape that a + user could have typed directly. + + How it is used: + ``_canonicalize_argv()`` runs this before compat translation. If a root + structural option matches, its ``Option.action_adapter`` returns the + canonical argv that should be parsed normally. + + Why it resolves this way: + keeping the rewrite at the argv level means the existing action tree, + positional binding, and handler flow continue to do the real work. The + parser only has one normal execution path after canonicalization. + """ + root_options = self._gather_options(ROOT_ACTION) + + i = 0 + while i < len(tokens): + tok = tokens[i] + if tok == "--": + break + if not tok.startswith("-"): + i += 1 + continue + + opt = self._resolve_alias(tok, root_options) + if opt is None: + i += 1 + continue + + adapted = opt.adapt_argv(tokens, i, action_node, consumed_sequence) + if adapted is not None: + return adapted + + if opt.takes_value and "=" not in tok: + i += 2 + else: + i += 1 + + return None + + def _canonicalize_argv(self, tokens: list[str]) -> list[str]: + """Return the canonical argv to parse for this invocation. + + Why this exists: + both structural action flags (``--help``) and legacy compat forms + (``--list``, ``--stop mine``) are alternate spellings of the same + internal action tree. Canonicalizing them first keeps the rest of the + parser focused on one action-first grammar. + + How it is used: + ``_parse_with_compat()`` calls this once, then prescans and strictly + parses the returned argv as though the user had typed it directly. + + Why it resolves this way: + structural action adapters get first claim because they are part of the + current CLI surface. If none applies and no action path was found, the + older compat translator gets a chance to rewrite legacy 2.x forms. + """ + action_node, _active_options, consumed_sequence = self._prescan_actions(tokens) + adapted = self._adapt_action_argv(tokens, action_node, consumed_sequence) + if adapted is not None: + return adapted + if action_node == ROOT_ACTION: + return COMPAT.translate(tokens) + return tokens + + @classmethod + def resolve(cls, argv: list[str] | None = None) -> RuntimeConfig: + if argv is None: + import sys + + argv = sys.argv[1:] + + obj = cls() + try: + obj._parse_with_compat(argv) + except ParserError as exc: + obj.parse_error = obj._friendly_parse_error(str(exc)) + + obj.apply_keychainrc() + return obj + + def _friendly_parse_error(self, message: str) -> str: + """Convert strict parser failures into short user-facing messages. + + The public ``resolve()`` path is intentionally forgiving: callers get a + partially resolved config plus a parse-error string instead of an + exception. That lets the CLI return an intuitive exit code and a short + redirect to the relevant help page without dumping full help text. + """ + action = self.action_node.fq_name if self.action_node is not None and self.action_node != ROOT_ACTION else "add" + help_cmd = f"keychain help {action}" + + prefix = "Unrecognized flag '" + if message.startswith(prefix) and "'" in message[len(prefix) :]: + flag = message[len(prefix) :].split("'", 1)[0] + return f"Unrecognized option '{flag}'. Run '{help_cmd}' for more information." + + prefix = "Unrecognized argument '" + if message.startswith(prefix) and "'" in message[len(prefix) :]: + arg = message[len(prefix) :].split("'", 1)[0] + return f"Unrecognized argument '{arg}'. Run '{help_cmd}' for more information." + + return f"{message} Run '{help_cmd}' for more information." + + def _parse_with_compat(self, tokens: list[str]) -> None: + """ + Phase 1 & 2: Pre-scan for action verbs and trigger compat fallback if needed. + + Phase 1 runs `_prescan_actions` to attempt a new-style resolution. + Phase 2 checks if ANY action verbs were found. If `ROOT_ACTION` was still the current + node, it indicates legacy calls like `keychain ~/.ssh/id_rsa` or `keychain --eval`. + This is safely translated using `COMPAT.translate(tokens)` and the pre-scan is retried. + """ + self._reset_all_cli() + + tokens_to_parse = self._canonicalize_argv(tokens) + action_node, active_options, consumed_sequence = self._prescan_actions(tokens_to_parse) + + self.action_node = action_node + self.action = action_node.fq_name or "help" + + # Phase 3 & 4: Strict validation and positional binding + self._strict_parse(tokens_to_parse, action_node, active_options, consumed_sequence) + if self.action == "help" and self._parsed_positionals.get("help_target") == []: + self._parsed_positionals.pop("help_target", None) + + def _reset_all_cli(self) -> None: + def _reset(node: Action): + for opt in node.options.values(): + opt.reset_cli() + for child in node.sub_actions.values(): + _reset(child) + + _reset(ROOT_ACTION) + + def _prescan_actions(self, tokens: list[str]) -> tuple[Action, dict[str, Option], list[str]]: + """ + Phase 1: The Action-Seeking Pre-Scan + + Scans CLI tokens from left to right to build an action sequence. + Since the AST defines `takes_value` for every known option, it intelligently skips + over flags and their arguments, enabling us to cleanly determine the terminal action. + + Returns: + - The final Action node found + - A dictionary mapping active option strings to Option objects + - The sequence of action verbs consumed from tokens (for later removal). + """ + current_node = ROOT_ACTION + active_options = self._gather_options(current_node) + consumed_sequence = [] + + i = 0 + while i < len(tokens): + tok = tokens[i] + + if tok == "--": + break + + if tok.startswith("-"): + opt = self._resolve_alias(tok, active_options) + if opt and opt.takes_value: + if "=" in tok: + i += 1 + continue + i += 2 # skip flag and its positional argument + continue + i += 1 + continue + + # Non-flag positional, see if it maps to a sub-action in the current tree level + if tok in current_node.sub_actions: + current_node = current_node.sub_actions[tok] + active_options.update(self._gather_options(current_node)) + consumed_sequence.append(tok) + i += 1 + else: + # End of the action verb chain + break + + return current_node, active_options, consumed_sequence + + def _resolve_alias(self, flag: str, active_options) -> Option | None: + if "=" in flag: + flag = flag.split("=", 1)[0] + return active_options.get(flag) + + def _strict_parse( + self, tokens: list[str], action_node: Action, active_options, consumed_sequence: list[str] + ) -> None: + """ + Phase 3: Strict Option Validation + Phase 4: Positional Binding + + Traverses all tokens against the resolved action context. + It strictly validates flags against known active options and raises `ParserError` + for any unrecognized flags or missing arguments. + Consumed action verbs are discarded and any leftovers are collected into positionals. + Finally, Phase 4 runs `_map_positionals` to bind to the action arguments. + """ + positionals = [] + i = 0 + after_dashdash = False + action_verb_queue = list(consumed_sequence) + + while i < len(tokens): + tok = tokens[i] + + if after_dashdash: + positionals.append(tok) + i += 1 + continue + + if tok == "--": + after_dashdash = True + i += 1 + continue + + if tok.startswith("-"): + flag, _, inline_val = tok.partition("=") + opt = active_options.get(flag) + + if not opt: + # Temporary rescue for structural help/version flags if they aren't explicitly registered + if flag in ("-h", "--help"): + opt = active_options.get("--help") + if opt: + self._set_opt(opt, None) + i += 1 + continue + raise ParserError(f"Unrecognized flag '{tok}'") + + if opt.takes_value: + if inline_val: + self._set_opt(opt, inline_val) + elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"): + self._set_opt(opt, tokens[i + 1]) + i += 1 + else: + raise ParserError(f"Option '{flag}' requires an argument.") + else: + if inline_val: + raise ParserError(f"Option '{flag}' does not take a value.") + self._set_opt(opt, None) + i += 1 + else: + # Is it one of the action verbs we matched in phase 1? Expand and skip. + if action_verb_queue and tok == action_verb_queue[0]: + action_verb_queue.pop(0) + i += 1 + continue + + positionals.append(tok) + i += 1 + + self.positionals = positionals + self._map_positionals(positionals, action_node.arguments, action_node.fq_name) + + def _set_opt(self, opt: Option, val: str | None) -> None: + if opt.argparse_action == "append": + # Direct append against the _cli_value state layer + from .actions import UNSET + + if opt._cli_value is UNSET: + opt._cli_value = [] + opt._cli_value.append(val) + self._record_option_policy(opt, val) + elif val is not None: + coerced = opt._coerce(val) + if coerced is None and opt.type == "int": + raise ParserError(f"Option '{opt.option}' expects an integer.") + opt._cli_value = coerced + self._record_option_policy(opt, coerced) + elif opt.type == "bool": + opt._cli_value = True + self._record_option_policy(opt, True) + else: + opt._cli_value = val + self._record_option_policy(opt, val) + + def _map_positionals(self, positionals: list[str], arguments: tuple[dict, ...], action_name: str) -> None: + index = 0 + for arg in arguments: + name = arg["name"] + nargs = arg.get("nargs") + if nargs in ("*", "+"): + self._parsed_positionals[name] = positionals[index:] + return + else: + self._parsed_positionals[name] = positionals[index] if index < len(positionals) else None + index += 1 + + if index < len(positionals): + raise ParserError(f"Unrecognized argument '{positionals[index]}'") diff --git a/src/keychain/runtime/platform.py b/src/keychain/runtime/platform.py new file mode 100644 index 0000000..3565002 --- /dev/null +++ b/src/keychain/runtime/platform.py @@ -0,0 +1,176 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Runtime environment detection. + +Keychain manages POSIX agents (``ssh-agent``, ``gpg-agent``) and assumes a +POSIX-shaped userland (``ps``, ``kill``, UNIX-domain sockets, shell-style +environment files). Rather than scattering ``sys.platform`` checks across +process listing, signal installation and pidfile handling, we resolve the +host environment **once** at startup and let the rest of the codebase ask +a single :class:`Platform` for what it needs (or refuse to run). + +The detected platform is cached for the life of the process; tests that +need to override it can call :func:`reset` and then :func:`detect` with a +``platform_override`` argument. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess + + +class Platform: + """Resolved host environment. + + A :class:`Platform` is the single point of truth for everything the + rest of the codebase needs to know about the host: its short name, + the reason it can't run keychain (empty when supported), and *how* + to enumerate processes (:meth:`process_list`). Subclasses encapsulate + the strategy; callers never branch on platform identifiers. + """ + + name = "unknown" + reason = "" + + @property + def supported(self) -> bool: + """``True`` when keychain can manage agents on this host.""" + return not self.reason + + def process_list(self, pattern: re.Pattern, uid: int | None = None) -> list[int]: + """Return PIDs whose command name matches *pattern*. + + When *uid* is given, only processes owned by that UID are + returned. Unsupported platforms raise :class:`RuntimeError`; + in practice the CLI aborts before any caller reaches this. + """ + raise RuntimeError( + "process listing not available on {}: {}".format(self.name, self.reason or "unsupported platform") + ) + + def __repr__(self): # pragma: no cover - debugging aid + return f"" + + +class _PosixPlatform(Platform): + """POSIX-shaped userland: enumerate processes via ``ps``.""" + + def __init__(self, name: str) -> None: + self.name = name + + def process_list(self, pattern: re.Pattern, uid: int | None = None) -> list[int]: + pids: list[int] = [] + try: + proc = subprocess.Popen( + ["ps", "-A", "-o", "pid=,uid=,comm="], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + stdout, _ = proc.communicate() + except OSError: + return pids + for line in stdout.decode(errors="replace").splitlines(): + parts = line.split(None, 2) + if len(parts) < 3: + continue + try: + pid, proc_uid = int(parts[0]), int(parts[1]) + except ValueError: + continue + if uid is not None and proc_uid != uid: + continue + if pattern.search(parts[2].strip()): + pids.append(pid) + return pids + + +class _UnsupportedPlatform(Platform): + """Recognised platform on which keychain refuses to operate.""" + + def __init__(self, name: str, reason: str) -> None: + self.name = name + self.reason = reason + + +_UNSUPPORTED_WINDOWS_REASON = ( + "Native Windows is not a supported keychain target: there is no " + "ssh-agent process to manage in the POSIX sense, no UNIX-domain " + "socket layer, and the shell-eval contract assumes a POSIX shell. " + "Run keychain inside WSL, Cygwin or MSYS instead." +) + + +def _probe_ps() -> bool: + """Return True if ``ps`` is findable in PATH.""" + return shutil.which("ps") is not None + + +def _classify(platform_name: str, has_ps: bool | None = None) -> Platform: + """Classify *platform_name* into a concrete :class:`Platform`. + + *has_ps* controls whether ``ps`` is considered available; pass it + explicitly in tests to avoid probing the host's actual PATH. + When ``None`` (the default), :func:`_probe_ps` is called once. + """ + if has_ps is None: + has_ps = _probe_ps() + + p = platform_name.lower() + + # Native Windows is unsupported regardless of ps. + if p == "win32": + return _UnsupportedPlatform("windows", _UNSUPPORTED_WINDOWS_REASON) + + is_known_posix = ( + p.startswith(("linux", "darwin", "freebsd", "openbsd", "netbsd", "dragonfly", "sunos", "aix", "haiku", "gnu")) + or p == "cygwin" + or p.startswith("msys") + ) + + if is_known_posix: + if has_ps: + return _PosixPlatform(p) + return _UnsupportedPlatform( + p, + "ps(1) was not found in PATH on this {} system. " + "Ensure procps (Linux) or the system ps is installed " + "and on PATH.".format(p), + ) + + # Unknown platform: use ps if available; refuse with a clear message otherwise. + if has_ps: + return _PosixPlatform(p) + return _UnsupportedPlatform( + p, + "Unrecognized platform '{}' and ps(1) is not in PATH. " + "Keychain requires a POSIX-compatible userland with ps(1).".format(p), + ) + + +_cached: Platform | None = None + + +def detect(platform_override: str | None = None, has_ps: bool | None = None) -> Platform: + """Return the cached :class:`Platform` for this process. + + The first call performs detection (using ``sys.platform`` unless + *platform_override* is given) and caches the result. Subsequent calls + return the same instance. Tests can call :func:`reset` to clear the + cache between runs. + + *has_ps* is forwarded to :func:`_classify`; pass it in tests to + decouple detection from the host's actual PATH. + """ + global _cached + if _cached is None: + import sys + + _cached = _classify(platform_override or sys.platform, has_ps) + return _cached + + +def reset() -> None: + """Clear the cached detection. Intended for tests.""" + global _cached + _cached = None diff --git a/src/keychain/state.py b/src/keychain/state.py new file mode 100644 index 0000000..b00bbcb --- /dev/null +++ b/src/keychain/state.py @@ -0,0 +1,457 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""One-shot snapshot of every state probe keychain performs. + +A :class:`KeychainState` is built once per process by :mod:`keychain.main` +(after :class:`keychain.paths.KeychainPaths` is constructed) and replaces +the scattered free-function calls that previously re-checked the same +state at each use site. + +Every probe is wrapped behind a property that delegates to the existing +free function in :mod:`keychain.agents`, :mod:`keychain.paths`, +:mod:`keychain.runtime`, :mod:`keychain.util` or :mod:`keychain.keys`. +Results are memoised in ``self._cache`` so the runtime path and the +``keychain inspect`` action share work. +""" + +from __future__ import annotations + +import os +import shutil +import socket +from collections.abc import Mapping +from functools import cached_property +from typing import Any + +from . import agents, keys +from .env import SshAgentRef +from .paths import KeychainPaths +from .runtime.platform import Platform +from .runtime.platform import detect as detect_platform +from .util import ( + KeychainError, + Output, + current_user, + lax_perm_warning, + lax_perms, + pid_alive, + run, +) + +# Environment variables that influence (and are propagated by) ssh-agent. +_INHERITED_KEYS = ("SSH_AUTH_SOCK", "SSH_AGENT_PID") + + +def _resolve_host(args: Any, env: Mapping[str, str]) -> tuple[str, str]: + """Return ``(hostname, source)`` honoring ``--host`` > ``socket.gethostname()`` > ``$HOSTNAME``. + + *source* is one of ``"--host"``, ``"socket.gethostname()"``, ``"$HOSTNAME"``, + or ``"fallback"`` and is surfaced in ``keychain inspect``'s Host panel + so users can see *why* they got the keydir they got (which surfaces + bash's flaky $HOSTNAME export, container hostname inheritance, etc.). + """ + h = args.get_value("host") + if h: + return h, "--host" + try: + n = socket.gethostname() + if n: + return n, "socket.gethostname()" + except OSError: + pass + h = env.get("HOSTNAME") or "" + if h: + return h, "$HOSTNAME" + return "unknown", "fallback" + + +def _command_first_line(cmd: list[str]) -> str: + try: + r = run(cmd) + except (FileNotFoundError, OSError): + return "" + for line in (r.stdout + "\n" + r.stderr).splitlines(): + if line.strip(): + return line.strip() + return "" + + +class KeychainState: + """Lazy, memoised view of every probe keychain performs. + + Construction is cheap; nothing is probed until a property is read. + Each cached property does the work exactly once. + """ + + def __init__( + self, + paths: KeychainPaths, + env: Mapping[str, str] | None = None, + cmdline_keys: list[str] | None = None, + extended: bool = False, + confallhosts: bool = False, + hostname_source: str = "explicit", + user: str | None = None, + args: Any = None, + ) -> None: + self.paths = paths + self.env = dict(os.environ if env is None else env) + self.cmdline_keys = list(cmdline_keys or []) + self.extended = extended + self.confallhosts = confallhosts + self.hostname_source = hostname_source + self.user = user or current_user() + # ``args`` is the fully-resolved ParsedArgs; the agent classes use + # it to read run-flag options (timeout, confirm, nogui, ...) without + # threading every flag through their constructors. Tests that build + # KeychainState directly (without args) rely on agent methods only + # via `getattr(args, X, default)` reads inside SshAgent / GpgAgent. + self.args = args + self.out: Output | None = None # set by build(); needed by ssh/gpg + + # ---- one-call builder used by the CLI ----------------------------- + + @classmethod + def build(cls, args: Any, out: Output | None = None) -> KeychainState: + """Resolve host + paths + perms in one call. + + Uses ``args.env`` (assembled by ``ParsedArgs.apply_config()``) as the + effective process environment. This is the single entry point used by + the CLI; tests that exercise state probes directly should construct + :class:`KeychainState` with an explicit ``env=`` instead. + """ + env_map = dict(args.env) + host, source = _resolve_host(args, env_map) + paths = KeychainPaths.build( + dir_opt=args.get_value("dir"), + absolute=bool(args.get_value("absolute")), + host=host, + pid_formats=args.get_value("pid_formats"), + ) + me = current_user() + if not me: + raise KeychainError("Who are you? Can't determine username.") + k = cls( + paths=paths, + env=env_map, + cmdline_keys=list(args.get_value("keys") or []), + extended=bool(args.get_value("extended")), + confallhosts=bool(args.get_value("confallhosts")), + hostname_source=source, + user=me, + args=args, + ) + k.out = out + if out is not None: + paths.check_pidfile_perms(me, out) + return k + + # ---- hostname (resolved by ``build``; reflected back for inspect) - + + @property + def hostname(self) -> str: + return self.paths.host + + # ---- agent façades (lazy; require out, which build() supplies) ---- + + @cached_property + def ssh(self) -> agents.SshAgent: + if self.out is None: + raise RuntimeError("KeychainState.ssh requires build() with out=") + return agents.SshAgent(self, self.out) + + @cached_property + def gpg(self) -> agents.GpgAgent: + if self.out is None: + raise RuntimeError("KeychainState.gpg requires build() with out=") + return agents.GpgAgent(self, self.out) + + # ---- platform ------------------------------------------------------ + + @cached_property + def platform(self) -> Platform: + return detect_platform() + + # ---- ssh / gpg implementation detection --------------------------- + + @cached_property + def openssh(self) -> bool: + return agents.detect_ssh() + + @property + def ssh_implementation(self) -> str: + if self.openssh: + return "OpenSSH" + return "(unknown)" + + @cached_property + def ssh_version(self) -> str: + return _command_first_line(["ssh", "-V"]) + + @cached_property + def ssh_path(self) -> str: + return shutil.which("ssh") or "" + + @cached_property + def gpg_has_ssh_support(self) -> bool: + return agents.gpg_has_ssh_support() + + @cached_property + def gpg_prog(self) -> str: + return agents.choose_gpg_prog(bool(self.args.get_value("gpg2")) if self.args is not None else False, self.env) + + @cached_property + def gpg_version(self) -> str: + return _command_first_line([self.gpg_prog, "--version"]) + + @cached_property + def gpg_path(self) -> str: + return shutil.which(self.gpg_prog) or "" + + @cached_property + def gpg_ssh_socket(self) -> str: + return agents.gpg_ssh_socket(self.env) + + @cached_property + def gpg_main_socket(self) -> str: + return agents.gpg_main_socket(self.env) + + # ---- running agent processes -------------------------------------- + + @property + def process_listing_supported(self) -> bool: + return self.platform.supported + + @cached_property + def ssh_agent_pids(self) -> list[int]: + if not self.process_listing_supported: + return [] + return agents.findpids("ssh") + + @cached_property + def gpg_agent_pids(self) -> list[int]: + if not self.process_listing_supported: + return [] + return agents.findpids("gpg") + + @property + def gpg_primary_socket_is_ours(self) -> bool: + """True if the gpg-agent socket reported by ``GETINFO socket_name`` + lives under one of the user's gpg homedirs (``$GNUPGHOME``, + ``$HOME/.gnupg`` or ``/run/user//gnupg``). When False, any + running gpg-agents owned by us were started by a third party + (typically a package manager via ``--homedir /var/tmp/…``) and + must not be adopted as a keychain agent. + """ + sock = self.gpg_main_socket + return bool(sock) and agents.gpg_socket_is_primary(sock, self.env) + + @property + def gpg_foreign_agents_present(self) -> bool: + """True when there is at least one gpg-agent we wouldn't adopt. + + Two scenarios trigger this: + + * The primary socket isn't ours -- every running gpg-agent is + foreign (package-manager / sandbox). + * The primary socket is ours, but there are *extra* gpg-agent + pids besides the one backing it. gpg-agent is single-instance + per homedir, so any extras necessarily live under a different + ``--homedir`` and are foreign. + """ + pids = self.gpg_agent_pids + if not pids: + return False + if not self.gpg_primary_socket_is_ours: + return True + return len(pids) > 1 + + # ---- pidfile ------------------------------------------------------ + # NOTE: These properties are specific to the "canonical" pidfile at + # ~/.keychain/-sh, which is used as the source of truth for the + # running agent we adopt. + + @property + def pidfile_path(self): + return self.paths.pidfile_path("sh") + + @property + def pidfile_exists(self) -> bool: + return self.pidfile_path.is_file() + + @property + def pidfile_content(self) -> str: + try: + return self.pidfile_path.read_text(encoding="utf-8") + except OSError: + return "" + + @property + def pidfile_env(self) -> SshAgentRef: + return SshAgentRef.from_text(self.pidfile_content) + + @property + def pidfile_socket(self) -> str: + return self.pidfile_env.sock + + @property + def pidfile_pid(self) -> str: + return self.pidfile_env.display_pid + + @property + def pidfile_socket_valid(self) -> bool: + return self.pidfile_socket_validation.valid + + @property + def pidfile_socket_validation(self) -> agents.SocketValidation: + return agents.validate_ssh_socket(self.pidfile_socket) + + @property + def pidfile_pid_alive(self) -> bool: + pid = self.pidfile_pid + if not pid: + return False + pid_int = self.pidfile_env.pid_int + return bool(pid_int and pid_alive(pid_int)) + + # ---- inherited shell environment ---------------------------------- + + @property + def inherited_env(self) -> SshAgentRef: + return SshAgentRef.from_env({k: self.env[k] for k in _INHERITED_KEYS if self.env.get(k)}) + + @property + def inherited_socket(self) -> str: + return self.inherited_env.sock + + @property + def inherited_pid(self) -> str: + return self.inherited_env.display_pid + + @property + def inherited_socket_valid(self) -> bool: + return self.inherited_socket_validation.valid + + @property + def inherited_socket_validation(self) -> agents.SocketValidation: + return agents.validate_ssh_socket(self.inherited_socket) + + @property + def inherited_pid_alive(self) -> bool: + pid = self.inherited_pid + if not pid: + return False + pid_int = self.inherited_env.pid_int + return bool(pid_int and pid_alive(pid_int)) + + # ---- agent contents (loaded keys) --------------------------------- + + @property + def find_active_agent_env(self) -> SshAgentRef: + """The single source of truth for which SSH agent keychain should talk to right now. + + It performs a prioritized fallback: it first uses the agent tracked in + Keychain's own pidfile if alive (the managed agent), then falls back to + an inherited agent from the invoking shell if valid (e.g. from X11 forwarding). + If neither is reachable, it returns an empty environment to signal a new + agent needs to be spawned. + """ + if self.pidfile_socket_valid: + return self.pidfile_env + if self.inherited_socket_valid: + return self.inherited_env + return SshAgentRef() + + @property + def has_reachable_agent(self) -> bool: + """True if a live ssh-agent socket is reachable (pidfile or inherited).""" + return bool(self.find_active_agent_env) + + @cached_property + def loaded_ssh_fingerprints(self) -> list[str]: + env = self.find_active_agent_env + if not env: + return [] + fps, _ = agents.ssh_l(env.as_dict()) + return fps + + # ---- keychain dir ------------------------------------------------ + + @property + def keydir_exists(self) -> bool: + return self.paths.keydir.is_dir() + + @property + def keydir_writable(self) -> bool: + return self.keydir_exists and os.access(str(self.paths.keydir), os.W_OK) + + @property + def keydir_lax_perms(self) -> bool: + return self.keydir_exists and lax_perms(self.paths.keydir) + + # ---- security audit ---------------------------------------------- + + @property + def security_audit(self) -> list[tuple[str, str, str, str]]: + """File ownership and permission rows for the Permissions section. + + Each tuple is ``(label, value, hint, severity)`` where *severity* is + ``""`` (info/neutral), ``"warn"`` or ``"err"``. An empty *hint* always + means the check passed. Rows are derived purely from already-collected + :class:`KeychainState` data -- no additional probes are run. + + GPG socket / foreign-agent checks are intentionally *absent* here: + those facts are already surfaced in the GPG panel (``main socket`` + hint) and the Processes panel (``gpg-agent pids`` hint). + """ + from .output.inspect import _mode_row, _owner_row + + me = current_user() + o_rows: list[tuple[str, str, str, str]] = [] + m_rows: list[tuple[str, str, str, str]] = [] + + # Keydir owner + perms + if self.keydir_exists: + o_rows.append(_owner_row("keydir_owner", self.paths.keydir, me)) + m_rows.append(_mode_row("keydir_perms", self.paths.keydir, lax_perm_warning(self.paths.keydir))) + + # Pidfile owner + perms (sh format only -- the three pidfiles share perms) + if self.pidfile_exists: + sh_path = self.paths.pidfile_path("sh") + o_rows.append(_owner_row("pidfile_owner", sh_path, me)) + m_rows.append(_mode_row("pidfile_perms", sh_path, lax_perm_warning(self.paths.keydir))) + + # ssh-agent socket from the pidfile (the agent we'd actually adopt) + sock = self.pidfile_socket + if sock and self.pidfile_socket_valid: + o_rows.append(_owner_row("ssh_socket_owner", sock, me)) + + return o_rows + m_rows + + # ---- key resolution (reflects user's --extended / cmdline) ------- + + @cached_property + def resolved_keys(self) -> keys.ResolvedKeys: + """Resolved SSH/GPG/missing keys for the user's args.""" + if not (self.cmdline_keys or self.confallhosts): + return keys.ResolvedKeys([], [], [], [], [], []) + return self.resolve_requested_keys(Output.silent()) + + def resolve_requested_keys(self, out: Output, *, gpg_lookup: bool = True) -> keys.ResolvedKeys: + if not (self.cmdline_keys or self.confallhosts): + return keys.ResolvedKeys([], [], [], [], [], []) + gpg_prog = self.gpg_prog if gpg_lookup else "gpg" + return keys.resolve_requested_keys( + self.confallhosts, self.extended, self.cmdline_keys, gpg_prog, out, gpg_lookup=gpg_lookup + ) + + @property + def ssh_keys(self) -> list[str]: + return list(self.resolved_keys.ssh) + + @property + def gpg_keys(self) -> list[str]: + return list(self.resolved_keys.gpg) + + @property + def missing_keys(self) -> list[str]: + return list(self.resolved_keys.missing) diff --git a/src/keychain/util.py b/src/keychain/util.py new file mode 100644 index 0000000..f704ca9 --- /dev/null +++ b/src/keychain/util.py @@ -0,0 +1,317 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Shared utilities: exceptions, output, locking, small POSIX helpers. + +Targets Python 3.9+ (RHEL 8 users opt in via ``dnf module install python39``). +""" + +from __future__ import annotations + +import contextlib +import ctypes +import os +import socket +import stat +import subprocess +import sys +import time +from collections.abc import Iterable +from pathlib import Path +from typing import Any, Union, cast + +try: + import pwd as _pwd_impl # POSIX +except ImportError: # Windows / Git Bash on native Python + _pwd: Any | None = None +else: + _pwd = _pwd_impl + + +PathLike = Union[str, "os.PathLike[str]"] + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class KeychainError(Exception): + """Raised for user-visible fatal errors. Caught once in :func:`cli.main`.""" + + +# --------------------------------------------------------------------------- +# Output / colors / themes -- moved to keychain.output. Re-exported here so +# ``from keychain.util import Output`` keeps working through the deprecation +# window. New code should import from ``keychain.output`` directly. +# --------------------------------------------------------------------------- + +from .output.core import ( # noqa: E402,F401 (re-export for back-compat) + DEFAULT_THEME, + THEMES, + Output, + Span, + stderr_supports_unicode, +) + + +# Back-compat: docs/render.py imports ``resolve_theme`` and uses the +# legacy-palette dict shape (``{'CYANN': '...', 'OFF': '...'}``). The new +# :class:`~keychain.output.Theme` exposes that as ``Theme.palette``. +def resolve_theme(name): # type: ignore[no-untyped-def] + """Return the legacy palette dict for *name*; fall back to default.""" + if name: + key = name.strip().lower() + if key in THEMES: + return dict(THEMES[key].palette) + return dict(THEMES[DEFAULT_THEME].palette) + + +# --------------------------------------------------------------------------- +# Subprocess wrapper +# --------------------------------------------------------------------------- + + +def run( + cmd: list[str], + env: dict[str, str] | None = None, + input_: str | None = None, + timeout: float | None = None, + c_locale: bool = True, +) -> subprocess.CompletedProcess: + """Run ``cmd``, capturing text output. Returns ``CompletedProcess``. + + ``c_locale=True`` forces ``LC_ALL=C`` for the child only, so the caller's + locale is never mutated. Raises :class:`FileNotFoundError` if the binary + is missing -- callers decide how to react. + """ + run_env = {**os.environ, **(env or {})} | ({"LC_ALL": "C"} if c_locale else {}) + return subprocess.run( + cmd, + input=input_, + env=run_env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + ) + + +# --------------------------------------------------------------------------- +# Lockfile +# --------------------------------------------------------------------------- + + +def _lock_content() -> str: + """Lock-file payload: ``hostname:pid``. + + Including the hostname makes the lock NFS-safe: a reader on a different + host that finds a lock written here will see a hostname mismatch and + leave the lock alone rather than running ``os.kill`` against an + unrelated local process that happens to share the same PID. + """ + return f"{socket.gethostname()}:{os.getpid()}" + + +class LockFile: + """Atomic ``O_CREAT | O_EXCL`` PID lock. Use as a context manager. + + ``no_lock=True`` makes the manager a no-op (still safe to use). The acquired + state is exposed via :attr:`acquired` and the lock is released on exit. + """ + + __slots__ = ("path", "no_lock", "wait", "out", "acquired") + + def __init__(self, path: PathLike, no_lock: bool, wait: int, out: Output) -> None: + self.path = Path(path) + self.no_lock = no_lock + self.wait = max(0, int(wait)) + self.out = out + self.acquired = False + + # ---- context manager ---------------------------------------------- + def __enter__(self) -> LockFile: + if self.no_lock: + self.acquired = True # granted without writing a file + return self + if self._acquire(): + return self + self.out.info(f"Waiting {self.wait} seconds for lock...") + deadline = time.monotonic() + self.wait + while time.monotonic() < deadline: + if self._acquire(): + return self + time.sleep(0.1) + # Final break-the-glass attempt: drop a stale lock and retry once. + # With wait=0, force-takeover is the default behavior (gap §3.6). + # With wait>0, only unlink if the lock is stale to avoid stomping + # on a valid lock held by another process that happened to create + # the file during the wait loop. + if self.wait == 0 or not self._lock_is_live(): + with contextlib.suppress(OSError): + self.path.unlink() + if not self._acquire(): + raise KeychainError(f"could not acquire lock {self.path}") + return self + + def __exit__(self, exc_type, exc, tb): + self.release() + return False + + # ---- internals ----------------------------------------------------- + def _acquire(self) -> bool: + try: + fd = os.open(str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError: + # Lock exists; honour it only if the owning process is still live. + if self._lock_is_live(): + return False + with contextlib.suppress(OSError): + self.path.unlink() + try: + fd = os.open(str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except OSError: + return False + try: + os.write(fd, _lock_content().encode()) + finally: + os.close(fd) + self.acquired = True + return True + + def _lock_is_live(self) -> bool: + """Return True if the existing lock file belongs to a live process. + + The lock file payload is ``hostname:pid``. If the hostname differs + from ours (NFS-mounted home directory shared across hosts) we cannot + call ``os.kill`` against a remote PID, so we conservatively treat the + lock as live and leave it alone. A legacy file containing only a + plain PID (no ``:``) is treated as originating on the local host. + """ + try: + raw = self.path.read_text(encoding="utf-8").strip() + except OSError: + return False + hostname, sep, pid_s = raw.partition(":") + if not sep: + # Legacy plain-PID format -- assume local host. + hostname, pid_s = socket.gethostname(), raw + try: + owner_pid = int(pid_s) + except ValueError: + return False + if not owner_pid: + return False + if hostname != socket.gethostname(): + # Lock is held on a different host; cannot verify liveness. + return True + return pid_alive(owner_pid) + + def release(self) -> None: + if self.acquired: + if not self.no_lock: + with contextlib.suppress(OSError): + self.path.unlink() + self.acquired = False + + +# --------------------------------------------------------------------------- +# Small POSIX helpers +# --------------------------------------------------------------------------- + + +def pid_alive(pid: int) -> bool: + """Best-effort process liveness probe.""" + if pid <= 0: + return False + if os.name == "nt": + kernel32 = cast(Any, ctypes).windll.kernel32 + handle = kernel32.OpenProcess(0x1000, False, pid) + if not handle: + return False + try: + exit_code = ctypes.c_ulong() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): + return False + return exit_code.value == 259 + finally: + kernel32.CloseHandle(handle) + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def get_owner(path: PathLike) -> str: + """Return the username that owns *path*, or '' on error / non-POSIX.""" + if _pwd is None: + return "" + try: + return _pwd.getpwuid(os.stat(path).st_uid).pw_name + except (OSError, KeyError): + return "" + + +def current_uid() -> int | None: + """Numeric user ID for the current process, if available.""" + return os.getuid() if hasattr(os, "getuid") else None + + +def current_user() -> str: + """Best-effort username for the current process.""" + uid = current_uid() + if _pwd is not None and uid is not None: + try: + return _pwd.getpwuid(uid).pw_name + except (KeyError, OSError): + pass + return os.environ.get("USER") or os.environ.get("LOGNAME") or os.environ.get("USERNAME") or "" + + +def get_tty() -> str: + """Controlling tty device (POSIX only) or ''.""" + if not hasattr(os, "ttyname"): + return "" + try: + return os.ttyname(sys.stdin.fileno()) + except OSError: + return "" + + +def lax_perms(path: PathLike) -> bool: + """True if *path* is group/world readable, writable or executable.""" + try: + mode = stat.S_IMODE(os.stat(path).st_mode) + except OSError: + return False + return bool(mode & (stat.S_IRWXG | stat.S_IRWXO)) + + +def lax_perm_warning(keydir: PathLike) -> str: + """Canonical warning text for a keychain dir / pidfile with lax perms. + + Single source of truth for both the runtime add-path warnings + (:meth:`keychain.paths.KeychainPaths.ensure_keydir` / + :meth:`check_pidfile_perms`) and the ``inspect`` action's post-panel + audit warnings, so the wording can't drift between code paths. + """ + return f"Keychain dir has lax permissions. Use chmod -R go-rwx '{keydir}' to fix." + + +def unlink_quiet(*paths: PathLike) -> None: + for p in paths: + with contextlib.suppress(OSError): + os.unlink(p) + + +def dedupe_sorted(items: Iterable[str]) -> list[str]: + """Deterministic deduplication: insertion-order de-dup, then sorted.""" + seen: set[str] = set() + out: list[str] = [] + for it in items: + if it and it not in seen: + seen.add(it) + out.append(it) + out.sort() + return out diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6e8b5b1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def pytest_configure() -> None: + root = Path(__file__).resolve().parents[1] + subprocess.run([sys.executable, str(root / "scripts" / "build_doc_texts.py")], check=True) diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..0e939ca --- /dev/null +++ b/tests/support.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +def set_home(monkeypatch, home: Path, *, patch_path_home: bool = False) -> None: + """Point test home resolution at *home* across POSIX and Windows code paths.""" + if patch_path_home: + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setenv("HOME", str(home)) + if os.name == "nt": + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.delenv("HOMEDRIVE", raising=False) + monkeypatch.delenv("HOMEPATH", raising=False) diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..2646481 --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,388 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tests for keychain.agents: fingerprint extraction, list dispatch and findpids.""" + +from __future__ import annotations + +import os +import socket +from types import SimpleNamespace + +import pytest + +from keychain import agents +from keychain.agents import extract_fingerprints, findpids +from keychain.env import SshAgentRef +from keychain.runtime import platform +from keychain.util import Output + + +def _out(theme: str | None = None): + return Output.build(quiet=True, debug=False, eval_mode=False, color=False, theme=theme) + + +# --------------------------------------------------------------------------- +# extract_fingerprints +# --------------------------------------------------------------------------- + +# Representative ssh-add -l output (OpenSSH SHA256 format) +_SHA256_OUTPUT = """\ +256 SHA256:abc123XYZdefGHI+jklMNO/pqr= /home/user/.ssh/id_rsa (RSA) +521 SHA256:uvwXYZ789+abc/def= /home/user/.ssh/id_ecdsa521 (ECDSA) +The agent has no identities. +""" + +# Representative ssh-add -l output (legacy MD5 format) +_MD5_OUTPUT = """\ +2048 aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99 /home/user/.ssh/id_rsa (RSA) +""" + +# Some older implementations emit the bit-count in column 0 and MD5 in column 2 +_MD5_COL2_OUTPUT = """\ +RSA 1024 11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00 /path (RSA) +""" + + +class TestExtractFingerprints: + def test_sha256_fingerprints_extracted(self): + """Verify SHA256 fingerprints are parsed from standard ssh-add output because each key line exposes the fingerprint in column two.""" + fps = extract_fingerprints(_SHA256_OUTPUT) + assert fps == [ + "SHA256:abc123XYZdefGHI+jklMNO/pqr=", + "SHA256:uvwXYZ789+abc/def=", + ] + + def test_md5_fingerprints_extracted(self): + """Verify legacy MD5 fingerprints are preserved because older ssh-add formats still report identities with colon-delimited hashes.""" + fps = extract_fingerprints(_MD5_OUTPUT) + assert len(fps) == 1 + assert fps[0] == "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99" + + def test_md5_in_column_two_extracted(self): + """Verify the parser accepts MD5 fingerprints from the alternate legacy column layout because some implementations print type, bits, then hash.""" + fps = extract_fingerprints(_MD5_COL2_OUTPUT) + assert fps == ["11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00"] + + def test_empty_output_returns_empty_list(self): + """Verify empty ssh-add output yields no fingerprints because there are no identity lines to parse.""" + assert extract_fingerprints("") == [] + + def test_no_identities_line_returns_empty(self): + """Verify the explicit no-identities banner produces an empty result because it is status text, not a key record.""" + assert extract_fingerprints("The agent has no identities.\n") == [] + + def test_mixed_output_extracts_all(self): + """Verify mixed SHA256 and MD5 listings are both collected because the extractor must handle both formats in one stream.""" + mixed = _SHA256_OUTPUT + _MD5_OUTPUT + fps = extract_fingerprints(mixed) + assert len(fps) == 3 # 2 SHA256 + 1 MD5 + + def test_deduplication_not_performed(self): + """Verify duplicate fingerprints are returned unchanged because de-duplication is the caller's responsibility, not the parser's.""" + # extract_fingerprints returns what it sees; dedup is the caller's job + fps = extract_fingerprints(_SHA256_OUTPUT + _SHA256_OUTPUT) + assert len(fps) == 4 + + +class TestListSelection: + def test_ssh_agent_defaults_to_find_active_agent_env(self): + """Verify SshAgent starts from KeychainState.find_active_agent_env because that cached state is the single source of truth for live agent variables.""" + kstate = SimpleNamespace(find_active_agent_env=SshAgentRef(sock="/tmp/live.sock", pid="1111")) + + agent = agents.SshAgent(kstate, _out()) + + assert agent.env == kstate.find_active_agent_env + + def test_render_list_table_uses_find_active_agent_env(self, monkeypatch, capsys): + """Verify modern list rendering shells out with find_active_agent_env because stale pidfile values must not override the selected live agent.""" + seen = [] + + def fake_run(cmd, env=None, **_kwargs): + seen.append((cmd, env)) + return SimpleNamespace(returncode=0, stdout="256 SHA256:abc comment (ED25519)\n", stderr="") + + monkeypatch.setattr(agents, "run", fake_run) + kstate = SimpleNamespace( + find_active_agent_env=SshAgentRef(sock="/tmp/live.sock", pid="1111"), + pidfile_env=SshAgentRef(sock="/tmp/stale.sock", pid="9999"), + ssh=SimpleNamespace(passthrough=lambda _flag: 0), + ) + + assert agents.render_list_table(kstate, _out()) == 0 + assert len(seen) == 1 + assert seen[0][0] == ["ssh-add", "-l"] + assert seen[0][1]["SSH_AUTH_SOCK"] == "/tmp/live.sock" + assert seen[0][1]["SSH_AGENT_PID"] == "1111" + assert "SHA256:abc" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# findpids +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not platform.detect().supported, reason="findpids requires a supported (POSIX-shaped) host") +class TestFindpids: + def test_returns_list_of_ints(self): + """Verify findpids returns integer process ids because callers use the result as numeric PIDs for follow-up probes.""" + result = findpids("ssh") + assert isinstance(result, list) + assert all(isinstance(p, int) for p in result) + + def test_current_python_process_not_in_ssh_agents(self): + """Verify the pytest process is not reported as ssh-agent because process-name filtering should only match the requested daemon.""" + # The pytest runner should never appear as an ssh-agent. + result = findpids("ssh") + assert os.getpid() not in result + + def test_gpg_findpids_returns_list(self): + """Verify gpg lookups also return integer PID lists because the helper supports both ssh-agent and gpg-agent discovery paths.""" + result = findpids("gpg") + assert isinstance(result, list) + assert all(isinstance(p, int) for p in result) + + def test_nonexistent_program_returns_empty(self): + """Verify unknown program names produce no matches because the process scan should not fabricate PIDs for missing executables.""" + result = findpids("no-such-program-zzz") + assert result == [] + + +# --------------------------------------------------------------------------- +# ssh_socket_valid (owner check) and gpg_socket_is_primary +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not hasattr(os, "getuid"), reason="POSIX-only: socket owner check") +class TestSshSocketValid: + def test_real_socket_owned_by_us_is_valid(self, tmp_path, monkeypatch): + """Verify a real AF_UNIX socket owned by the current user is accepted because that is the expected shape of a usable SSH agent socket.""" + # macOS caps AF_UNIX paths at 104 bytes (Linux: 108); GitHub Actions + # macos runners use long /private/var/folders/... TMPDIRs that + # overflow this. Bind via a relative name from inside tmp_path so + # the kernel only sees the short name. + monkeypatch.chdir(tmp_path) + sock_path = tmp_path / "agent.sock" + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.bind("agent.sock") + try: + assert agents.ssh_socket_valid(str(sock_path)) is True + assert agents.validate_ssh_socket(str(sock_path)) == agents.SocketValidation(str(sock_path), True) + finally: + s.close() + + def test_regular_file_is_not_valid(self, tmp_path): + """Verify regular files are rejected because only socket filesystem entries can back SSH_AUTH_SOCK.""" + f = tmp_path / "not_a_socket" + f.write_text("x") + assert agents.ssh_socket_valid(str(f)) is False + assert agents.validate_ssh_socket(str(f)).reason == "not-socket" + assert agents.validate_ssh_socket(str(f)).severity == "warn" + + def test_symlink_to_socket_is_not_valid(self, tmp_path, monkeypatch): + """Verify symlinks are rejected because SSH_AUTH_SOCK should name the socket itself, not a redirected path.""" + monkeypatch.chdir(tmp_path) # see note in test_real_socket_owned_by_us_is_valid + sock_path = tmp_path / "agent.sock" + link_path = tmp_path / "agent-link.sock" + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.bind("agent.sock") + try: + link_path.symlink_to(sock_path) + assert agents.ssh_socket_valid(str(link_path)) is False + assert agents.validate_ssh_socket(str(link_path)).reason == "symlink" + assert agents.validate_ssh_socket(str(link_path)).severity == "err" + finally: + s.close() + + def test_missing_path_is_not_valid(self, tmp_path): + """Verify missing paths are rejected because a nonexistent socket cannot connect to an agent.""" + assert agents.ssh_socket_valid(str(tmp_path / "nope")) is False + assert agents.validate_ssh_socket(str(tmp_path / "nope")).reason == "missing" + + def test_empty_path_is_not_valid(self): + """Verify the empty path is rejected because there is no socket location to validate.""" + assert agents.ssh_socket_valid("") is False + assert agents.validate_ssh_socket("").reason == "empty" + + def test_foreign_owner_rejected(self, tmp_path, monkeypatch): + """Verify sockets owned by another uid are rejected because keychain must not trust foreign agent endpoints.""" + monkeypatch.chdir(tmp_path) # see note in test_real_socket_owned_by_us_is_valid + sock_path = tmp_path / "agent.sock" + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.bind("agent.sock") + try: + # Pretend our uid is one we definitely don't match. + real_uid = os.getuid() + monkeypatch.setattr(os, "getuid", lambda: real_uid + 99999) + assert agents.ssh_socket_valid(str(sock_path)) is False + assert agents.validate_ssh_socket(str(sock_path)).reason == "foreign-owner" + assert agents.validate_ssh_socket(str(sock_path)).severity == "err" + finally: + s.close() + + +class TestGpgSocketIsPrimary: + def test_socket_under_gnupghome_is_primary(self, tmp_path): + """Verify a socket inside GNUPGHOME is treated as primary because that directory explicitly defines the active GnuPG home.""" + gh = tmp_path / "gnupg" + gh.mkdir() + sock = gh / "S.gpg-agent" + env = {"GNUPGHOME": str(gh), "HOME": str(tmp_path)} + assert agents.gpg_socket_is_primary(str(sock), env=env, uid=1000) + + def test_socket_under_home_dot_gnupg_is_primary(self, tmp_path): + """Verify a socket inside HOME/.gnupg is treated as primary because that is GnuPG's default home when GNUPGHOME is unset.""" + (tmp_path / ".gnupg").mkdir() + sock = tmp_path / ".gnupg" / "S.gpg-agent" + env = {"HOME": str(tmp_path)} + assert agents.gpg_socket_is_primary(str(sock), env=env, uid=1000) + + def test_foreign_homedir_rejected(self, tmp_path): + """Verify sockets under an unrelated homedir are rejected because package-manager scratch agents must not be mistaken for the user's primary agent.""" + # Simulates package-manager: gpg-agent --homedir /var/tmp/zypp.X + foreign = tmp_path / "zypp.XXX" + foreign.mkdir() + sock = foreign / "S.gpg-agent" + env = {"HOME": str(tmp_path / "home"), "GNUPGHOME": str(tmp_path / "home" / ".gnupg")} + assert not agents.gpg_socket_is_primary(str(sock), env=env, uid=1000) + + def test_empty_socket_rejected(self): + """Verify an empty socket path is rejected because there is no candidate GnuPG socket to classify as primary.""" + assert not agents.gpg_socket_is_primary("", env={"HOME": "/x"}, uid=1000) + + +# --------------------------------------------------------------------------- +# Issue #181: don't claim "forwarded socket" when source is unknown +# --------------------------------------------------------------------------- + + +class TestSshEnvcheckUnknownSource: + """When SSH_AUTH_SOCK is valid but no SSH_AGENT_PID and not GnuPG, + the message must be honest (path included, source called unknown).""" + + def test_unknown_source_message_includes_path_and_does_not_claim_forwarded(self, tmp_path, monkeypatch): + """Verify envcheck names an otherwise valid socket as unknown source because without PID or GnuPG evidence it must not claim the socket was forwarded.""" + sock_path = tmp_path / "agent.sock" + sock_path.write_text("") # placeholder; ssh_socket_valid is mocked + captured: list[str] = [] + + class _Out: + def debug(self, msg): + captured.append(msg) + + def mesg(self, msg): + captured.append(msg) + + def note(self, msg): + captured.append(msg) + + def warn(self, msg): + captured.append(msg) + + def c(self, _): + return "" + + # Pretend the socket is valid and that GnuPG isn't supplying it, + # so we hit the "unknown source" branch. + monkeypatch.setattr(agents, "ssh_socket_valid", lambda _: True) + monkeypatch.setattr(agents, "gpg_ssh_socket", lambda: None) + + env = SshAgentRef(str(sock_path)) + # Build a minimal SshAgent: envcheck reads self._allow_gpg and + # self._allow_forwarded (latched by start()), self.out, and the host + # probes ssh_socket_valid / gpg_ssh_socket which we mocked above. + from keychain import state + from keychain.paths import KeychainPaths + + kstate = state.KeychainState(paths=KeychainPaths(keydir=tmp_path, host="h")) + agent = agents.SshAgent(kstate, _Out()) + ok = agent.envcheck("env", env, quick=False) + assert ok is None + joined = " ".join(captured) + assert str(sock_path) in joined + assert "forwarded" not in joined.lower() + + +# --------------------------------------------------------------------------- +# Issue #21: KEYCHAIN_{SSH,GPG}_AGENT_ARGS append flags to the spawn command +# --------------------------------------------------------------------------- + + +class TestAgentArgsPassthrough: + """Verify env vars are spliced into the agent spawn command.""" + + def _capture_run(self, monkeypatch): + """Replace agents.run with a recorder; return the captured cmd list.""" + captured = [] + + class _R: + returncode = 0 + stdout = "" + + def fake_run(cmd, *_a, **_k): + captured.append(list(cmd)) + return _R() + + monkeypatch.setattr(agents, "run", fake_run) + return captured + + def _build_ssh_agent(self, tmp_path): + """Construct a SshAgent with parsed args for the spawn path.""" + from keychain import state + from keychain.paths import KeychainPaths + from keychain.runtime.config import RuntimeConfig + from keychain.util import Output + + args = RuntimeConfig.resolve(["add", "--no-inherit", "--no-gui"]) + + kstate = state.KeychainState( + paths=KeychainPaths(keydir=tmp_path, host="h"), + args=args, + ) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + return agents.SshAgent(kstate, out) + + def test_ssh_agent_args_appended(self, monkeypatch, tmp_path): + """Verify KEYCHAIN_SSH_AGENT_ARGS tokens are appended to ssh-agent because the environment variable is the supported override for extra spawn flags.""" + cap = self._capture_run(monkeypatch) + monkeypatch.setenv("KEYCHAIN_SSH_AGENT_ARGS", "-O no-restrict-websafe -t 7200") + # Force a "spawn new agent" path: empty pidfile, no inherited env. + monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) + monkeypatch.delenv("SSH_AGENT_PID", raising=False) + self._build_ssh_agent(tmp_path).start(ssh_spawn_gpg=False, ssh_allow_gpg=False) + # ssh-agent invocation is the last captured run. + cmd = cap[-1] + assert cmd[0] == "ssh-agent" + assert "-O" in cmd and "no-restrict-websafe" in cmd + assert "-t" in cmd and "7200" in cmd + + def test_gpg_agent_args_appended(self, monkeypatch, tmp_path): + """Verify KEYCHAIN_GPG_AGENT_ARGS tokens are appended to gpg-agent because callers need a supported way to extend the spawned daemon command line.""" + from keychain import state + from keychain.paths import KeychainPaths + from keychain.runtime.config import RuntimeConfig + from keychain.util import Output + + cap = self._capture_run(monkeypatch) + monkeypatch.setenv("KEYCHAIN_GPG_AGENT_ARGS", "--allow-preset-passphrase --debug-level=basic") + # Pretend no existing gpg-agent so we go down the spawn path. + monkeypatch.setattr(agents, "gpg_main_socket", lambda *_args, **_kwargs: "") + args = RuntimeConfig.resolve(["add", "--no-gui"]) + kstate = state.KeychainState( + paths=KeychainPaths(keydir=tmp_path, host="h"), + args=args, + ) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + agents.GpgAgent(kstate, out).start(ssh_support=False) + cmd = cap[-1] + assert cmd[0] == "gpg-agent" + assert "--allow-preset-passphrase" in cmd + assert "--debug-level=basic" in cmd + + def test_no_args_when_env_unset(self, monkeypatch, tmp_path): + """Verify no extra ssh-agent flags are added when the passthrough env var is unset because the default spawn command should stay minimal.""" + cap = self._capture_run(monkeypatch) + monkeypatch.delenv("KEYCHAIN_SSH_AGENT_ARGS", raising=False) + monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) + monkeypatch.delenv("SSH_AGENT_PID", raising=False) + self._build_ssh_agent(tmp_path).start(ssh_spawn_gpg=False, ssh_allow_gpg=False) + # Default invocation has no extra tokens beyond ssh-agent -s. + assert cap[-1] == ["ssh-agent", "-s"] diff --git a/tests/test_build_backend.py b/tests/test_build_backend.py new file mode 100644 index 0000000..22fb1ef --- /dev/null +++ b/tests/test_build_backend.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tests for the thin PEP 517 backend wrapper.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +def _load_backend(monkeypatch, build_meta): + fake_setuptools = ModuleType("setuptools") + fake_setuptools.build_meta = build_meta + monkeypatch.setitem(sys.modules, "setuptools", fake_setuptools) + + spec = importlib.util.spec_from_file_location( + "build_backend_testcopy", + Path(__file__).resolve().parents[1] / "scripts" / "build_backend.py", + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_editable_hooks_fail_clearly_when_setuptools_lacks_pep660(monkeypatch): + backend = _load_backend(monkeypatch, SimpleNamespace()) + monkeypatch.setattr(backend, "_generate_docs", lambda: None) + monkeypatch.setattr(backend, "_setuptools", SimpleNamespace()) + + with pytest.raises(SystemExit, match="setuptools>=64"): + backend.get_requires_for_build_editable(None) + + with pytest.raises(SystemExit, match="setuptools>=64"): + backend.prepare_metadata_for_build_editable("meta") + + with pytest.raises(SystemExit, match="setuptools>=64"): + backend.build_editable("dist") + + +def test_pyproject_declares_main_entrypoint_and_docs_package(): + text = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(encoding="utf-8") + + assert 'keychain = "keychain.main:main"' in text + assert 'build-backend = "build_backend"' in text + assert 'backend-path = ["scripts"]' in text + assert '"keychain.docs"' in text diff --git a/tests/test_build_doc_texts.py b/tests/test_build_doc_texts.py new file mode 100644 index 0000000..c8c3e31 --- /dev/null +++ b/tests/test_build_doc_texts.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: GPL-3.0-only +from __future__ import annotations + +from scripts import build_doc_texts + + +def test_parse_sections_keeps_heading_metadata_out_of_body(): + text = """== @action add: Add keys. + +@syntax keychain add [KEYS...] + +Load identities into the agent. +""" + + section = build_doc_texts.parse_sections(text)[0] + + assert section.tag == "action:add" + assert section.short_help == "Add keys." + assert section.syntax == "keychain add [KEYS...]" + assert section.body == "Load identities into the agent." + + +def test_parse_tagged_text_preserves_each_body_line_once(): + text = """== @topic usage: Usage. + +First line. +Second line. +""" + + docs = build_doc_texts.parse_tagged_text(text) + + assert docs["all"] == ["topic:usage"] + assert docs["topic"]["usage"]["description"] == "First line.\nSecond line." + + +def test_parse_sections_allows_empty_section_markers(): + text = """== @topic usage: Usage. + +Body. + +== @section ACTIONS + +== @action add: Add keys. + +Body. +""" + + docs = build_doc_texts.parse_tagged_text(text) + + assert docs["all"] == ["topic:usage", "section:ACTIONS", "action:add"] + assert docs["section"]["ACTIONS"]["description"] == "" diff --git a/tests/test_cli_actions.py b/tests/test_cli_actions.py new file mode 100644 index 0000000..99a5542 --- /dev/null +++ b/tests/test_cli_actions.py @@ -0,0 +1,333 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""CLI action-resolution and handler tests.""" + +import subprocess +from types import SimpleNamespace + +import pytest + +from keychain import keys, main +from keychain.main import KeychainApp +from keychain.paths import KeychainPaths +from keychain.runtime.config import RuntimeConfig +from keychain.util import KeychainError, Output +from tests.support import set_home + + +class TestResolveAction: + """Validation and dispatch tests for KeychainApp action resolution.""" + + def test_start_returns_empty(self): + args = RuntimeConfig.resolve([]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + assert KeychainApp(args, out)._resolve_action() == "add" + + def test_agent_stop_action(self): + """An explicit stop target should survive parse and resolve to the stop handler.""" + ns = RuntimeConfig.resolve(["agent", "stop", "--mine"]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + assert KeychainApp(ns, out)._resolve_action() == "agent_stop" + assert ns.get_value("target") == "mine" + + def test_agent_stop_defaults_to_pidfile_when_no_target_is_selected(self): + """The stop handler applies its own runtime fallback when no target flag was parsed.""" + ns = RuntimeConfig.resolve(["agent", "stop"]) + seen: list[str] = [] + + class _Paths: + def verify_keydir(self, _user, _out): + return None + + class _SSH: + def stop(self, target): + seen.append(target) + + class _State: + user = "tester" + paths = _Paths() + ssh = _SSH() + + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + app = KeychainApp(ns, out) + app._kstate = _State() + + assert app._resolve_action() == "agent_stop" + assert ns.get_value("target") is None + assert app._handle_agent_stop_action() == 0 + assert seen == ["pidfile"] + + def test_agent_start_action(self): + args = RuntimeConfig.resolve(["agent", "start"]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + assert KeychainApp(args, out)._resolve_action() == "agent_start" + + @pytest.mark.parametrize( + "argv,expected", + [ + (["wipe", "--ssh"], "wipe"), + (["wipe", "--gpg"], "wipe"), + (["wipe"], "wipe"), + (["wipe", "--ssh", "--gpg"], "wipe"), + ], + ) + def test_wipe_actions(self, argv, expected): + args = RuntimeConfig.resolve(argv) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + assert KeychainApp(args, out)._resolve_action() == expected + + @pytest.mark.parametrize( + "sub,expected", + [ + ("list", "list"), + ("env", "env"), + ("help", "help"), + ("version", "version"), + ], + ) + def test_passthrough_actions(self, sub, expected): + args = RuntimeConfig.resolve([sub]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + assert KeychainApp(args, out)._resolve_action() == expected + + def test_ssh_rm_action(self): + args = RuntimeConfig.resolve(["forget", "k"]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + assert KeychainApp(args, out)._resolve_action() == "forget" + + def test_default_dir_resolves_tilde_to_home(self, tmp_path, monkeypatch): + """Parser defaults should stay unexpanded until the path layer resolves them against HOME.""" + set_home(monkeypatch, tmp_path) + ns = RuntimeConfig.resolve([]) + assert ns.get_value("dir") == "~/.keychain" + assert ( + KeychainPaths.build(ns.get_value("dir"), bool(ns.get_value("absolute")), "host").keydir + == tmp_path / ".keychain" + ) + + def test_forget_noop_without_keys(self): + """Forget is a no-op unless the caller asked for keys or config-driven host expansion.""" + ns = RuntimeConfig.resolve(["forget"]) + removed: list[list[str]] = [] + + class _Paths: + def verify_keydir(self, _user, _out): + return None + + class _SSH: + def remove(self, ssh_keys): + removed.append(list(ssh_keys)) + + class _State: + user = "tester" + paths = _Paths() + ssh = _SSH() + + def resolve_requested_keys(self, _out, *, gpg_lookup=True): + return keys.ResolvedKeys([], [], [], [], [], []) + + _State.args = ns + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + app = KeychainApp(ns, out) + app._kstate = _State() + assert app._handle_forget_action() == 0 + assert removed == [] + + def test_forget_rejects_explicit_gpg_extkeys(self): + """Forget intentionally stays SSH-only even when key lookup finds a GPG external key.""" + ns = RuntimeConfig.resolve(["forget", "gpgk:ABCD1234"]) + + class _Paths: + def verify_keydir(self, _user, _out): + return None + + class _SSH: + def remove(self, _ssh_keys): + raise AssertionError("ssh removal should not run for gpgk inputs") + + class _State: + user = "tester" + paths = _Paths() + ssh = _SSH() + + def resolve_requested_keys(self, _out, *, gpg_lookup=True): + return keys.ResolvedKeys([], ["ABCD1234"], [], [], [], []) + + _State.args = ns + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + app = KeychainApp(ns, out) + app._kstate = _State() + with pytest.raises(KeychainError, match="forget only supports SSH keys"): + app._handle_forget_action() + + def test_systemd_set_env_warns_on_timeout(self, monkeypatch, capsys): + def timeout_run(*_args, **_kwargs): + raise subprocess.TimeoutExpired(["systemctl"], 5) + + monkeypatch.setattr(main.subprocess, "run", timeout_run) + out = Output.build(quiet=False, debug=False, eval_mode=False, color=False) + + main._systemd_set_env(main.SshAgentRef("/tmp/user name/agent.sock", "123"), out) + + captured = capsys.readouterr() + assert "Timed out while updating the systemd user environment" in captured.err + + def test_add_with_only_missing_keys_refuses_to_start_agent(self): + ns = RuntimeConfig.resolve(["add", "ghost-key"]) + + class _Paths: + def verify_keydir(self, _user, _out): + return None + + class _State: + user = "tester" + paths = _Paths() + + def resolve_requested_keys(self, _out, *, gpg_lookup=True): + return keys.ResolvedKeys([], [], [], [], [], ["ghost-key"]) + + _State.args = ns + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + app = KeychainApp(ns, out) + app._kstate = _State() + app._agent_settings = lambda: (_ for _ in ()).throw(AssertionError("agent settings should not be read")) + app._do_add = lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("agent should not start")) + + with pytest.raises(KeychainError, match="No requested keys could be resolved; refusing to start an agent"): + app._handle_add_action() + + def test_quick_and_clear_incompatible(self): + ns = RuntimeConfig.resolve(["add", "--quick", "--clear"]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + with pytest.raises(KeychainError): + KeychainApp(ns, out)._resolve_action() + + def test_lockwait_negative_rejected(self): + """Validation should reject negative lockwait once the parser has accepted the numeric value.""" + ns = RuntimeConfig.resolve(["add", "--lockwait=-1"]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + with pytest.raises(KeychainError): + KeychainApp(ns, out)._resolve_action() + + def test_timeout_zero_rejected(self): + ns = RuntimeConfig.resolve(["add", "--timeout", "0"]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + with pytest.raises(KeychainError): + KeychainApp(ns, out)._resolve_action() + + def test_confhost_raises(self): + ns = RuntimeConfig.resolve(["add", "--confhost", "remote"]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + with pytest.raises(KeychainError): + KeychainApp(ns, out)._resolve_action() + + def test_deprecated_agents_warns(self, capsys): + ns = RuntimeConfig.resolve(["add", "--agents", "ssh"]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + KeychainApp(ns, out)._resolve_action() + assert "deprecated" in capsys.readouterr().err + + +class TestOutputFormatOptions: + """Output-format and option-shape coverage for live actions.""" + + def test_json_flag_defaults_false_on_list(self): + """Action-local JSON flags should default to false when the option is omitted.""" + ns = RuntimeConfig.resolve(["list"]) + assert ns.get_value("json") is False + + def test_json_flag_after_action(self): + ns = RuntimeConfig.resolve(["list", "--json"]) + assert ns.get_value("json") is True + + def test_json_flag_after_action_inspect(self): + ns = RuntimeConfig.resolve(["inspect", "--json"]) + assert ns.get_value("json") is True + + def test_theme_flag_default_is_none(self, monkeypatch, tmp_path): + monkeypatch.setenv("KEYCHAIN_CONFIG", str(tmp_path / "missing.conf")) + ns = RuntimeConfig.resolve(["version"]) + assert ns.get_value("theme") is None + + def test_theme_flag_carries_value(self): + ns = RuntimeConfig.resolve(["version", "--theme", "modern"]) + assert ns.get_value("theme") == "modern" + + def test_ssh_rm_with_no_keys_parses(self): + ns = RuntimeConfig.resolve(["--ssh-rm"]) + assert ns.action == "forget" + assert ns.get_value("keys") == [] + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + assert KeychainApp(ns, out)._resolve_action() == "forget" + + def test_ssh_rm_with_multiple_keys(self): + ns = RuntimeConfig.resolve(["--ssh-rm", "id_rsa", "id_ed25519"]) + assert ns.action == "forget" + assert ns.get_value("keys") == ["id_rsa", "id_ed25519"] + + def test_eval_failure_prints_false_fallback(self, capsys): + """Eval mode should emit the shell-friendly failure stub on runtime validation errors.""" + with pytest.raises(SystemExit): + main.main(["--eval", "add", "--quick", "--clear"]) + captured = capsys.readouterr() + assert "false;" in captured.out + + def test_non_eval_action_error_does_not_touch_missing_eval_attribute(self, monkeypatch, capsys): + """Non-eval action failures should report the real KeychainError instead of crashing in the error handler.""" + monkeypatch.setattr(main.platform, "detect", lambda: SimpleNamespace(supported=True, name="linux", reason="")) + with pytest.raises(SystemExit) as exc: + main.main(["agent"]) + assert exc.value.code == 1 + captured = capsys.readouterr() + assert "agent: missing subcommand (start|stop)" in captured.err + assert "AttributeError" not in captured.err + + @pytest.mark.parametrize( + "flag,value", + [ + ("--inherit", "any"), + ("--attempts", "3"), + ], + ) + def test_other_deprecated_flags_warn(self, flag, value, capsys): + ns = RuntimeConfig.resolve(["add", flag, value]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + KeychainApp(ns, out)._resolve_action() + assert "deprecated" in capsys.readouterr().err + + @pytest.mark.parametrize( + "opt,attr,value", + [ + ("--dir", "dir", "/tmp/keychain"), + ("--host", "host", "build-7"), + ("--lockwait", "lockwait", 7), + ("--timeout", "timeout", 30), + ("--ssh-agent-socket", "ssh_agent_socket", "/tmp/agent.sock"), + ], + ) + def test_value_options_accept_equals_form(self, opt, attr, value): + ns = RuntimeConfig.resolve(["add", f"{opt}={value}"]) + assert ns.get_value(attr) == value + + +class TestListFingerprints: + """List-action handler behavior for table and JSON output.""" + + def test_list_default_uses_short_form(self): + ns = RuntimeConfig.resolve(["list"]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + assert KeychainApp(ns, out)._resolve_action() == "list" + + def test_list_json_uses_find_active_agent_env(self, monkeypatch): + seen = [] + + monkeypatch.setattr(main.agents, "render_list_json", lambda env: seen.append(env)) + kstate = SimpleNamespace( + pidfile_env=main.SshAgentRef(sock="/tmp/stale.sock", pid="9999"), + find_active_agent_env=main.SshAgentRef(sock="/tmp/live.sock", pid="1111"), + ) + out_json = Output.build(quiet=True, debug=False, eval_mode=False, color=False, json=True) + + app = KeychainApp(RuntimeConfig.resolve(["list", "--json"]), out_json) + app._kstate = kstate + assert app._handle_list_action() == 0 + assert seen == [kstate.find_active_agent_env] diff --git a/tests/test_cli_help.py b/tests/test_cli_help.py new file mode 100644 index 0000000..211ac2c --- /dev/null +++ b/tests/test_cli_help.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""CLI help and version rendering tests.""" + +import pytest + +from keychain import main +from keychain.main import helpinfo +from keychain.runtime.config import RuntimeConfig +from keychain.util import Output + + +class TestHelpVersionOutput: + def test_version_first_line_matches_banner_format(self, capsys): + from keychain import __version__ + + ns = RuntimeConfig.resolve(["version"]) + out = Output.build(quiet=False, debug=False, eval_mode=False, color=False) + main.banner(out) + main.versinfo(out) + captured = capsys.readouterr().err + assert __version__ in captured + assert "keychain" in captured + assert ns.action == "version" + + def test_help_lists_every_visible_action(self, capsys): + helpinfo(None) + captured = capsys.readouterr().out + assert "Actions" in captured + for sub in ("add", "agent", "list", "wipe", "forget", "env", "inspect", "version", "help", "man"): + assert sub in captured, f"action {sub!r} missing from help" + assert "list-fp" not in captured + assert "--quiet" in captured + assert "Global options" in captured + + @pytest.mark.parametrize( + "sub", + [ + "add", + "agent", + "list", + "wipe", + "forget", + "env", + "inspect", + ], + ) + def test_per_action_cheat_sheet_renders(self, sub, capsys): + helpinfo(sub) + captured = capsys.readouterr().out + assert f"keychain {sub}" in captured + + def test_per_action_help_strips_markup_syntax(self, capsys): + helpinfo("add") + captured = capsys.readouterr().out + assert "`" not in captured + + def test_cli_man_topic_dedupes_repeated_lines_and_strips_markup(self, capsys): + with pytest.raises(SystemExit) as ex: + main.main(["man", "topic:config"]) + assert ex.value.code == 0 + out = capsys.readouterr().out + assert "See ``keychain config show``" not in out + assert "[keys] key-resolution:" in out or "[keys] key-resolution:" in out + assert ( + "[keys] key-resolution: ``confallhosts``,\n [keys] key-resolution: ``confallhosts``," + not in out + ) + + def test_cli_man_list_uses_authored_labels(self, capsys): + with pytest.raises(SystemExit) as ex: + main.main(["man", "--list"]) + assert ex.value.code == 0 + out = capsys.readouterr().out + assert "keychain add" in out + assert "keychain config" in out + assert "action:config" not in out + assert "option:status-json" not in out + assert "ACTIONS" not in out + assert "--quiet" in out + + def test_cli_man_full_renders_section_markers(self, capsys): + with pytest.raises(SystemExit) as ex: + main.main(["man"]) + assert ex.value.code == 0 + out = capsys.readouterr().out + assert "ACTIONS" in out + assert "keychain add" in out + + def test_removed_top_level_verb_helpinfo_errors(self, capsys): + assert helpinfo("stop") == 2 + captured = capsys.readouterr() + assert "help: unknown action: stop" in captured.err + assert captured.out == "" + + def test_cli_help_removed_top_level_verb_errors(self, capsys): + with pytest.raises(SystemExit) as ex: + main.main(["help", "stop"]) + assert ex.value.code == 2 + err = capsys.readouterr().err + assert "help: unknown action: stop" in err + + def test_cli_help_nested_action_target_renders(self, capsys): + with pytest.raises(SystemExit) as ex: + main.main(["help", "agent", "stop"]) + assert ex.value.code == 0 + out = capsys.readouterr().out + assert "keychain agent stop" in out + + def test_cli_flag_help_nested_action_target_renders(self, capsys): + with pytest.raises(SystemExit) as ex: + main.main(["--help", "agent", "stop"]) + assert ex.value.code == 0 + out = capsys.readouterr().out + assert "keychain agent stop" in out + + def test_cli_help_unknown_target_errors(self, capsys): + with pytest.raises(SystemExit) as ex: + main.main(["help", "nonsense-token"]) + assert ex.value.code == 2 + err = capsys.readouterr().err + assert "help: unknown action: nonsense-token" in err + + def test_cli_flag_help_unknown_target_errors(self, capsys): + with pytest.raises(SystemExit) as ex: + main.main(["--help", "nonsense-token"]) + assert ex.value.code == 2 + err = capsys.readouterr().err + assert "help: unknown action: nonsense-token" in err + + def test_cli_explain_noncompat_invocation_renders_action_and_option_panels(self, capsys): + with pytest.raises(SystemExit) as ex: + main.main(["add", "--quick", "--explain"]) + assert ex.value.code == 0 + out = capsys.readouterr().out + assert "keychain add" in out + assert "--quick" in out + assert "╯\n\n ╭" not in out + assert "``" not in out + + def test_cli_explain_compat_invocation_renders_legacy_panel(self, capsys): + with pytest.raises(SystemExit) as ex: + main.main(["--list", "--explain"]) + assert ex.value.code == 0 + out = capsys.readouterr().out + assert "keychain list" in out + assert "Legacy invocation" in out + assert "No match for any new-style action; legacy keychain 2.x parsing invoked." in out + + def test_cli_explain_default_add_fallback_renders_legacy_warning(self, capsys): + with pytest.raises(SystemExit) as ex: + main.main(["start", "--explain"]) + assert ex.value.code == 0 + out = capsys.readouterr().out + assert "Legacy invocation" in out + assert "No match for any new-style action; legacy keychain 2.x parsing invoked." in out + assert "keychain add" in out + assert "Literal Agent Key: 'start'" in out + assert "A literal SSH or GnuPG key specification to load into the agent." in out diff --git a/tests/test_cli_legacy.py b/tests/test_cli_legacy.py new file mode 100644 index 0000000..139192b --- /dev/null +++ b/tests/test_cli_legacy.py @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""CLI compatibility and legacy-behavior tests.""" + +import pytest + +from keychain import main +from keychain.main import KeychainApp +from keychain.runtime.config import RuntimeConfig +from keychain.util import Output + + +class TestParseArgsLegacy: + """Legacy flat-flag compatibility that is still expected to round-trip.""" + + def test_legacy_list(self): + ns = RuntimeConfig.resolve(["--list"]) + assert ns.action == "list" + + def test_legacy_stop_all(self): + """The compat shim now treats `all` as the implicit stop default, not an explicit parsed target.""" + ns = RuntimeConfig.resolve(["--stop", "all"]) + assert ns.action == "agent stop" + assert ns.get_value("target") is None + + def test_legacy_stop_mine(self): + ns = RuntimeConfig.resolve(["--stop", "mine"]) + assert ns.action == "agent stop" + assert ns.get_value("target") == "mine" + + def test_legacy_stop_others(self): + ns = RuntimeConfig.resolve(["-k", "others"]) + assert ns.action == "agent stop" + assert ns.get_value("target") == "others" + + def test_legacy_wipe_ssh(self): + ns = RuntimeConfig.resolve(["--wipe", "ssh"]) + assert ns.action == "wipe" + assert ns.get_value("wipe_ssh") is True + assert ns.get_value("wipe_gpg") is False + + def test_legacy_keys_become_start(self): + ns = RuntimeConfig.resolve(["id_rsa"]) + assert ns.action == "add" + assert ns.get_value("keys") == ["id_rsa"] + + def test_legacy_ssh_rm(self): + ns = RuntimeConfig.resolve(["--ssh-rm", "keyA"]) + assert ns.action == "forget" + assert ns.get_value("keys") == ["keyA"] + + def test_legacy_inspect(self): + ns = RuntimeConfig.resolve(["--inspect"]) + assert ns.action == "inspect" + + def test_legacy_inspect_with_keys(self): + ns = RuntimeConfig.resolve(["--inspect", "id_rsa", "id_ed25519"]) + assert ns.action == "inspect" + assert ns.get_value("keys") == ["id_rsa", "id_ed25519"] + + @pytest.mark.skip( + reason="Invalid legacy stop values are currently translated into `agent stop` without parser rejection; discuss whether compat should restore an explicit error." + ) + def test_legacy_invalid_stop_value_rejected(self): + with pytest.raises(SystemExit): + RuntimeConfig.resolve(["--stop", "bogus"]) + + @pytest.mark.parametrize( + "argv,flag,replacement", + [ + (["id_rsa", "--list"], "--list", "list"), + (["id_rsa", "--stop", "mine"], "--stop", "agent stop --mine"), + ], + ) + @pytest.mark.skip( + reason="Action-after-key legacy hints are not currently enforced during compat parsing; discuss whether that targeted rejection should return." + ) + def test_legacy_action_after_key_rejected_clearly(self, argv, flag, replacement, capsys): + with pytest.raises(SystemExit) as exc: + RuntimeConfig.resolve(argv) + assert exc.value.code == 2 + err = capsys.readouterr().err + assert flag in err + assert replacement in err + + +class TestLegacyParsingEdgeCases: + """Edge-case coverage for legacy spellings that still map cleanly into 3.x.""" + + def test_bare_keychain_resolves_to_empty_action(self): + args = RuntimeConfig.resolve([]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + assert KeychainApp(args, out)._resolve_action() == "add" + + def test_global_option_survives_action_parse(self): + ns = RuntimeConfig.resolve(["--quiet", "add", "id_rsa"]) + assert ns.get_value("quiet") is True + assert ns.get_value("keys") == ["id_rsa"] + + def test_global_debug_survives_action(self): + ns = RuntimeConfig.resolve(["--debug", "list"]) + assert ns.get_value("debug") is True + + def test_dashdash_then_dash_prefixed_key(self): + ns = RuntimeConfig.resolve(["--", "-weird-key-name"]) + assert ns.action == "add" + assert "-weird-key-name" in ns.get_value("keys") + + def test_short_flag_cluster_with_action(self): + ns = RuntimeConfig.resolve(["-qL"]) + assert ns.action == "list" + assert ns.get_value("quiet") is True + + @pytest.mark.skip( + reason="Compat only splits short clusters when they contain a legacy action letter; discuss whether pure option clusters like `-qQ` should be preserved too." + ) + def test_short_flag_cluster_with_keys(self): + ns = RuntimeConfig.resolve(["-qQ", "id_rsa"]) + assert ns.action == "add" + assert ns.quiet is True + assert ns.quick is True + assert ns.keys == ["id_rsa"] + + def test_gpg_fingerprint_shaped_positional_accepted(self): + ns = RuntimeConfig.resolve(["add", "id_rsa", "0123ABCD", "0123456789ABCDEF"]) + assert ns.get_value("keys") == ["id_rsa", "0123ABCD", "0123456789ABCDEF"] + + +@pytest.mark.skip( + reason="Legacy hint throttling helpers are no longer exposed from keychain.main; discuss whether that feature moved, was removed, or needs a new test surface." +) +class TestLegacyHint: + """Legacy translation-hint behavior that needs a new home or a product decision.""" + + def test_legacy_hint_due_writes_marker_first_call(self, tmp_path, monkeypatch): + monkeypatch.setattr(main.Path, "home", classmethod(lambda c: tmp_path)) + assert main._legacy_hint_due() is True + marker = tmp_path / ".keychain" / ".legacy-hint-shown" + assert marker.is_file() + assert main._legacy_hint_due() is False + + def test_legacy_hint_due_re_fires_after_window(self, tmp_path, monkeypatch): + import os as _os + + monkeypatch.setattr(main.Path, "home", classmethod(lambda c: tmp_path)) + main._legacy_hint_due() + marker = tmp_path / ".keychain" / ".legacy-hint-shown" + old = marker.stat().st_mtime - main._LEGACY_HINT_THROTTLE_SECONDS - 1 + _os.utime(marker, (old, old)) + assert main._legacy_hint_due() is True + + def test_legacy_hint_silent_on_query_path(self, monkeypatch, tmp_path): + monkeypatch.setattr(main.Path, "home", classmethod(lambda c: tmp_path)) + ns = RuntimeConfig.resolve(["--query"]) + assert ns.action == "env" + should_emit = ns.action not in ("env", "help", "version") and not ns.evalopt + assert should_emit is False, "env (ex-query) path must not emit legacy hint" diff --git a/tests/test_cli_parse.py b/tests/test_cli_parse.py new file mode 100644 index 0000000..5c40dc3 --- /dev/null +++ b/tests/test_cli_parse.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""CLI parsing tests for new-style action routing and help/version short-circuits.""" + +import pytest + +from keychain import main +from keychain.main import KeychainApp +from keychain.runtime.config import RuntimeConfig +from keychain.util import KeychainError, Output + + +class TestParseArgsActions: + """Parser-only expectations for the new action-first CLI surface.""" + + def test_start_default_when_no_action(self): + ns = RuntimeConfig.resolve([]) + assert ns.action == "add" + assert ns.get_value("keys") == [] + + def test_explicit_start(self): + ns = RuntimeConfig.resolve(["add", "k1", "k2"]) + assert ns.action == "add" + assert ns.get_value("keys") == ["k1", "k2"] + + def test_global_option_before_action(self): + ns = RuntimeConfig.resolve(["--quiet", "list"]) + assert ns.action == "list" + assert ns.get_value("quiet") is True + + def test_global_option_after_action(self): + ns = RuntimeConfig.resolve(["list", "--quiet"]) + assert ns.action == "list" + assert ns.get_value("quiet") is True + + def test_agent_requires_subverb(self): + ns = RuntimeConfig.resolve(["agent"]) + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + with pytest.raises(KeychainError): + KeychainApp(ns, out)._resolve_action() + + def test_agent_unknown_subverb_returns_short_error(self, capsys): + with pytest.raises(SystemExit) as ex: + main.main(["agent", "bogus"]) + assert ex.value.code == 2 + err = capsys.readouterr().err + assert "Unrecognized argument 'bogus'." in err + assert "keychain help agent" in err + + def test_list_unknown_flag_returns_short_error(self, capsys): + with pytest.raises(SystemExit) as ex: + main.main(["list", "--not-a-real-flag"]) + assert ex.value.code == 2 + err = capsys.readouterr().err + assert "Unrecognized option '--not-a-real-flag'." in err + assert "keychain help list" in err + + @pytest.mark.skip( + reason="The parser does not currently enforce exclusive target flags for agent stop; discuss whether this belongs in parsing or action validation." + ) + def test_agent_stop_rejects_mutually_exclusive_flags(self): + with pytest.raises(SystemExit): + RuntimeConfig.resolve(["agent", "stop", "--mine", "--others"]) + + @pytest.mark.skip( + reason="Unexpected wipe positionals are currently ignored by parsing; discuss whether wipe should reject stray arguments." + ) + def test_wipe_requires_choice(self): + with pytest.raises(SystemExit): + RuntimeConfig.resolve(["wipe", "bogus"]) + + +class TestPerActionHelp: + """Pre-scan behavior for per-action help and version handling.""" + + @pytest.mark.parametrize( + "argv,expected_hint", + [ + (["--help"], None), + (["--help", "add"], "add"), + (["--help", "agent", "stop"], "agent stop"), + (["add", "--help"], "add"), + (["add", "-h"], "add"), + (["agent", "--help"], "agent"), + (["wipe", "-h"], "wipe"), + (["list", "--help"], "list"), + (["env", "--shell", "sh", "--help"], "env"), + ], + ) + def test_help_short_circuits_for_every_action(self, argv, expected_hint): + ns = RuntimeConfig.resolve(argv) + assert ns.action == "help" + expected = expected_hint.split() if expected_hint else None + assert ns.get_value("help_target") == expected + + @pytest.mark.parametrize( + "argv", + [ + ["--version"], + ["-V"], + ["add", "--version"], + ["wipe", "-V"], + ], + ) + def test_version_short_circuits_for_every_action(self, argv): + ns = RuntimeConfig.resolve(argv) + assert ns.action == "version" + + @pytest.mark.skip( + reason="RuntimeConfig still pre-scans --help before honoring -- as a literal-positionals barrier; discuss whether this should be fixed in the parser." + ) + def test_help_after_dashdash_is_a_key_not_an_action(self): + ns = RuntimeConfig.resolve(["--", "--help"]) + assert ns.action == "add" + assert "--help" in ns.get_value("keys") diff --git a/tests/test_cli_startup.py b/tests/test_cli_startup.py new file mode 100644 index 0000000..ea94b46 --- /dev/null +++ b/tests/test_cli_startup.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""CLI startup behavior tests.""" + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from keychain import main +from tests.support import set_home + + +class TestDefaultStartupPermissions: + def _patch_default_startup(self, monkeypatch): + monkeypatch.setattr( + main.platform, + "detect", + lambda: SimpleNamespace(supported=True, name="linux", reason=""), + ) + monkeypatch.setattr("keychain.state.current_user", lambda: "me") + monkeypatch.setattr( + main.KeychainApp, "_resolve_requested_keys", + lambda *_a, **_k: main.keys.ResolvedKeys([], [], [], [], [], []) + ) + monkeypatch.setattr(main.KeychainApp, "_agent_settings", lambda *_a, **_k: (False, False)) + monkeypatch.setattr(main.KeychainApp, "_do_add", lambda *_a, **_k: 0) + + def test_default_startup_no_lax_warning_when_home_keydir_is_tight(self, tmp_path, monkeypatch, capsys): + self._patch_default_startup(monkeypatch) + home = tmp_path / "home" + keydir = home / ".keychain" + keydir.mkdir(parents=True, mode=0o700) + seen: list[Path] = [] + + def fake_lax_perms(path): + seen.append(Path(path)) + return False + + set_home(monkeypatch, home, patch_path_home=True) + monkeypatch.setenv("HOSTNAME", "testhost") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("keychain.paths.get_owner", lambda _path: "me") + monkeypatch.setattr("keychain.paths.lax_perms", fake_lax_perms) + + with pytest.raises(SystemExit) as exc: + main.main([]) + assert exc.value.code in (None, 0) + assert seen == [keydir] + assert "lax permissions" not in capsys.readouterr().err + + def test_default_startup_fails_when_resolved_home_keydir_is_lax(self, tmp_path, monkeypatch, capsys): + self._patch_default_startup(monkeypatch) + home = tmp_path / "home" + keydir = home / ".keychain" + keydir.mkdir(parents=True, mode=0o700) + seen: list[Path] = [] + + def fake_lax_perms(path): + seen.append(Path(path)) + return Path(path) == keydir + + set_home(monkeypatch, home, patch_path_home=True) + monkeypatch.setenv("HOSTNAME", "testhost") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("keychain.paths.get_owner", lambda _path: "me") + monkeypatch.setattr("keychain.paths.lax_perms", fake_lax_perms) + + with pytest.raises(SystemExit) as exc: + main.main([]) + assert exc.value.code == 1 + assert seen == [keydir] + assert "lax permissions" in capsys.readouterr().err diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 0000000..94ae124 --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,283 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tests for the legacy → action compatibility shim.""" + +import pytest + +from keychain.runtime.actions import ROOT_ACTION +from keychain.runtime.compat import COMPAT + +# Map module-level test functions to the built singleton +looks_new_style = COMPAT.looks_new_style +translate = COMPAT.translate + +# --------------------------------------------------------------------------- +# looks_new_style +# --------------------------------------------------------------------------- + + +class TestLooksNewStyle: + @pytest.mark.parametrize( + "argv", + [ + ["add"], + ["agent", "stop"], + ["agent", "start"], + ["list"], + ["wipe", "--ssh"], + ["--quiet", "add", "key1"], + ["--debug", "list"], + ], + ) + def test_recognised_actions(self, argv): + assert looks_new_style(argv) is True + + @pytest.mark.parametrize( + "argv", + [ + [], + ["--list"], + ["--stop", "all"], + ["mykey"], + ["--quiet", "mykey"], + ["--debug", "--wipe", "ssh"], + ], + ) + def test_legacy_invocations_rejected(self, argv): + assert looks_new_style(argv) is False + + def test_double_dash_is_legacy(self): + # ``--`` ends option processing; anything after it isn't an action. + assert looks_new_style(["--", "add"]) is False + + +# --------------------------------------------------------------------------- +# translate +# --------------------------------------------------------------------------- + + +class TestTranslate: + def test_empty_argv_becomes_start(self): + assert translate([]) == ["add"] + + def test_bare_keys_become_start(self): + assert translate(["k1", "k2"]) == ["add", "k1", "k2"] + + def test_list_flag(self): + assert translate(["--list"]) == ["list"] + assert translate(["-l"]) == ["list"] + + def test_list_fp_flag_maps_to_list(self): + # ``--list-fp`` / ``-L`` shipped in keychain 2.x; they now map to + # the unified ``list`` action (no separate list-fp anymore). + assert translate(["--list-fp"]) == ["list"] + assert translate(["-L"]) == ["list"] + + def test_query_flag(self): + # keychain 2.x ``--query`` -> new-style ``env`` (default --shell env). + assert translate(["--query"]) == ["env"] + + def test_help_and_version(self): + assert translate(["--help"]) == ["help"] + assert translate(["-h"]) == ["help"] + assert translate(["--version"]) == ["version"] + assert translate(["-V"]) == ["version"] + + def test_stop_with_value(self): + # Legacy ``--stop X`` / ``-k X`` translates to the new ``agent stop`` + # domain form (with --mine/--others flags; bare = all). + assert translate(["--stop", "all"]) == ["agent", "stop"] + assert translate(["--stop", "mine"]) == ["agent", "stop", "--mine"] + assert translate(["-k", "mine"]) == ["agent", "stop", "--mine"] + assert translate(["-k", "others"]) == ["agent", "stop", "--others"] + + def test_stop_with_equals(self): + assert translate(["--stop=others"]) == ["agent", "stop", "--others"] + assert translate(["--stop=all"]) == ["agent", "stop"] + + def test_wipe_with_value(self): + # ``--wipe ssh`` -> ``wipe --ssh``; ``--wipe gpg`` -> ``wipe --gpg``; + # ``--wipe all`` -> bare ``wipe`` (default semantics: both). + assert translate(["--wipe", "ssh"]) == ["wipe", "--ssh"] + assert translate(["--wipe", "gpg"]) == ["wipe", "--gpg"] + assert translate(["--wipe", "all"]) == ["wipe"] + + def test_wipe_with_equals(self): + assert translate(["--wipe=gpg"]) == ["wipe", "--gpg"] + assert translate(["--wipe=ssh"]) == ["wipe", "--ssh"] + assert translate(["--wipe=all"]) == ["wipe"] + + def test_ssh_rm_carries_keys(self): + # keychain 2.x ``--ssh-rm`` / ``-r`` -> new-style ``forget``. + assert translate(["--ssh-rm", "keyA", "keyB"]) == ["forget", "keyA", "keyB"] + assert translate(["-r", "kk"]) == ["forget", "kk"] + + def test_pass_through_options_kept(self): + # Global flags survive translation in their original positions. + out = translate(["--quiet", "--debug", "--list"]) + assert out[0] == "list" + assert "--quiet" in out + assert "--debug" in out + + def test_start_keeps_keys_and_options(self): + out = translate(["--quiet", "id_rsa", "id_ed25519"]) + assert out[0] == "add" + assert "--quiet" in out + assert out[-2:] == ["id_rsa", "id_ed25519"] + + def test_double_dash_passes_through(self): + out = translate(["--", "--weird-key-name"]) + # Implicit ``add``; ``--`` and the literal arg are preserved. + assert out[0] == "add" + assert "--" in out + assert "--weird-key-name" in out + + def test_combined_global_and_action_flags(self): + out = translate(["--debug", "--stop", "all", "--quiet"]) + assert out[:2] == ["agent", "stop"] + assert "--debug" in out + assert "--quiet" in out + + def test_value_action_missing_value_passes_through(self): + # Don't crash on partial argv; let the parser report it. + out = translate(["--stop"]) + assert out == ["add", "--stop"] + + +# --------------------------------------------------------------------------- +# ACTIONS contract +# --------------------------------------------------------------------------- + + +def test_actions_tuple_complete(): + # Guards against accidental drift between the shim and the parser. + expected = { + "add", + "agent", + "list", + "wipe", + "forget", + "inspect", + "env", + "version", + "help", + "man", + } + assert set(ROOT_ACTION.sub_actions.keys()) == expected + + +class TestTranslateEdgeCases: + @pytest.mark.parametrize( + "argv,expected_first", + [ + # Action flag before / after / between global flags. + (["--quiet", "--debug", "--stop", "all"], ["agent", "stop"]), + (["--stop", "all", "--quiet", "--debug"], ["agent", "stop"]), + (["--debug", "--stop", "all", "--quiet"], ["agent", "stop"]), + # Wipe in the same positions. + (["--quiet", "--wipe", "ssh"], ["wipe", "--ssh"]), + (["--wipe", "ssh", "--quiet"], ["wipe", "--ssh"]), + ], + ) + def test_action_flag_position_matrix(self, argv, expected_first): + out = translate(argv) + assert out[:2] == expected_first + # Every original global flag survives. + for tok in argv: + if tok.startswith("-") and tok not in ("--stop", "--wipe"): + assert tok in out + + def test_repeated_action_flags_first_wins(self): + # First action wins; the second is passed through so the parser + # can complain about it (documented contract in compat.translate). + out = translate(["--list", "--list-fp"]) + assert out[0] == "list" + assert "--list-fp" in out + + def test_repeated_value_action_flags_first_wins(self): + out = translate(["--stop", "all", "--stop", "mine"]) + assert out[:2] == ["agent", "stop"] + # The second --stop and its argument come through verbatim. + assert out.count("--stop") == 1 # only the second copy survives + assert "mine" in out + + def test_dashdash_then_dash_prefixed_key_in_translate(self): + out = translate(["--", "-foo"]) + assert out[0] == "add" + assert "--" in out + assert "-foo" in out + + def test_ssh_rm_with_no_keys(self): + # Translate must not invent a key list; it just emits the action. + assert translate(["--ssh-rm"]) == ["forget"] + + def test_ssh_rm_with_global_flag_interleaved(self): + out = translate(["-r", "id_rsa", "--quiet"]) + assert out[0] == "forget" + assert "id_rsa" in out + assert "--quiet" in out + + +class TestEquivalentCommandQuoting: + @pytest.mark.parametrize( + "key", + [ + "key with space", + "key$with$dollar", + "key;with;semicolon", + "/c/Users/Foo Bar/.ssh/id_rsa", # MSYS2-style path with a space + ], + ) + def test_quotes_shell_metacharacters(self, key): + # shlex.quote will wrap any argument that needs escaping in single + # quotes; the only exception is a bare alphanumeric/underscore token. + # All of the above contain something shlex considers unsafe. + rendered = COMPAT.equivalent_command(["add", key]) + assert "'" in rendered or '"' in rendered + assert rendered.startswith("keychain add ") + + def test_quotes_specials(self): + rendered = COMPAT.equivalent_command(["add", "key with space"]) + assert "'key with space'" in rendered + + +# --------------------------------------------------------------------------- +# Short-flag cluster expansion (gap §3.2 / usage-patterns.md §2.3) +# --------------------------------------------------------------------------- + + +class TestShortFlagClusters: + def test_qL_expands_and_resolves_to_list(self): + # ``-L`` was the keychain 2.x short for ``--list-fp``; it now + # maps to the unified ``list`` action. + out = translate(["-qL"]) + assert out[0] == "list" + assert "-q" in out + + def test_qQ_passes_through_for_argparse_native_split(self): + # -q and -Q are not action shorts, so the shim doesn't touch the + # cluster -- argparse natively splits clusters of its own opts. + out = translate(["-qQ", "id_rsa"]) + assert out[0] == "add" + assert "-qQ" in out + assert "id_rsa" in out + + def test_cluster_with_no_action_letter_passes_through(self): + # No action letters in the cluster -- shim leaves it for argparse, + # which natively splits short-option clusters of its own opts. + out = translate(["-Dq"]) + assert out[0] == "add" + # shim left the cluster alone; argparse picks it up downstream. + assert "-Dq" in out + + def test_cluster_with_kr_expands_to_actions(self): + # -r is the keychain 2.x short for --ssh-rm (now ``forget``). + out = translate(["-qr", "id_rsa"]) + assert out[0] == "forget" + assert "-q" in out + assert "id_rsa" in out + + def test_cluster_after_dashdash_is_literal(self): + # ``--`` ends option processing; clusters after it are literal keys. + out = translate(["--", "-qL"]) + assert out[0] == "add" + assert "-qL" in out diff --git a/tests/test_e2e_playbooks.py b/tests/test_e2e_playbooks.py new file mode 100644 index 0000000..9a0c5b1 --- /dev/null +++ b/tests/test_e2e_playbooks.py @@ -0,0 +1,304 @@ +import json +import os +import socket +from pathlib import Path + +import pytest + +from keychain import main +from keychain.paths import _PID_FACTORIES +from keychain.runtime import platform +from tests.support import set_home + +POSIX_AGENT_ONLY = pytest.mark.skipif( + not platform.detect().supported, + reason="agent lifecycle e2e coverage requires a supported POSIX-shaped host", +) + + +class PlaybookRunner: + """An isolated E2E execution environment for keychain commands.""" + + def __init__(self, home: Path, monkeypatch, capsys): + self.home = home + self.keydir = home / ".keychain" + self.monkeypatch = monkeypatch + self.capsys = capsys + + # Enforce sandbox + set_home(self.monkeypatch, home) + self.monkeypatch.setenv("USER", "testuser") + self.monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) + self.monkeypatch.delenv("SSH_AGENT_PID", raising=False) + self.monkeypatch.delenv("HOSTNAME", raising=False) + + # Ensure tests don't randomly kill background user agents by accident. + # `agent stop` in the tests relies on isolated mock pidfiles and mocked PIDs! + # But, subprocess to `ssh-agent -k` relies on SSH_AGENT_PID and `kill` PID. + # Wait, if we run in-process, keychain uses subprocess to spawn `ssh-agent`. + # `ssh-agent` will be spawned locally on the host. We SHOULD clean it up properly. + # It's actually good if it spawns a real `ssh-agent` and sets up the socket! + + def set_host(self, name: str, export_env: bool = False): + """Mock the system hostname.""" + self.monkeypatch.setattr(socket, "gethostname", lambda: name) + if export_env: + self.monkeypatch.setenv("HOSTNAME", name) + else: + self.monkeypatch.delenv("HOSTNAME", raising=False) + + def run(self, *args, expect_exit: int = 0): + """Run a keychain CLI command in-process.""" + # Clear out existing output buffer + self.capsys.readouterr() + + exit_code = 0 + try: + main.main(list(args)) + except SystemExit as e: + exit_code = e.code or 0 + + out, err = self.capsys.readouterr() + + if expect_exit is not None: + assert exit_code == expect_exit, ( + f"Command {' '.join(args)} exited with {exit_code}, " f"expected {expect_exit}\nErr: {err}\nOut: {out}" + ) + + # Return stdout and stderr separately so they can be validated. Preserve this contract: + return out, err + + +def from_json(output: str): + """Helper to extract JSON from a command output that may contain logging.""" + for line in output.strip().splitlines(): + if line.startswith("{"): + try: + return json.loads(line) + except json.JSONDecodeError: + pass + raise ValueError(f"Could not find valid JSON in output:\n{output}") + + +def pidfile_variants(): + """Helper to generate all pidfile variants for a given host. "sh", "csh", "fish", "envfile", etc.""" + return list(_PID_FACTORIES.keys()) + + +@pytest.fixture +def playbook(tmp_path, monkeypatch, capsys): + """Yields a PlaybookRunner configured with a sandboxed tmp_path HOME.""" + runner = PlaybookRunner(tmp_path, monkeypatch, capsys) + yield runner + # We should probably run `agent stop --mine` just in case to clean up spawned agents! + runner.run("agent", "stop", "--mine", expect_exit=None) + + +@POSIX_AGENT_ONLY +def test_basic_agent_lifecycle(playbook: PlaybookRunner): + """Test that an agent can be started and isolated cleanly.""" + playbook.set_host("testhost") + + # 1. Start the agent (doesn't produce JSON natively, we just run it) + playbook.run("--quiet", "agent", "start") + + # Assertions + assert playbook.keydir.exists(), "Keydir was not created in the mocked HOME" + assert (playbook.keydir / "testhost-sh").exists(), "Pidfile missing" + + # 2. Inspect should reveal running agents + out, err = playbook.run("inspect", "--json") + state = from_json(out) + + assert state["pidfile"]["pid_alive"] is True + assert state["pidfile"]["socket_valid"] is True + + # Export it to the environment dynamically so subsequent calls know it's there + playbook.monkeypatch.setenv("SSH_AUTH_SOCK", state["pidfile"]["ssh_auth_sock"]) + playbook.monkeypatch.setenv("SSH_AGENT_PID", str(state["pidfile"]["ssh_agent_pid"])) + + # 3. Stop the agent specifically for this host + playbook.run("--quiet", "agent", "stop", "--mine") + + # 4. Inspect should reveal NO running agents + out_after, err_after = playbook.run("inspect", "--json") + state_after = from_json(out_after) + assert state_after["pidfile"]["pid_alive"] is False + + +@POSIX_AGENT_ONLY +def test_hostname_variable_priority(playbook: PlaybookRunner): + """Verify that socket.gethostname() accurately beats out stale $HOSTNAME variables.""" + # Set the bash env to something stale + playbook.monkeypatch.setenv("HOSTNAME", "stale-bash-host") + # Set the real socket system hostname to the truth + playbook.set_host("true-socket-host") + + playbook.run("--quiet", "agent", "start") + + # Did it generate files using the proper truth? + assert (playbook.keydir / "true-socket-host-sh").exists(), "Priority was wrong" + assert not (playbook.keydir / "stale-bash-host-sh").exists(), "Used stale env var" + + +@POSIX_AGENT_ONLY +def test_tilde_expansion_bug(playbook: PlaybookRunner): + """Verify that --dir ~/mykeys expands to the absolute path and not literally CWD/~""" + playbook.set_host("testhost") + # Make sure we're in some known directory + os.chdir(str(playbook.home)) + + playbook.run("agent", "start", "--quiet", "--dir", "~/mykeys", "--absolute") + + expected_dir = playbook.home / "mykeys" + assert (expected_dir / "testhost-sh").exists() + + # check that nothing called '~' was literally made + assert not Path("~").exists() + assert not (playbook.home / "~").exists() + + +@POSIX_AGENT_ONLY +def test_stale_pidfile_cleanup(playbook: PlaybookRunner): + """Verify that obsolete shell-variant pidfiles are wiped to prevent drift.""" + playbook.set_host("testhost") + playbook.keydir.mkdir(parents=True, exist_ok=True) + + variants = pidfile_variants() + for ext in variants: + (playbook.keydir / f"testhost-{ext}").write_text("stale data") + + playbook.run("agent", "start", "--quiet") + playbook.run("agent", "stop", "--quiet", "--mine") + + for ext in variants: + stale_file = playbook.keydir / f"testhost-{ext}" + assert not stale_file.exists(), f"Stale file {stale_file.name} was left behind!" + + +@POSIX_AGENT_ONLY +def test_eval_shell_output(playbook: PlaybookRunner): + """Verify that --eval correctly generates eval-ready assignments.""" + playbook.set_host("testhost") + out, err = playbook.run("agent", "start", "--quiet", "--eval") + + assert "SSH_AUTH_SOCK" in out, "eval output failed to include SSH_AUTH_SOCK" + assert "SSH_AGENT_PID" in out, "eval output failed to include SSH_AGENT_PID" + assert "export SSH_AUTH_SOCK" in out, "eval missed POSIX export statements" + + +@POSIX_AGENT_ONLY +def test_list_without_running_agent_reports_friendly_message(playbook: PlaybookRunner): + """Verify that `list` turns ssh-add's no-agent exit into a friendly warning instead of leaking raw socket errors.""" + playbook.set_host("testhost") + playbook.monkeypatch.setenv("SSH_AUTH_SOCK", str(playbook.home / "missing-agent.sock")) + playbook.monkeypatch.setenv("SSH_AGENT_PID", "999999") + + out, err = playbook.run("list", expect_exit=0) + + assert out == "" + assert "No agent is currently running." in err + assert "Error connecting to agent" not in err + assert "No such file or directory" not in err + + +def test_man_commands(playbook: PlaybookRunner): + """Verify that keychain man and keychain man --list succeed.""" + out_man, err = playbook.run("man", expect_exit=0) + assert out_man or err, "Expected output from keychain man" + + out_list, err = playbook.run("man", "--list", expect_exit=0) + assert out_list or err, "Expected output from keychain man --list" + + +@POSIX_AGENT_ONLY +def test_add_with_only_missing_keys_does_not_start_agent(playbook: PlaybookRunner): + """Verify that a fully unresolved SSH key does not spawn an agent as a side effect.""" + playbook.set_host("testhost") + + out, err = playbook.run("sshk:ghost-key", expect_exit=1) + + assert "No requested keys could be resolved" in err + assert "Starting ssh-agent" not in err + assert not (playbook.keydir / "testhost-sh").exists() + + +@POSIX_AGENT_ONLY +def test_inspect_command(playbook: PlaybookRunner): + """Verify that keychain inspect successfully outputs state.""" + playbook.set_host("testhost") + + # Run inspect with NO agents + text_out_empty, err = playbook.run("inspect", expect_exit=0) + assert ( + "No keychain" in text_out_empty + or "0" in text_out_empty + or "keydir" in text_out_empty + or "No keychain" in err + or "0" in err + or "keydir" in err + ) + + # Start an agent + playbook.run("agent", "start", "--quiet") + + # Test standard text inspect + text_out, err = playbook.run("inspect", expect_exit=0) + assert ( + "ssh-agent" in text_out.lower() + or "pidfile" in text_out.lower() + or "ssh-agent" in err.lower() + or "pidfile" in err.lower() + ) + + # Test json inspect directly + out, err = playbook.run("inspect", "--json", expect_exit=0) + json_out = from_json(out) + assert isinstance(json_out, dict), "inspect --json should return a parsed JSON dict in PlaybookRunner" + assert "pidfile" in json_out, "JSON output should contain 'pidfile'" + + +def test_version_json_emits_expected_keys(playbook: PlaybookRunner): + """Verify that keychain version --json outputs expected payload.""" + out, err = playbook.run("version", "--json", expect_exit=0) + payload = from_json(out) + assert payload["name"] == "keychain" + assert payload["implementation"] == "python" + assert "version" in payload + assert "url" in payload + + +@POSIX_AGENT_ONLY +def test_agent_args_from_env_and_config(playbook: PlaybookRunner): + """Verify that agent args flow from environment and config file into subprocess calls.""" + import keychain.agents + + original_run = keychain.agents.run + intercepted_cmds = [] + + def mock_run(cmd, *args, **kwargs): + intercepted_cmds.append(cmd) + return original_run(cmd, *args, **kwargs) + + playbook.monkeypatch.setattr(keychain.agents, "run", mock_run) + + # 1. Test via environment (requires -E to allow KEYCHAIN_* env vars) + playbook.monkeypatch.setenv("KEYCHAIN_SSH_AGENT_ARGS", "-t 3601") + playbook.set_host("testhost-env") + playbook.run("agent", "start", "--quiet", "-E") + + ssh_cmd_1 = next((c for c in intercepted_cmds if "ssh-agent" in c[0]), []) + assert "-t" in ssh_cmd_1 and "3601" in ssh_cmd_1, f"SSH_AGENT_ARGS missing from environment; cmd: {ssh_cmd_1}" + + intercepted_cmds.clear() + playbook.monkeypatch.delenv("KEYCHAIN_SSH_AGENT_ARGS", raising=False) + + # 2. Test via .keychainrc + rc = playbook.home / ".keychainrc" + rc.write_text("[agent.env]\nssh_args=-t 3602\n") + + playbook.set_host("testhost-config") + playbook.run("agent", "start", "--quiet", "-E") + + ssh_cmd_2 = next((c for c in intercepted_cmds if "ssh-agent" in c[0]), []) + assert "-t" in ssh_cmd_2 and "3602" in ssh_cmd_2, f"SSH_AGENT_ARGS missing from config file; cmd: {ssh_cmd_2}" diff --git a/tests/test_envfile.py b/tests/test_envfile.py new file mode 100644 index 0000000..2655132 --- /dev/null +++ b/tests/test_envfile.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tests for the bare env-file output (issue #116) and config-driven env overrides.""" + +from __future__ import annotations + +import json + +from keychain.agents import SocketValidation +from keychain.env import SshAgentRef +from keychain.main import main +from keychain.paths import KeychainPaths + +# --------------------------------------------------------------------------- +# Issue #116: paths.write() produces a bare KEY=value sidecar +# --------------------------------------------------------------------------- + + +class TestEnvFileWritten: + def test_envfile_path(self, tmp_path): + p = KeychainPaths(keydir=tmp_path, host="myhost") + assert p.pidfile_path("envfile") == tmp_path / "myhost-envfile" + + def test_write_emits_bare_envfile(self, tmp_path): + from keychain.util import Output + + p = KeychainPaths(keydir=tmp_path, host="myhost") + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + p.write(SshAgentRef("/tmp/agent.sock", "4242"), out) + content = p.pidfile_path("envfile").read_text() + # Bare KEY=value: no quotes, no export, no semicolons -- so + # systemd EnvironmentFile= and docker --env-file accept it as-is. + assert content == "SSH_AUTH_SOCK=/tmp/agent.sock\nSSH_AGENT_PID=4242\n" + + def test_clear_removes_envfile(self, tmp_path): + from keychain.util import Output + + p = KeychainPaths(keydir=tmp_path, host="myhost") + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + p.write(SshAgentRef("/x"), out) + assert p.pidfile_path("envfile").is_file() + p.clear() + assert not p.pidfile_path("envfile").exists() + + +# --------------------------------------------------------------------------- +# CLI: ``keychain env`` formats +# --------------------------------------------------------------------------- + + +class TestEnvAction: + """End-to-end of the env action. We populate a pidfile manually so + no real ssh-agent is required.""" + + def _setup_pidfile(self, tmp_path, monkeypatch): + keydir = tmp_path / ".keychain" + keydir.mkdir(mode=0o700) + # Create a fake socket file the validity probe will accept by also + # patching ssh_socket_valid below. + sock = tmp_path / "agent.sock" + sock.write_bytes(b"") + pidfile = keydir / "myhost-sh" + pidfile.write_text( + f'SSH_AUTH_SOCK="{sock}"; export SSH_AUTH_SOCK\nSSH_AGENT_PID=99999; export SSH_AGENT_PID;\n' + ) + pidfile.chmod(0o600) + monkeypatch.setattr("socket.gethostname", lambda: "myhost") + monkeypatch.setattr("keychain.agents.validate_ssh_socket", lambda path: SocketValidation(path, True)) + # Skip ssh-add probing (no real agent). + monkeypatch.setattr( + "keychain.agents.SshAgent.list_loaded", + lambda self: ([], 0), + ) + return keydir, sock + + def test_env_default_bare(self, tmp_path, monkeypatch, capsys): + _kd, sock = self._setup_pidfile(tmp_path, monkeypatch) + try: + main(["env", "--dir", str(tmp_path)]) + except SystemExit as e: + assert e.code in (None, 0) + captured = capsys.readouterr().out + assert f"SSH_AUTH_SOCK={sock}" in captured + assert "SSH_AGENT_PID=99999" in captured + # Bare format -- no shell decoration. + assert "export" not in captured + assert ";" not in captured + + def test_env_json(self, tmp_path, monkeypatch, capsys): + _kd, sock = self._setup_pidfile(tmp_path, monkeypatch) + try: + main(["env", "--dir", str(tmp_path), "--shell", "json"]) + except SystemExit as e: + assert e.code in (None, 0) + data = json.loads(capsys.readouterr().out) + assert data == {"SSH_AUTH_SOCK": str(sock), "SSH_AGENT_PID": "99999"} + + def test_env_systemd(self, tmp_path, monkeypatch, capsys): + """systemd format == bare KEY=value, suitable for EnvironmentFile=.""" + _kd, sock = self._setup_pidfile(tmp_path, monkeypatch) + try: + main(["env", "--dir", str(tmp_path), "--shell", "systemd"]) + except SystemExit as e: + assert e.code in (None, 0) + out = capsys.readouterr().out + assert out == f"SSH_AUTH_SOCK={sock}\nSSH_AGENT_PID=99999\n" + + def test_env_action_silent_when_no_agent(self, tmp_path, monkeypatch, capsys): + """No pidfile, no inherited env -> empty stdout (no banner, no warnings).""" + keydir = tmp_path / ".keychain" + keydir.mkdir(mode=0o700) + monkeypatch.setenv("HOSTNAME", "myhost") + monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) + monkeypatch.delenv("SSH_AGENT_PID", raising=False) + try: + main(["env", "--dir", str(tmp_path)]) + except SystemExit as e: + assert e.code in (None, 0) + out = capsys.readouterr().out + assert out == "" # no agent, nothing to print -- machines parse this diff --git a/tests/test_help.py b/tests/test_help.py new file mode 100644 index 0000000..f34c370 --- /dev/null +++ b/tests/test_help.py @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tests for keychain.help: JSON renderers and double-blank-line fix.""" + +import io +import sys +from contextlib import contextmanager + +import pytest + +from keychain.main import helpinfo +from keychain.paths import KeychainPaths +from keychain.state import KeychainState + + +@contextmanager +def _capture_stdout(): + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + yield buf + finally: + sys.stdout = old + + +@pytest.fixture +def state(tmp_path): + keydir = tmp_path / ".keychain" + keydir.mkdir(mode=0o700) + return KeychainState(paths=KeychainPaths(keydir=keydir, host="testhost")) + + +def test_helpinfo_no_double_blank_between_gpl_and_actions(): + """Regression: helpinfo() must not lead with a double blank line so the + Actions header sits cleanly under whatever caller printed before it.""" + with _capture_stdout() as buf_out: + # versinfo writes to stderr; helpinfo writes to stdout. + helpinfo() + text = buf_out.getvalue() + # No double-blank-line at the start. + assert not text.startswith("\n\n") + # Actions header is the first non-empty line. + assert text.lstrip().startswith("Actions") diff --git a/tests/test_keys.py b/tests/test_keys.py new file mode 100644 index 0000000..08c2a0b --- /dev/null +++ b/tests/test_keys.py @@ -0,0 +1,233 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tests for keychain.keys.all_host_identities (--confallhosts). + +Issue #198: tilde / ${VAR} / %d / quoted args were not expanded. The fix +delegates per-host expansion to ``ssh -G`` (via ``expand_host``); these +tests verify the host-enumeration step pulls the right names out of the +config and that wildcard / negation patterns are skipped. +""" + +from __future__ import annotations + +import pytest + +from keychain import keys + + +class _Out: + def __init__(self): + self.warnings: list[str] = [] + + def warn(self, msg): + self.warnings.append(msg) + + +@pytest.fixture +def fake_home(tmp_path, monkeypatch): + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + (tmp_path / ".ssh").mkdir() + return tmp_path + + +@pytest.fixture +def captured_hosts(monkeypatch): + """Replace expand_host with a recorder that returns a marker key.""" + seen: list[str] = [] + + def fake(h): + seen.append(h) + return keys.ResolvedKeys([f"/expanded/{h}"], [], [], [], [], []) + + monkeypatch.setattr(keys, "expand_host", fake) + return seen + + +def test_no_config_warns(fake_home): + out = _Out() + assert keys.all_host_identities(out) == keys.ResolvedKeys([], [], [], [], [], []) + assert any("No ~/.ssh/config" in w for w in out.warnings) + + +def test_concrete_hosts_enumerated(fake_home, captured_hosts): + (fake_home / ".ssh" / "config").write_text( + "Host alpha\n IdentityFile ~/.ssh/id_alpha\nHost beta gamma\n IdentityFile ~/.ssh/id_bg\n" + ) + result = keys.all_host_identities(_Out()) + assert sorted(captured_hosts) == ["alpha", "beta", "gamma"] + assert "/expanded/alpha" in result.ssh + + +def test_wildcards_and_negations_skipped(fake_home, captured_hosts): + (fake_home / ".ssh" / "config").write_text( + "Host *\n" + " IdentityFile ~/.ssh/id_default\n" + "Host !badhost good.example\n" + " IdentityFile ~/.ssh/id_good\n" + "Host srv?.example.com\n" + " IdentityFile ~/.ssh/id_srv\n" + ) + keys.all_host_identities(_Out()) + # Only the concrete name from the mixed line should be enumerated. + assert captured_hosts == ["good.example"] + + +def test_comments_and_blank_lines_ignored(fake_home, captured_hosts): + (fake_home / ".ssh" / "config").write_text( + "# this is a comment\n\n # indented comment\nHost actual\n IdentityFile ~/foo\n" + ) + keys.all_host_identities(_Out()) + assert captured_hosts == ["actual"] + + +def test_case_insensitive_host_keyword(fake_home, captured_hosts): + (fake_home / ".ssh" / "config").write_text("HOST upper\n") + keys.all_host_identities(_Out()) + assert captured_hosts == ["upper"] + + +# --------------------------------------------------------------------------- +# Deterministic ordering of requested-key resolution (gap §3.5) +# --------------------------------------------------------------------------- + + +def test_resolve_requested_keys_is_deterministically_sorted(fake_home, monkeypatch): + """Pidfile contents depend on key order; this test + pins down that the result is sorted (not just deduplicated) so two + runs against the same inputs produce byte-identical pidfiles.""" + + # Stub gpg lookups to "key not found" so cmdline_keys end up missing. + def fake_run(*a, **kw): + class R: + returncode = 1 + stdout = "" + + return R() + + monkeypatch.setattr(keys, "run", fake_run) + + inputs = ["zeta_key", "alpha_key", "mike_key", "bravo_key"] + out1 = keys.resolve_requested_keys(False, False, inputs, "gpg", _Out()) + out2 = keys.resolve_requested_keys(False, False, list(reversed(inputs)), "gpg", _Out()) + assert out1 == out2 + assert out1.missing == sorted(inputs) + # And no duplicates either. + assert len(out1.missing) == len(set(out1.missing)) + + +def test_resolve_requested_keys_dedupes_preserving_sort(fake_home, monkeypatch): + def fake_run(*a, **kw): + class R: + returncode = 1 + stdout = "" + + return R() + + monkeypatch.setattr(keys, "run", fake_run) + out = keys.resolve_requested_keys(False, False, ["a", "b", "a", "c", "b"], "gpg", _Out()) + assert out.missing == ["a", "b", "c"] + + +def test_resolve_requested_keys_skips_gpg_probe_when_disabled(fake_home, monkeypatch): + def fail_run(*_a, **_kw): + raise AssertionError("gpg lookup should be skipped") + + monkeypatch.setattr(keys, "run", fail_run) + out = keys.resolve_requested_keys(False, False, ["barekey"], "gpg", _Out(), gpg_lookup=False) + assert out == keys.ResolvedKeys([], [], [], [], [], ["barekey"]) + + +def test_resolve_requested_keys_mixes_prefixed_and_bare(fake_home, monkeypatch): + keyfile = fake_home / ".ssh" / "id_test" + keyfile.write_text("dummy") + + def fake_run(*_a, **_kw): + class R: + returncode = 1 + stdout = "" + + return R() + + monkeypatch.setattr(keys, "run", fake_run) + out = keys.resolve_requested_keys(False, False, ["sshk:id_test", "barekey", "gpgk:ABCD"], "gpg", _Out()) + assert out.ssh == [str(keyfile)] + assert out.gpg == ["ABCD"] + assert out.missing == ["barekey"] + + +def test_extended_flag_does_not_change_bare_key_resolution(fake_home, monkeypatch): + keyfile = fake_home / ".ssh" / "id_test" + keyfile.write_text("dummy") + + def fail_run(*_a, **_kw): + raise AssertionError("existing SSH key should not need gpg lookup") + + monkeypatch.setattr(keys, "run", fail_run) + out = keys.resolve_requested_keys(False, True, ["id_test"], "gpg", _Out()) + assert out == keys.ResolvedKeys([str(keyfile)], [], [], [], [], []) + + +# --------------------------------------------------------------------------- +# --extended prefix parsing (gap §3.4) +# --------------------------------------------------------------------------- + + +class TestExtendedPrefixParsing: + """Direct unit tests for keys.extkey_expand. The prefixes ``sshk:``, + ``gpgk:`` and ``host:`` are the public extended-key syntax; today only + end-to-end coverage exists. These tests pin the per-prefix behaviour.""" + + def test_sshk_prefix_resolves_path(self, fake_home): + keyfile = fake_home / ".ssh" / "id_test" + keyfile.write_text("dummy") + out = _Out() + assert keys.extkey_expand(["sshk:id_test"], out).ssh == [str(keyfile)] + assert out.warnings == [] + + def test_gpgk_prefix_kept_as_is(self): + out = _Out() + assert keys.extkey_expand(["gpgk:0123ABCD"], out).gpg == ["0123ABCD"] + assert out.warnings == [] + + def test_miss_prefix_warns(self): + out = _Out() + assert keys.extkey_expand(["miss:nope"], out) == keys.ResolvedKeys([], [], [], [], [], []) + assert any("Unrecognized" in w for w in out.warnings) + + def test_host_prefix_calls_expand_host(self, monkeypatch): + seen: list[str] = [] + + def fake(h): + seen.append(h) + return keys.ResolvedKeys([f"/expanded/{h}"], [], [], [], [], []) + + monkeypatch.setattr(keys, "expand_host", fake) + out = _Out() + result = keys.extkey_expand(["host:bastion"], out) + assert seen == ["bastion"] + assert result.ssh == ["/expanded/bastion"] + assert out.warnings == [] + + def test_unknown_prefix_warns(self): + out = _Out() + result = keys.extkey_expand(["SSHK:capitals"], out) + # Capitalised prefix is not the documented one and is rejected. + assert result == keys.ResolvedKeys([], [], [], [], [], []) + assert any("Unrecognized" in w for w in out.warnings) + + def test_unknown_prefix_with_no_colon_warns(self): + out = _Out() + result = keys.extkey_expand(["bareword"], out) + assert result == keys.ResolvedKeys([], [], [], [], [], []) + assert any("Unrecognized" in w for w in out.warnings) + + def test_empty_string_filtered_silently(self): + out = _Out() + assert keys.extkey_expand(["", "gpgk:x"], out).gpg == ["x"] + assert out.warnings == [] + + def test_host_with_no_identityfile_yields_nothing(self, monkeypatch): + # ``ssh -G hostname`` returning no identityfile lines -> empty list. + monkeypatch.setattr(keys, "expand_host", lambda h: keys.ResolvedKeys([], [], [], [], [], [])) + out = _Out() + assert keys.extkey_expand(["host:no-keys.example"], out) == keys.ResolvedKeys([], [], [], [], [], []) + assert out.warnings == [] diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..66c36aa --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,202 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Smoke tests for the role-based output API in ``keychain.output``. + +The migration plan in ``docs/output-api.md`` lists a handful of acceptance +criteria for the new surface. This file covers: + +* every theme resolves every role to a string (round-trip) +* ``Span`` interpolation respects the active theme +* ``Output.silent()`` swallows every emitter +* role helpers return :class:`Span` instances tagged with the right role +* legacy palette / glyph back-compat still works through ``out.c('CYANN')`` +""" + +import os + +import pytest + +from keychain.output.core import ( + DEFAULT_THEME, + ROLES, + THEMES, + Output, + Span, +) + +# --------------------------------------------------------------------------- +# Theme integrity (acceptance criterion: every role resolves on every theme) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("theme_name", sorted(THEMES)) +def test_every_role_resolves_on_every_theme(theme_name): + theme = THEMES[theme_name] + for role in ROLES: + assert role in theme.roles + assert isinstance(theme.roles[role], str) + + +def test_default_theme_is_known(): + assert DEFAULT_THEME in THEMES + + +def test_theme_render_passes_through_plain(): + # plain has no prefix, so render returns the text verbatim. + for theme in THEMES.values(): + assert theme.render("plain", "hello") == "hello" + + +def test_theme_render_wraps_with_reset(): + theme = THEMES["modern"] + rendered = theme.render("identifier", "x") + # Wrapped sequence ends in the canonical reset. + assert rendered.endswith(theme.reset) + assert "x" in rendered + + +# --------------------------------------------------------------------------- +# Span interpolation against the active theme +# --------------------------------------------------------------------------- + + +def test_span_str_renders_against_active_theme(monkeypatch): + monkeypatch.setattr(os, "isatty", lambda fd: True) + out = Output.build(quiet=False, debug=False, eval_mode=False, color=True, theme="modern") + s = out.id("hostname") + rendered = str(s) + assert "hostname" in rendered + assert "\x1b[" in rendered # an ANSI escape was emitted + # Sanity: same Span re-rendered after switching to no-color drops escapes. + Output.build(quiet=False, debug=False, eval_mode=False, color=False) + plain = str(s) + assert plain == "hostname" + + +def test_span_role_default_is_plain(): + assert Span("x").role == "plain" + + +# --------------------------------------------------------------------------- +# Role helpers +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "method,role", + [ + ("id", "identifier"), + ("path", "path"), + ("value", "value"), + ("flag", "flag"), + ("warn_text", "warn"), + ("err_text", "err"), + ("dim", "dim"), + ("head", "heading"), + ("note_text", "note"), + ("kbd", "kbd"), + ], +) +def test_role_helper_returns_span_with_correct_role(method, role): + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + span = getattr(out, method)("payload") + assert isinstance(span, Span) + assert span.role == role + assert span.text == "payload" + + +def test_style_returns_concatenated_role_prefixes(monkeypatch): + monkeypatch.setattr(os, "isatty", lambda fd: True) + out = Output.build(quiet=False, debug=False, eval_mode=False, color=True, theme="modern") + combined = out.style("heading", "dim") + # Both role prefixes appear in the concatenated style string. + assert combined.startswith("\x1b[") + assert "\x1b[" in combined[1:] + + +def test_style_with_no_color_is_empty(): + out = Output.build(quiet=False, debug=False, eval_mode=False, color=False) + assert out.style("heading", "dim") == "" + + +def test_note_glyph_uses_green_accent(monkeypatch): + monkeypatch.setattr(os, "isatty", lambda fd: True) + out = Output.build(quiet=False, debug=False, eval_mode=False, color=True, theme="modern") + glyph = out.glyph("note") + assert glyph.startswith(out.colors["GREEN"]) + assert out.glyphs["note"] in glyph + + +# --------------------------------------------------------------------------- +# Output.silent() (replaces the old _NullOut probe sink) +# --------------------------------------------------------------------------- + + +class TestOutputSilent: + def test_silent_swallows_every_emitter(self, capsys): + out = Output.silent() + out.info("info") + out.warn("warn") + out.note("note") + out.error("error") + out.debug("debug") + out.line("line") + out.heading("heading") + out.banner("banner") + captured = capsys.readouterr() + assert captured.err == "" + assert captured.out == "" + + def test_silent_role_helpers_still_return_spans(self): + # Role helpers don't emit; they just construct Spans for f-string + # interpolation. They must keep working under silent() so probe + # callers can still build error messages they choose to suppress. + out = Output.silent() + assert isinstance(out.id("x"), Span) + assert out.id("x").role == "identifier" + + +# --------------------------------------------------------------------------- +# Emitters: write() bypasses suppression, line() respects quiet +# --------------------------------------------------------------------------- + + +def test_write_bypasses_quiet_and_json(capsys): + # write() is for protocol output (shell-eval / env / JSON); it must + # never be suppressed by quiet or json. + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False, json=True) + out.write("MACHINE-READABLE\n") + captured = capsys.readouterr() + assert "MACHINE-READABLE" in captured.out + + +def test_line_suppressed_under_quiet(capsys): + out = Output.build(quiet=True, debug=False, eval_mode=False, color=False) + out.line("nope") + assert capsys.readouterr().err == "" + + +def test_info_suppressed_under_json(capsys): + out = Output.build(quiet=False, debug=False, eval_mode=False, color=False, json=True) + out.info("nope") + assert capsys.readouterr().err == "" + + +def test_warn_suppressed_under_json(capsys): + # New policy: warn/error are human-facing under --json too. The JSON + # consumer sees only the JSON document on stdout. + out = Output.build(quiet=False, debug=False, eval_mode=False, color=False, json=True) + out.warn("noisy") + assert capsys.readouterr().err == "" + + +# --------------------------------------------------------------------------- +# Back-compat: out.c() still works for the deprecation window (step 6) +# --------------------------------------------------------------------------- + + +def test_legacy_palette_accessor_still_works(monkeypatch): + monkeypatch.setattr(os, "isatty", lambda fd: True) + out = Output.build(quiet=False, debug=False, eval_mode=False, color=True, theme="legacy") + # Legacy palette name 'CYANN' is still resolvable via the deprecated + # out.c() shim so out-of-tree callers keep working. + assert out.c("CYANN") != "" diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..68014ed --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,262 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tests for keychain.paths: KeychainPaths construction, parse, write.""" + +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from keychain.env import SshAgentRef +from keychain.paths import KeychainPaths +from keychain.util import KeychainError, Output +from tests.support import set_home + + +def _out(): + return Output.build(quiet=True, debug=False, eval_mode=False, color=False) + + +# --------------------------------------------------------------------------- +# KeychainPaths construction +# --------------------------------------------------------------------------- + + +class TestKeychainPathsBuild: + def test_default_dir_uses_home_dot_keychain(self, tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + kp = KeychainPaths.build(None, False, "myhost") + assert kp.keydir == tmp_path / ".keychain" + assert kp.host == "myhost" + + def test_explicit_dir_appends_dot_keychain(self, tmp_path): + kp = KeychainPaths.build(str(tmp_path), False, "h") + assert kp.keydir == tmp_path / ".keychain" + + def test_absolute_flag_uses_dir_verbatim(self, tmp_path): + kp = KeychainPaths.build(str(tmp_path), True, "h") + assert kp.keydir == tmp_path + + def test_dotted_path_used_verbatim(self): + kp = KeychainPaths.build("/home/user/.mykeys", False, "h") + assert kp.keydir == Path("/home/user/.mykeys") + + def test_tilde_dotted_path_expands_home(self, tmp_path, monkeypatch): + set_home(monkeypatch, tmp_path) + kp = KeychainPaths.build("~/.keychain", False, "h") + assert kp.keydir == tmp_path / ".keychain" + + def test_tilde_base_path_expands_then_appends_dot_keychain(self, tmp_path, monkeypatch): + set_home(monkeypatch, tmp_path) + kp = KeychainPaths.build("~", False, "h") + assert kp.keydir == tmp_path / ".keychain" + + +class TestKeychainPathsProperties: + def test_pidfile_names_include_host(self): + kp = KeychainPaths(keydir=Path("/tmp/.keychain"), host="box") + assert kp.pidfile_path("sh").name == "box-sh" + assert kp.pidfile_path("csh").name == "box-csh" + assert kp.pidfile_path("fish").name == "box-fish" + assert kp.lockf.name == "box-lockf" + + def test_pidfile_for_fish(self): + kp = KeychainPaths(keydir=Path("/tmp/.keychain"), host="box") + assert kp.pidfile_path("fish") == kp.pidfile_path("fish") + + +# --------------------------------------------------------------------------- +# KeychainPaths.parse +# --------------------------------------------------------------------------- + +SH_CONTENT = ( + 'SSH_AUTH_SOCK="/tmp/ssh-XXX/agent.1234"; export SSH_AUTH_SOCK\nSSH_AGENT_PID=5678; export SSH_AGENT_PID;\n' +) + + +class TestKeychainPathsParse: + def _kp(self): + return KeychainPaths(keydir=Path("/tmp/.keychain"), host="test") + + def test_parses_sock_and_pid(self): + env = SshAgentRef.from_text(SH_CONTENT) + assert env.sock == "/tmp/ssh-XXX/agent.1234" + assert env.pid == "5678" + + def test_empty_content_returns_empty_dict(self): + assert SshAgentRef.from_text("") == SshAgentRef() + + def test_unrelated_lines_ignored(self): + env = SshAgentRef.from_text("echo Agent pid 5678;\n") + assert env == SshAgentRef() + + def test_parse_is_tolerant_of_missing_quotes(self): + content = "SSH_AUTH_SOCK=/tmp/agent.99; export SSH_AUTH_SOCK\n" + env = SshAgentRef.from_text(content) + assert env.sock == "/tmp/agent.99" + + +# --------------------------------------------------------------------------- +# KeychainPaths.write + read round-trip +# --------------------------------------------------------------------------- + +AGENT_SH_OUTPUT = ( + "SSH_AUTH_SOCK=/tmp/ssh-YYY/agent.9999; export SSH_AUTH_SOCK;\n" + "SSH_AGENT_PID=1111; export SSH_AGENT_PID;\n" + "echo Agent pid 1111;\n" +) + + +class TestKeychainPathsWriteRead: + def test_write_creates_all_three_pidfiles(self, tmp_path): + kp = KeychainPaths.build( + dir_opt=str(tmp_path), absolute=True, host="box", pid_formats="sh,csh,fish,envfile,json" + ) + kp.write(SshAgentRef.from_text(AGENT_SH_OUTPUT), _out()) + assert kp.pidfile_path("sh").exists() + assert kp.pidfile_path("csh").exists() + assert kp.pidfile_path("fish").exists() + assert kp.pidfile_path("envfile").exists() + assert kp.pidfile_path("json").exists() + + def test_sh_pidfile_is_parseable(self, tmp_path): + kp = KeychainPaths(keydir=tmp_path, host="box") + kp.write(SshAgentRef.from_text(AGENT_SH_OUTPUT), _out()) + env = SshAgentRef.from_text(kp.pidfile_path("sh").read_text()) + assert env.sock == "/tmp/ssh-YYY/agent.9999" + assert env.pid == "1111" + + def test_csh_pidfile_uses_setenv_syntax(self, tmp_path): + kp = KeychainPaths(keydir=tmp_path, host="box") + kp.write(SshAgentRef.from_text(AGENT_SH_OUTPUT), _out()) + csh = kp.pidfile_path("csh").read_text() + assert "setenv SSH_AUTH_SOCK" in csh + assert "setenv SSH_AGENT_PID" in csh + + def test_fish_pidfile_uses_set_syntax(self, tmp_path): + kp = KeychainPaths(keydir=tmp_path, host="box") + kp.write(SshAgentRef.from_text(AGENT_SH_OUTPUT), _out()) + fish = kp.pidfile_path("fish").read_text() + assert "set -x -U SSH_AUTH_SOCK" in fish + assert "set -x -U SSH_AGENT_PID" in fish + + def test_clear_removes_pidfiles(self, tmp_path): + kp = KeychainPaths(keydir=tmp_path, host="box") + kp.write(SshAgentRef.from_text(AGENT_SH_OUTPUT), _out()) + kp.clear() + assert not kp.pidfile_path("sh").exists() + assert not kp.pidfile_path("csh").exists() + assert not kp.pidfile_path("fish").exists() + + def test_write_uses_mkstemp_in_target_dir(self, tmp_path): + kp = KeychainPaths(keydir=tmp_path, host="box") + calls = [] + real_mkstemp = tempfile.mkstemp + + def fake_mkstemp(*args, **kwargs): + calls.append((kwargs["prefix"], kwargs["suffix"], kwargs["dir"])) + return real_mkstemp(*args, **kwargs) + + with patch("keychain.paths.tempfile.mkstemp", side_effect=fake_mkstemp): + kp.write(SshAgentRef.from_text(AGENT_SH_OUTPUT), _out()) + + assert calls == [ + (".box-sh.", ".tmp", tmp_path), + (".box-csh.", ".tmp", tmp_path), + (".box-fish.", ".tmp", tmp_path), + (".box-envfile.", ".tmp", tmp_path), + ] + assert kp.pidfile_path("sh").exists() + assert not (tmp_path / "box-sh.tmp").exists() + + +class TestKeychainPathsRenderEnv: + def test_sh_output_renders_passed_env_not_pidfile(self, tmp_path): + kp = KeychainPaths(keydir=tmp_path, host="box") + kp.write(SshAgentRef(sock="/tmp/stale.sock", pid="9999"), _out()) + + rendered = kp.render_env(SshAgentRef(sock="/tmp/live.sock", pid="1111"), "sh") + + assert 'SSH_AUTH_SOCK="/tmp/live.sock"' in rendered + assert "SSH_AGENT_PID=1111" in rendered + assert "/tmp/stale.sock" not in rendered + + def test_csh_output_renders_passed_env_not_pidfile(self, tmp_path): + kp = KeychainPaths(keydir=tmp_path, host="box") + kp.write(SshAgentRef(sock="/tmp/stale.sock", pid="9999"), _out()) + + rendered = kp.render_env(SshAgentRef(sock="/tmp/live.sock", pid="1111"), "csh") + + assert 'setenv SSH_AUTH_SOCK "/tmp/live.sock";' in rendered + assert "setenv SSH_AGENT_PID 1111;" in rendered + assert "/tmp/stale.sock" not in rendered + + def test_fish_output_renders_passed_env_not_pidfile(self, tmp_path): + kp = KeychainPaths(keydir=tmp_path, host="box") + kp.write(SshAgentRef(sock="/tmp/stale.sock", pid="9999"), _out()) + + rendered = kp.render_env(SshAgentRef(sock="/tmp/live.sock", pid="1111"), "fish") + + assert 'set -x -U SSH_AUTH_SOCK "/tmp/live.sock";' in rendered + assert "set -x -U SSH_AGENT_PID 1111;" in rendered + assert "/tmp/stale.sock" not in rendered + + def test_eval_output_uses_shell_but_renders_passed_env(self, tmp_path): + kp = KeychainPaths(keydir=tmp_path, host="box") + kp.write(SshAgentRef(sock="/tmp/stale.sock", pid="9999"), _out()) + + rendered = kp.render_env( + SshAgentRef(sock="/tmp/live.sock", pid="1111"), + "eval", + {"SHELL": "/usr/bin/fish"}, + ) + + assert 'set -x -U SSH_AUTH_SOCK "/tmp/live.sock";' in rendered + assert "set -x -U SSH_AGENT_PID 1111;" in rendered + assert "/tmp/stale.sock" not in rendered + + +# --------------------------------------------------------------------------- +# check_pidfile_perms: hard-fail on foreign owner +# --------------------------------------------------------------------------- + + +class TestCheckPidfilePerms: + def test_no_pidfiles_no_error(self, tmp_path): + kp = KeychainPaths(keydir=tmp_path, host="h") + tmp_path.mkdir(exist_ok=True) + kp.check_pidfile_perms("me", _out()) # nothing to check + + def test_owned_by_us_passes(self, tmp_path): + kp = KeychainPaths(keydir=tmp_path, host="h") + kp.pidfile_path("sh").write_text("SSH_AUTH_SOCK=/tmp/foo\n") + with ( + patch("keychain.paths.get_owner", return_value="me"), + patch("keychain.paths.lax_perms", return_value=False), + ): + kp.check_pidfile_perms("me", _out()) + + def test_foreign_owner_raises_keychain_error(self, tmp_path): + kp = KeychainPaths(keydir=tmp_path, host="h") + kp.pidfile_path("sh").write_text("SSH_AUTH_SOCK=/tmp/foo\n") + with patch("keychain.paths.get_owner", return_value="attacker"): + with pytest.raises(KeychainError, match="owned by attacker"): + kp.check_pidfile_perms("me", _out()) + + def test_lax_perms_raise_keychain_error(self, tmp_path): + kp = KeychainPaths(keydir=tmp_path, host="h") + kp.pidfile_path("sh").write_text("SSH_AUTH_SOCK=/tmp/foo\n") + with patch("keychain.paths.get_owner", return_value="me"), patch("keychain.paths.lax_perms", return_value=True): + with pytest.raises(KeychainError, match="lax permissions"): + kp.check_pidfile_perms("me", _out()) + + +class TestVerifyKeydir: + def test_lax_perms_raise_keychain_error(self, tmp_path): + keydir = tmp_path / ".keychain" + keydir.mkdir(mode=0o700) + kp = KeychainPaths(keydir=keydir, host="h") + + with patch("keychain.paths.get_owner", return_value="me"), patch("keychain.paths.lax_perms", return_value=True): + with pytest.raises(KeychainError, match="lax permissions"): + kp.verify_keydir("me", _out()) diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..0fe0317 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tests for :mod:`keychain.runtime` platform detection.""" + +import re + +import pytest + +from keychain.runtime import platform + + +@pytest.fixture(autouse=True) +def _reset_runtime(): + platform.reset() + yield + platform.reset() + + +@pytest.mark.parametrize("name", ["linux", "linux2", "darwin", "freebsd14", "openbsd7", "netbsd", "sunos5", "aix"]) +def test_posix_platforms_supported(name): + p = platform.detect(platform_override=name, has_ps=True) + assert p.supported is True + assert p.reason == "" + + +@pytest.mark.parametrize("name", ["cygwin", "msys", "msys2"]) +def test_cygwin_msys_supported(name): + p = platform.detect(platform_override=name, has_ps=True) + assert p.supported is True + + +def test_native_windows_unsupported(): + p = platform.detect(platform_override="win32") + assert p.supported is False + assert p.name == "windows" + assert "WSL" in p.reason or "Cygwin" in p.reason + + +def test_known_posix_ps_missing(): + # A known POSIX platform without ps in PATH is unsupported with a clear message. + p = platform.detect(platform_override="linux", has_ps=False) + assert p.supported is False + assert "ps" in p.reason.lower() + + +def test_unknown_platform_with_ps(): + # An unrecognised platform string is accepted when ps is present. + p = platform.detect(platform_override="plan9", has_ps=True) + assert p.supported is True + + +def test_unknown_platform_without_ps(): + # An unrecognised platform without ps gets an "unrecognized" refusal. + p = platform.detect(platform_override="plan9", has_ps=False) + assert p.supported is False + assert "unrecognized" in p.reason.lower() + + +def test_detection_is_cached(): + first = platform.detect(platform_override="linux", has_ps=True) + second = platform.detect(platform_override="win32") # ignored: cached + assert first is second + + +def test_unsupported_process_list_raises(): + p = platform.detect(platform_override="win32") + with pytest.raises(RuntimeError): + p.process_list(re.compile("anything")) + + +def test_supported_process_list_returns_list(): + # Use the host's actual ``ps`` if available; otherwise the Popen call + # raises OSError and the method returns an empty list. + p = platform.detect() + if not p.supported: + pytest.skip("host platform unsupported") + result = p.process_list(re.compile("definitely-not-a-real-command")) + assert isinstance(result, list) diff --git a/tests/test_runtime_config.py b/tests/test_runtime_config.py new file mode 100644 index 0000000..00b7987 --- /dev/null +++ b/tests/test_runtime_config.py @@ -0,0 +1,280 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tests for :mod:`keychain.runtime.config`.""" + +from __future__ import annotations + +import os + +from keychain.runtime.config import RuntimeConfig + + +def test_resolve_defaults_to_help_without_action(monkeypatch, tmp_path): + """Verify that resolving an empty argv produces the compat default ``add`` action. + + This should pass because ``resolve()`` now enables compat mode by default, + so an empty invocation is retried through the legacy translator and becomes + the historical ``add`` default. + """ + monkeypatch.setenv("KEYCHAIN_CONFIG", str(tmp_path / "missing.conf")) + + args = RuntimeConfig.resolve([]) + + assert args.action == "add" + assert args.action_node.fq_name == "add" + + +def test_resolve_short_circuits_help_with_action_hint(monkeypatch, tmp_path): + """Verify that ``--help`` short-circuits parsing while preserving the action hint. + + This should pass because RuntimeConfig pre-scans help flags before full + parsing and records the action token so help output can stay action-specific. + """ + monkeypatch.setenv("KEYCHAIN_CONFIG", str(tmp_path / "missing.conf")) + + args = RuntimeConfig.resolve(["add", "--help"]) + + # We expect `RuntimeConfig` to do prescan, find nothing, + # skip compat (since there's none?), nope, compat translates it to 'add' + # Wait, --help is prescan. Let's see how help propagates. + assert args.action == "help" + + +def test_resolve_short_circuits_version(monkeypatch, tmp_path): + """Verify that ``--version`` wins even when it appears after an action path. + + This should pass because version handling is a top-level short-circuit and + does not require the rest of the action tree to be parsed first. + """ + monkeypatch.setenv("KEYCHAIN_CONFIG", str(tmp_path / "missing.conf")) + + args = RuntimeConfig.resolve(["agent", "start", "--version"]) + + assert args.action == "version" + + +def test_resolve_maps_subaction_options_and_positionals(monkeypatch, tmp_path): + """Verify that subactions and their option-derived arguments are mapped correctly. + + This should pass because ``agent stop --mine`` is a valid new-style command, + so RuntimeConfig should set the action, subaction, and exclusive target field. + """ + monkeypatch.setenv("KEYCHAIN_CONFIG", str(tmp_path / "missing.conf")) + + args = RuntimeConfig.resolve(["agent", "stop", "--mine"]) + + assert args.action == "agent stop" + assert args.get_value("target") == "mine" + assert args.has_option("target") is True + + +def test_resolve_accepts_equals_form(monkeypatch, tmp_path): + """Verify that GNU-style ``--opt=value`` syntax is accepted for value options. + + This should pass because RuntimeConfig splits inline values during flag parsing + and feeds them through the same coercion path as separate option arguments. + """ + monkeypatch.setenv("KEYCHAIN_CONFIG", str(tmp_path / "missing.conf")) + + args = RuntimeConfig.resolve(["add", "--timeout=30", "--dir=/tmp/keychain"]) + + assert args.get_value("timeout") == 30 + assert args.get_value("dir") == "/tmp/keychain" + + +def test_resolve_records_unknown_flags_as_parse_errors(monkeypatch, tmp_path): + """Verify that unknown flags on a new-style action become parse errors. + + This should pass because the public ``resolve()`` path is now forgiving: + it preserves the resolved action context and records a short parse error + instead of raising or silently converting the invocation into help output. + """ + monkeypatch.setenv("KEYCHAIN_CONFIG", str(tmp_path / "missing.conf")) + + args = RuntimeConfig.resolve(["list", "--not-a-real-flag"]) + + assert args.action == "list" + assert args.parse_error == "Unrecognized option '--not-a-real-flag'. Run 'keychain help list' for more information." + + +def test_resolve_with_compat_retries_legacy_flag(monkeypatch, tmp_path): + """Verify that compat mode retries legacy flat flags through the translator. + + This should pass because ``--list`` is a known 2.x spelling and compat mode + retries non-new-style argv after translation into the action-first form. + """ + monkeypatch.setenv("KEYCHAIN_CONFIG", str(tmp_path / "missing.conf")) + + args = RuntimeConfig.resolve(["--list"]) + + assert args.action == "list" + + +def test_resolve_with_compat_retries_bare_key(monkeypatch, tmp_path): + """Verify that compat mode treats a bare positional key as legacy ``add`` input. + + This should pass because legacy key-only invocations are translated into the + modern ``add `` form when compat retry is enabled. + """ + monkeypatch.setenv("KEYCHAIN_CONFIG", str(tmp_path / "missing.conf")) + + args = RuntimeConfig.resolve(["id_rsa"]) + + assert args.action == "add" + assert args.get_value("keys") == ["id_rsa"] + + +def test_resolve_with_compat_does_not_retry_new_style_invalid_subaction(monkeypatch, tmp_path): + """Verify that unknown new-style arguments stay on the new-style path. + + This should pass because ``agent bogus`` already looks like a new-style + command, so compat must not reinterpret it into a different legacy form. + Instead, resolve should preserve the ``agent`` context and record a short + parse error for the stray argument. + """ + monkeypatch.setenv("KEYCHAIN_CONFIG", str(tmp_path / "missing.conf")) + + args = RuntimeConfig.resolve(["agent", "bogus"]) + + assert args.action == "agent" + assert args.parse_error == "Unrecognized argument 'bogus'. Run 'keychain help agent' for more information." + + +def test_resolve_with_compat_does_not_retry_new_style_unknown_flag(monkeypatch, tmp_path): + """Verify that unknown flags stay on the recognized new-style action. + + This should pass because once argv starts with a recognized modern action, + compat translation should no longer be considered. Resolve should record a + parse error against the already-recognized action context instead. + """ + monkeypatch.setenv("KEYCHAIN_CONFIG", str(tmp_path / "missing.conf")) + + args = RuntimeConfig.resolve(["list", "--not-a-real-flag"]) + + assert args.action == "list" + assert args.parse_error == "Unrecognized option '--not-a-real-flag'. Run 'keychain help list' for more information." + + +def test_resolve_preserves_dashdash_positionals(monkeypatch, tmp_path): + """Verify that ``--`` forces later tokens to remain literal positionals. + + This should pass because RuntimeConfig stops flag parsing after ``--`` and + preserves dash-prefixed key names as positional arguments. + """ + monkeypatch.setenv("KEYCHAIN_CONFIG", str(tmp_path / "missing.conf")) + + args = RuntimeConfig.resolve(["add", "--", "-weird-key-name"]) + + assert args.action == "add" + assert args.get_value("keys") == ["-weird-key-name"] + + +def test_has_option_reflects_active_action(monkeypatch, tmp_path): + """Verify that action-scoped option visibility matches the active action. + + This should pass because RuntimeConfig records only the option names valid for + the resolved action, exposing ``shell`` for ``env`` while rejecting ``help_target``. + """ + monkeypatch.setenv("KEYCHAIN_CONFIG", str(tmp_path / "missing.conf")) + + args = RuntimeConfig.resolve(["env", "--shell", "sh"]) + + assert args.has_option("shell") is True + assert args.get_value("shell") == "sh" + assert args.has_option("timeout") is False + + +def test_apply_keychainrc_injects_agent_args_into_env(): + """Verify that agent argument settings are exported into the effective environment. + + This should pass because apply_keychainrc builds a derived environment mapping + and mirrors agent argument options into KEYCHAIN_* variables without mutating os.environ. + """ + args = RuntimeConfig.resolve(["add", "--ssh-agent-args=-t 3600", "--gpg-agent-args=--max-cache-ttl 7200", "-E"]) + + args.apply_keychainrc({"HOME": "/home/test"}) + + assert args.env["KEYCHAIN_SSH_AGENT_ARGS"] == "-t 3600" + assert args.env["KEYCHAIN_GPG_AGENT_ARGS"] == "--max-cache-ttl 7200" + assert "KEYCHAIN_SSH_AGENT_ARGS" not in os.environ + assert "KEYCHAIN_GPG_AGENT_ARGS" not in os.environ + + +def test_apply_keychainrc_base_env_wins_over_agent_args(): + """Verify that an existing base environment overrides derived agent-arg exports. + + This should pass because apply_keychainrc treats the provided base environment + as higher priority than values synthesized from RuntimeConfig fields. + """ + args = RuntimeConfig.resolve(["add", "--ssh-agent-args=-t 3600", "--gpg-agent-args=--max-cache-ttl 7200", "-E"]) + + args.apply_keychainrc( + { + "HOME": "/home/test", + "KEYCHAIN_SSH_AGENT_ARGS": "-d", + "KEYCHAIN_GPG_AGENT_ARGS": "--debug-level guru", + } + ) + + assert args.env["KEYCHAIN_SSH_AGENT_ARGS"] == "-d" + assert args.env["KEYCHAIN_GPG_AGENT_ARGS"] == "--debug-level guru" + + +def test_apply_keychainrc_warns_on_unknown_section(tmp_path, monkeypatch): + """Verify that unknown .keychainrc sections are preserved as warnings. + + This should pass because configuration parsing is intentionally tolerant and + reports unsupported sections through ``rc_warnings`` instead of crashing. + """ + rc = tmp_path / ".keychainrc" + rc.write_text("[bogus]\nfoo = bar\n") + monkeypatch.setenv("KEYCHAIN_CONFIG", str(rc)) + + args = RuntimeConfig.resolve(["-E"]) + + assert any("bogus" in warning for warning in args.rc_warnings) + + +def test_apply_keychainrc_warns_on_unknown_key(tmp_path, monkeypatch): + """Verify that unsupported keys inside known sections become warnings. + + This should pass because apply_keychainrc validates keys against the config + model and records unknown entries rather than accepting them silently. + """ + rc = tmp_path / ".keychainrc" + rc.write_text("[agent]\nno_such_option = yes\n") + monkeypatch.setenv("KEYCHAIN_CONFIG", str(rc)) + + args = RuntimeConfig.resolve(["-E"]) + + assert any("no_such_option" in warning for warning in args.rc_warnings) + + +def test_apply_keychainrc_cli_value_wins_over_rc(tmp_path, monkeypatch): + """Verify that explicit CLI settings override values loaded from .keychainrc. + + This should pass because RuntimeConfig tracks CLI-provided argnames in + ``_cli_set`` and refuses to overwrite those values from config files. + """ + rc = tmp_path / ".keychainrc" + rc.write_text("[agent]\ntimeout = 20\n") + monkeypatch.setenv("KEYCHAIN_CONFIG", str(rc)) + + args = RuntimeConfig.resolve(["add", "--timeout=10", "-E"]) + + assert args.get_value("timeout") == 10 + + +def test_apply_keychainrc_coerces_bool_and_int_values(tmp_path, monkeypatch): + """Verify that bool and int strings from .keychainrc are coerced to real types. + + This should pass because apply_keychainrc uses the option metadata to coerce + raw config strings before storing them on RuntimeConfig. + """ + rc = tmp_path / ".keychainrc" + rc.write_text("[output]\ndebug = true\n[agent]\ntimeout = 15\n") + monkeypatch.setenv("KEYCHAIN_CONFIG", str(rc)) + + args = RuntimeConfig.resolve(["-E"]) + + assert args.get_value("debug") is True + assert args.get_value("timeout") == 15 diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..82c790e --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,398 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tests for :mod:`keychain.state`.""" + +import io +import sys +from contextlib import contextmanager +from unittest.mock import patch + +import pytest + +from keychain import agents, keys, state +from keychain.env import SshAgentRef +from keychain.output import inspect as inspect_view +from keychain.paths import KeychainPaths +from keychain.runtime import platform +from keychain.util import Output + + +@contextmanager +def _capture_stderr(): + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + yield buf + finally: + sys.stderr = old + + +@pytest.fixture(autouse=True) +def _reset_runtime(): + platform.reset() + yield + platform.reset() + + +@pytest.fixture +def paths(tmp_path): + keydir = tmp_path / ".keychain" + keydir.mkdir(mode=0o700) + return KeychainPaths(keydir=keydir, host="testhost") + + +@pytest.fixture +def out(): + return Output() + + +def test_cached_property_caches_underlying_call(paths): + calls = {"n": 0} + + def fake_detect_ssh(): + calls["n"] += 1 + return True + + with patch.object(agents, "detect_ssh", fake_detect_ssh): + st = state.KeychainState(paths=paths) + assert st.openssh is True + assert st.openssh is True + assert calls["n"] == 1 + + +def test_command_diagnostics_properties(paths): + calls: list[tuple[str, ...]] = [] + + class _R: + def __init__(self, stdout: str = "", stderr: str = ""): + self.stdout = stdout + self.stderr = stderr + + def fake_run(cmd, **_kwargs): + calls.append(tuple(cmd)) + if cmd == ["ssh", "-V"]: + return _R(stderr="OpenSSH_9.9p1, OpenSSL 1.1.1q 5 Jul 2022\n") + if cmd == ["gpg", "--version"]: + return _R(stdout="gpg (GnuPG) 2.4.7\nCopyright ...\n") + raise AssertionError(f"unexpected command: {cmd!r}") + + with ( + patch.object(agents, "detect_ssh", return_value=True), + patch("keychain.state.run", side_effect=fake_run), + patch("keychain.state.shutil.which", side_effect=lambda cmd: f"/usr/bin/{cmd}"), + ): + st = state.KeychainState(paths=paths) + assert st.ssh_implementation == "OpenSSH" + assert st.ssh_version == "OpenSSH_9.9p1, OpenSSL 1.1.1q 5 Jul 2022" + assert st.ssh_path == "/usr/bin/ssh" + assert st.gpg_version == "gpg (GnuPG) 2.4.7" + assert st.gpg_path == "/usr/bin/gpg" + assert st.ssh_version == "OpenSSH_9.9p1, OpenSSL 1.1.1q 5 Jul 2022" + assert st.gpg_version == "gpg (GnuPG) 2.4.7" + assert calls == [("ssh", "-V"), ("gpg", "--version")] + + +def test_pidfile_section_with_dead_pid(paths): + # No pidfile written -> all pidfile-related properties return falsy. + st = state.KeychainState(paths=paths) + assert st.pidfile_exists is False + assert st.pidfile_content == "" + assert st.pidfile_env == SshAgentRef() + assert st.pidfile_socket == "" + assert st.pidfile_pid == "" + assert st.pidfile_socket_valid is False + assert st.pidfile_pid_alive is False + + +def test_pidfile_section_with_invalid_socket(paths): + paths.pidfile_path("sh").write_text( + 'SSH_AUTH_SOCK="/tmp/keychain-state-test-nonexistent/agent.42"; export SSH_AUTH_SOCK\n' + "SSH_AGENT_PID=99999999; export SSH_AGENT_PID;\n" + ) + st = state.KeychainState(paths=paths) + assert st.pidfile_exists is True + assert st.pidfile_socket.endswith("agent.42") + assert st.pidfile_pid == "99999999" + assert st.pidfile_socket_valid is False + assert st.pidfile_socket_validation.reason == "missing" + assert st.pidfile_pid_alive is False + + +def test_inherited_section_with_stale_socket(paths): + env = {"SSH_AUTH_SOCK": "/tmp/keychain-state-test-stale/agent.0", "SSH_AGENT_PID": "99999999"} + st = state.KeychainState(paths=paths, env=env) + assert st.inherited_env == SshAgentRef.from_env(env) + assert st.inherited_socket_valid is False + assert st.inherited_socket_validation.reason == "missing" + assert st.inherited_pid_alive is False + + +def test_inherited_env_empty_when_unset(paths): + st = state.KeychainState(paths=paths, env={}) + assert st.inherited_env == SshAgentRef() + assert st.inherited_socket == "" + assert st.inherited_pid == "" + + +def test_keydir_introspection(paths): + st = state.KeychainState(paths=paths) + assert st.keydir_exists is True + assert st.keydir_writable is True + # On POSIX, mkdir(mode=0o700) yields tight perms; Windows ignores + # POSIX bits (everything looks lax) so this assertion is POSIX-only. + if sys.platform != "win32": + assert st.keydir_lax_perms is False + + +def test_resolved_keys_classifies_real_and_missing(tmp_path, paths): + real_key = tmp_path / "real_id" + real_key.write_text("dummy") + st = state.KeychainState( + paths=paths, + cmdline_keys=[str(real_key), "sshk:no-such-key-xyz"], + ) + # Don't depend on whether `gpg` is installed in CI; both should resolve as + # an SSH file and a missing key. + assert any(p.endswith("real_id") for p in st.resolved_keys.ssh) + assert "no-such-key-xyz" in st.resolved_keys.missing + assert any(p.endswith("real_id") for p in st.ssh_keys) + assert "no-such-key-xyz" in st.missing_keys + + +def test_resolved_keys_empty_when_no_args(paths): + st = state.KeychainState(paths=paths) + assert st.resolved_keys == keys.ResolvedKeys([], [], [], [], [], []) + assert st.ssh_keys == [] + assert st.gpg_keys == [] + assert st.missing_keys == [] + + +def test_render_inspect_emits_all_sections(paths, out): + st = state.KeychainState(paths=paths) + with _capture_stderr() as buf: + inspect_view.render_inspect(st, out) + text = buf.getvalue() + # Section headings are now bare titles after the bar glyph (see + # docs/output-design.md), no trailing colons or parens. + for header in ("Platform", "Pidfile", "Loaded SSH keys", "Permissions"): + assert header in text + + +def test_render_inspect_includes_resolved_keys_section_when_args(tmp_path, paths, out): + real_key = tmp_path / "id_test" + real_key.write_text("dummy") + st = state.KeychainState(paths=paths, cmdline_keys=[str(real_key), "sshk:ghost"]) + with _capture_stderr() as buf: + inspect_view.render_inspect(st, out) + text = buf.getvalue() + assert "Resolved keys" in text + assert "id_test" in text + assert "ghost" in text + + +def test_render_inspect_skips_resolved_keys_section_without_args(paths, out): + st = state.KeychainState(paths=paths) + with _capture_stderr() as buf: + inspect_view.render_inspect(st, out) + assert "Resolved keys" not in buf.getvalue() + + +def test_render_inspect_includes_socket_validation_reason(paths, out): + paths.pidfile_path("sh").write_text( + 'SSH_AUTH_SOCK="/tmp/keychain-state-test-nonexistent/agent.42"; export SSH_AUTH_SOCK\n' + ) + st = state.KeychainState(paths=paths) + with _capture_stderr() as buf: + inspect_view.render_inspect(st, out) + assert "rejected socket (missing)" in buf.getvalue() + + +def test_render_inspect_json_emits_valid_object(paths, capsys): + import json + + st = state.KeychainState(paths=paths) + inspect_view.render_inspect_json(st) + payload = json.loads(capsys.readouterr().out) + # Spot-check the schema: a few sections must always be present. + for key in ("platform", "ssh", "gpg", "pidfile", "inherited", "loaded_ssh_fingerprints", "permissions"): + assert key in payload + assert isinstance(payload["loaded_ssh_fingerprints"], list) + assert payload["pidfile"]["exists"] is False + assert payload["pidfile"]["socket_reason"] == "empty" + assert payload["pidfile"]["socket_severity"] == "" + assert "socket_reason" in payload["inherited"] + assert "socket_severity" in payload["inherited"] + assert "implementation" in payload["ssh"] + assert "version" in payload["ssh"] + assert "path" in payload["ssh"] + assert "version" in payload["gpg"] + assert "path" in payload["gpg"] + # Permissions section has both the keydir facts and the audit rows. + assert "keydir_path" in payload["permissions"] + assert "audit" in payload["permissions"] + + +def test_render_inspect_json_includes_resolved_keys_when_args(tmp_path, paths, capsys): + import json + + real_key = tmp_path / "id_test" + real_key.write_text("dummy") + st = state.KeychainState(paths=paths, cmdline_keys=[str(real_key), "sshk:ghost"]) + inspect_view.render_inspect_json(st) + payload = json.loads(capsys.readouterr().out) + assert "resolved_keys" in payload + assert "ghost" in payload["resolved_keys"]["missing"] + + +# --------------------------------------------------------------------------- +# Foreign gpg-agent classification (issue #202) +# --------------------------------------------------------------------------- + + +class TestGpgPrimaryClassification: + def test_primary_socket_under_homedir_is_ours(self, paths, tmp_path): + gh = tmp_path / ".gnupg" + gh.mkdir() + sock = str(gh / "S.gpg-agent") + with ( + patch.object(agents, "gpg_main_socket", return_value=sock), + patch.object(agents, "gpg_user_homedirs", return_value=[gh.resolve()]), + ): + st = state.KeychainState(paths=paths) + assert st.gpg_primary_socket_is_ours is True + + def test_foreign_socket_not_ours(self, paths, tmp_path): + gh = tmp_path / ".gnupg" + gh.mkdir() + foreign = tmp_path / "zypp.XYZ" + foreign.mkdir() + sock = str(foreign / "S.gpg-agent") + with ( + patch.object(agents, "gpg_main_socket", return_value=sock), + patch.object(agents, "gpg_user_homedirs", return_value=[gh.resolve()]), + ): + st = state.KeychainState(paths=paths) + assert st.gpg_primary_socket_is_ours is False + + def test_no_socket_is_not_ours(self, paths): + with patch.object(agents, "gpg_main_socket", return_value=""): + st = state.KeychainState(paths=paths) + assert st.gpg_primary_socket_is_ours is False + + def test_foreign_agents_present_when_pids_but_no_primary(self, paths, tmp_path): + # Simulates softmoth's #202: pids found, but socket not in our homedir. + foreign = tmp_path / "zypp.XYZ" + foreign.mkdir() + sock = str(foreign / "S.gpg-agent") + with ( + patch.object(agents, "gpg_main_socket", return_value=sock), + patch.object(agents, "gpg_user_homedirs", return_value=[tmp_path / ".gnupg"]), + patch.object(agents, "findpids", return_value=[4948]), + patch.object(state.KeychainState, "process_listing_supported", True), + ): + st = state.KeychainState(paths=paths) + assert st.gpg_foreign_agents_present is True + + def test_no_foreign_agents_when_socket_is_ours(self, paths, tmp_path): + gh = tmp_path / ".gnupg" + gh.mkdir() + sock = str(gh / "S.gpg-agent") + with ( + patch.object(agents, "gpg_main_socket", return_value=sock), + patch.object(agents, "gpg_user_homedirs", return_value=[gh.resolve()]), + patch.object(agents, "findpids", return_value=[3855]), + patch.object(state.KeychainState, "process_listing_supported", True), + ): + st = state.KeychainState(paths=paths) + assert st.gpg_foreign_agents_present is False + + def test_extras_alongside_ours_are_foreign(self, paths, tmp_path): + # Primary socket is ours, but a second gpg-agent pid exists -- + # gpg-agent is single-instance per --homedir, so the extra + # must belong to a different homedir (e.g. zypp). + gh = tmp_path / ".gnupg" + gh.mkdir() + sock = str(gh / "S.gpg-agent") + with ( + patch.object(agents, "gpg_main_socket", return_value=sock), + patch.object(agents, "gpg_user_homedirs", return_value=[gh.resolve()]), + patch.object(agents, "findpids", return_value=[3855, 4948]), + patch.object(state.KeychainState, "process_listing_supported", True), + ): + st = state.KeychainState(paths=paths) + assert st.gpg_foreign_agents_present is True + + def test_no_pids_means_no_foreign(self, paths): + with ( + patch.object(agents, "gpg_main_socket", return_value=""), + patch.object(agents, "findpids", return_value=[]), + patch.object(state.KeychainState, "process_listing_supported", True), + ): + st = state.KeychainState(paths=paths) + assert st.gpg_foreign_agents_present is False + + +# --------------------------------------------------------------------------- +# security_audit rows +# --------------------------------------------------------------------------- + + +class TestSecurityAudit: + def test_keydir_owner_and_perms_rows_present(self, paths): + with ( + patch("keychain.state.current_user", return_value="me"), + patch("keychain.output.inspect.get_owner", return_value="me"), + patch("keychain.output.inspect.os.stat") as st_mock, + ): + st_mock.return_value.st_mode = 0o40700 # dir, 0700 + ks = state.KeychainState(paths=paths) + audit = ks.security_audit + labels = [r[0] for r in audit] + assert "keydir_owner" in labels + assert "keydir_perms" in labels + for label, value, hint, sev in audit: + if label == "keydir_owner": + assert value == "me" and hint == "(you)" and sev == "" + if label == "keydir_perms": + assert value == "0700" and hint == "" and sev == "" + + def test_keydir_lax_perms_emits_hint(self, paths): + with ( + patch("keychain.state.current_user", return_value="me"), + patch("keychain.output.inspect.get_owner", return_value="me"), + patch("keychain.output.inspect.os.stat") as st_mock, + ): + st_mock.return_value.st_mode = 0o40777 # dir, 0777 + ks = state.KeychainState(paths=paths) + row = next(r for r in ks.security_audit if r[0] == "keydir_perms") + assert row[1] == "0777" + assert "lax permissions" in row[2] + assert row[3] == "warn" + + def test_foreign_keydir_owner_emits_hint(self, paths): + with ( + patch("keychain.state.current_user", return_value="me"), + patch("keychain.output.inspect.get_owner", return_value="attacker"), + patch("keychain.output.inspect.os.stat") as st_mock, + ): + st_mock.return_value.st_mode = 0o40700 + ks = state.KeychainState(paths=paths) + row = next(r for r in ks.security_audit if r[0] == "keydir_owner") + assert row[1] == "attacker" + assert "refusing to use" in row[2] + assert row[3] == "warn" + + def test_foreign_gpg_socket_not_in_security_audit(self, paths, tmp_path): + # GPG socket ownership is surfaced in the GPG panel (main socket hint), + # not in security_audit. Verify audit has no gpg rows. + foreign = tmp_path / "zypp.XYZ" + foreign.mkdir() + sock = str(foreign / "S.gpg-agent") + with ( + patch.object(agents, "gpg_main_socket", return_value=sock), + patch.object(agents, "gpg_user_homedirs", return_value=[tmp_path / ".gnupg"]), + ): + ks = state.KeychainState(paths=paths) + labels = [r[0] for r in ks.security_audit] + assert "gpg primary socket" not in labels + assert "foreign gpg-agents" not in labels diff --git a/tests/test_tables.py b/tests/test_tables.py new file mode 100644 index 0000000..344a1f0 --- /dev/null +++ b/tests/test_tables.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tests for keychain.tables.""" + +from keychain.output.tables import render_table, visible_width + + +def test_visible_width_strips_ansi(): + assert visible_width("\x1b[32;01mhi\x1b[0m") == 2 + assert visible_width("plain") == 5 + assert visible_width("") == 0 + + +def test_render_table_empty_returns_empty_string(): + assert render_table([]) == "" + + +def test_render_table_with_headers_aligns_columns(): + text = render_table( + [["a", "longer"], ["bbb", "x"]], + headers=["L", "R"], + ) + lines = text.splitlines() + # Header + body cells are pad-aligned to the widest column entry. + # Every body line must have identical length (tables are rectangular). + body_lines = [ln for ln in lines if not set(ln.strip()) <= set("-+|─│┌┬┐├┼┤└┴┘")] + widths = {len(ln) for ln in body_lines} + assert len(widths) == 1, f"rows misaligned: {widths}" + + +def test_render_table_honours_ansi_for_alignment(): + plain = render_table([["abc"]]) + coloured = render_table([["\x1b[32mabc\x1b[0m"]]) + # The coloured cell occupies the same visible width as the plain one, + # so the table outline is identical. + assert plain.splitlines()[0] == coloured.splitlines()[0] + + +def test_render_table_pads_short_rows(): + # Asymmetric rows must not raise; the short row gets blank cells. + text = render_table([["a", "b"], ["c"]], headers=["x", "y"]) + assert "c" in text diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..4924b09 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,206 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tests for keychain.util: Output and LockFile.""" + +import os +import socket + +import pytest + +from keychain.util import LockFile, Output, pid_alive + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _out(quiet=True, debug=False): + return Output.build(quiet=quiet, debug=debug, eval_mode=False, color=False) + + +# --------------------------------------------------------------------------- +# Output +# --------------------------------------------------------------------------- + + +class TestOutputBuild: + def test_no_color_clears_all_escapes(self): + out = _out() + for name in ("BLUE", "CYAN", "CYANN", "GREEN", "RED", "PURP", "YEL", "OFF"): + assert out.c(name) == "" + + def test_color_populates_escapes(self, monkeypatch): + monkeypatch.setattr(os, "isatty", lambda fd: True) + out = Output.build(quiet=False, debug=False, eval_mode=False, color=True) + assert out.c("GREEN") != "" + assert out.c("OFF") != "" + + def test_unknown_color_key_returns_empty(self): + out = _out() + assert out.c("NONEXISTENT") == "" + + def test_quiet_suppresses_mesg(self, capsys): + _out(quiet=True).mesg("should not appear") + assert capsys.readouterr().err == "" + + def test_not_quiet_emits_mesg(self, capsys): + _out(quiet=False).mesg("hello world") + assert "hello world" in capsys.readouterr().err + + def test_warn_always_emits_even_when_quiet(self, capsys): + _out(quiet=True).warn("danger") + assert "danger" in capsys.readouterr().err + + def test_note_suppressed_when_quiet(self, capsys): + _out(quiet=True).note("just a note") + assert capsys.readouterr().err == "" + + def test_debug_off_suppresses_message(self, capsys): + _out(debug=False).debug("hidden") + assert "hidden" not in capsys.readouterr().err + + def test_debug_on_emits_message(self, capsys): + _out(debug=True).debug("visible") + assert "visible" in capsys.readouterr().err + + +class TestOutputTheming: + def test_default_theme_uses_modern_palette(self, monkeypatch): + monkeypatch.setattr(os, "isatty", lambda fd: True) + monkeypatch.delenv("KEYCHAIN_THEME", raising=False) + out = Output.build(quiet=False, debug=False, eval_mode=False, color=True) + # Modern (the new default) uses 256-colour escapes: \033[38;5;NNNm + assert "38;5;" in out.c("GREEN") + assert out.theme == "modern" + + def test_modern_theme_uses_256_colour_palette(self, monkeypatch): + monkeypatch.setattr(os, "isatty", lambda fd: True) + out = Output.build(quiet=False, debug=False, eval_mode=False, color=True, theme="modern") + # Modern palette uses 256-colour escapes: \033[38;5;NNNm + assert "38;5;" in out.c("GREEN") + assert out.theme == "modern" + + def test_legacy_theme_uses_8_colour_palette(self, monkeypatch): + monkeypatch.setattr(os, "isatty", lambda fd: True) + out = Output.build(quiet=False, debug=False, eval_mode=False, color=True, theme="legacy") + # Legacy palette uses bold 8-colour green: \033[32;01m + assert "32;01" in out.c("GREEN") + assert out.theme == "legacy" + + def test_explicit_theme_flag(self, monkeypatch): + monkeypatch.setattr(os, "isatty", lambda fd: True) + out = Output.build(quiet=False, debug=False, eval_mode=False, color=True, theme="modern") + assert "38;5;" in out.c("GREEN") + + def test_unknown_theme_falls_back_to_default(self, monkeypatch): + monkeypatch.setattr(os, "isatty", lambda fd: True) + out = Output.build(quiet=False, debug=False, eval_mode=False, color=True, theme="neon-burrito") + # Falls back to the modern (default) palette without raising. + assert "38;5;" in out.c("GREEN") + + def test_json_forces_quiet_and_no_colour(self, monkeypatch): + monkeypatch.setattr(os, "isatty", lambda fd: True) + out = Output.build(quiet=False, debug=False, eval_mode=False, color=True, theme="modern", json=True) + assert out.json is True + assert out.quiet is True + # Colour is suppressed so JSON consumers never see ANSI escapes. + assert out.c("GREEN") == "" + + +# --------------------------------------------------------------------------- +# LockFile +# --------------------------------------------------------------------------- + + +@pytest.fixture +def silent_out(): + return _out() + + +class TestLockFile: + def test_acquire_creates_lock_file(self, tmp_path, silent_out): + lock = tmp_path / "test.lock" + with LockFile(lock, no_lock=False, wait=1, out=silent_out) as lf: + assert lf.acquired + assert lock.exists() + + def test_release_removes_lock_file(self, tmp_path, silent_out): + lock = tmp_path / "test.lock" + with LockFile(lock, no_lock=False, wait=1, out=silent_out): + pass + assert not lock.exists() + + def test_nolock_is_noop_no_file_created(self, tmp_path, silent_out): + lock = tmp_path / "test.lock" + with LockFile(lock, no_lock=True, wait=1, out=silent_out) as lf: + assert lf.acquired # nolock always succeeds ... + assert not lock.exists() # ... but writes nothing to disk + + def test_lock_content_is_hostname_colon_pid(self, tmp_path, silent_out): + lock = tmp_path / "test.lock" + with LockFile(lock, no_lock=False, wait=1, out=silent_out) as lf: + assert lf.acquired + content = lock.read_text() + hostname, _, pid_s = content.partition(":") + assert hostname == socket.gethostname() + assert int(pid_s) == os.getpid() + + def test_stale_local_lock_is_recovered(self, tmp_path, silent_out): + lock = tmp_path / "test.lock" + # PID 2**30 is far above the kernel max on any real system. + lock.write_text(f"{socket.gethostname()}:{2**30}") + with LockFile(lock, no_lock=False, wait=1, out=silent_out) as lf: + assert lf.acquired + + def test_legacy_plain_pid_stale_lock_recovered(self, tmp_path, silent_out): + lock = tmp_path / "test.lock" + # Pre-NFS-fix format: just a PID with no hostname. + lock.write_text(str(2**30)) + with LockFile(lock, no_lock=False, wait=1, out=silent_out) as lf: + assert lf.acquired + + def test_live_local_lock_not_stolen(self, tmp_path, silent_out): + lock = tmp_path / "test.lock" + # Our own PID is guaranteed alive. + lock.write_text(f"{socket.gethostname()}:{os.getpid()}") + lf = LockFile(lock, no_lock=False, wait=0, out=silent_out) + assert lf._acquire() is False + + def test_remote_host_lock_not_stolen(self, tmp_path, silent_out): + lock = tmp_path / "test.lock" + # A lock from a different host must be left alone (NFS safety). + lock.write_text("remote-host-that-cannot-exist-xyz:12345") + lf = LockFile(lock, no_lock=False, wait=0, out=silent_out) + assert lf._acquire() is False + + def test_release_is_idempotent(self, tmp_path, silent_out): + lock = tmp_path / "test.lock" + lf = LockFile(lock, no_lock=False, wait=1, out=silent_out) + lf.__enter__() + lf.release() + lf.release() # second release must not raise + assert not lf.acquired + + def test_lockwait_zero_force_acquires_live_lock(self, tmp_path, silent_out): + """gap §3.6 / usage-patterns.md §3.6: with ``--lockwait 0`` the + lockfile is forcibly taken over even when its owner is a live local + process. The wait loop falls through immediately and the + break-the-glass branch unlinks + reacquires. + """ + lock = tmp_path / "test.lock" + # Owner = ourselves (definitely alive). _acquire() would refuse + # this lock; __enter__ with wait=0 must overrule and force-take it. + lock.write_text(f"{socket.gethostname()}:{os.getpid()}") + with LockFile(lock, no_lock=False, wait=0, out=silent_out) as lf: + assert lf.acquired + # New lock content should now be ours, not the seeded value. + content = lock.read_text() + hostname, _, pid_s = content.partition(":") + assert hostname == socket.gethostname() + assert int(pid_s) == os.getpid() + assert not lock.exists() + + +class TestPidAlive: + def test_current_process_is_reported_alive(self): + assert pid_alive(os.getpid()) is True + assert os.getpid() > 0