commit 430875f200ce3a34b2904cdd6cf19605d75cc9bc Author: Karl-Einar Bergstrøm Date: Sat Oct 10 22:34:22 2026 +0200 Hello world 👋 Co-Authored-By: Claude Opus 5.5 (1M context) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..9578d4d --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +# Pull requests into main: formatting, vet, staticcheck, tests, and a +# build for each release platform. Not pushes to main: main's protection +# rule only merges a pull request that's up to date with main, so what +# lands there is what its pull request tested. Tags are release.yml's, +# which tests again before publishing. + +on: + pull_request: + branches: [main] + +jobs: + test: + name: Checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + # The runner mounts Go, the module cache and the build cache as + # volumes (nix-config, gitea-runner.nix): nothing to restore. + cache: false + + - name: gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "::error::not gofmt-formatted (run gofmt -w .):" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: staticcheck + # Pinned, so a new release can't fail CI on its own. + run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./... + + - name: go test + run: go test ./... + + - name: Build every release platform + run: | + for target in darwin/arm64 darwin/amd64 linux/amd64 linux/arm64; do + echo "building $target" + CGO_ENABLED=0 GOOS="${target%/*}" GOARCH="${target#*/}" go build -o /dev/null ./cmd/lazysecrets + done diff --git a/.gitea/workflows/pr-title.yml b/.gitea/workflows/pr-title.yml new file mode 100644 index 0000000..553178a --- /dev/null +++ b/.gitea/workflows/pr-title.yml @@ -0,0 +1,41 @@ +name: PR title + +# Pull requests are squash-merged, so the title becomes the commit on main +# and the changelog line (cliff.toml): it must start with a conventional +# commit type. feat, fix and perf are listed in the changelog; the rest +# aren't. Brackets and a missing space are fine too; the changelog writes +# them all as "feat: …". +# +# feat: SOPS and .env files in git repos +# fix(search): ctrl+r with empty results loads the scope +# feat!: rename config keys (! marks a breaking change) +# [feat] install with a one-liner +# [fix]: hide values on blur +# docs:split the README + +on: + pull_request: + branches: [main] + types: [opened, edited, reopened, synchronize] + +jobs: + title: + name: Conventional title + runs-on: ubuntu-latest + steps: + - name: Check the title + env: + TITLE: ${{ github.event.pull_request.title }} + run: | + types='feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert' + scope='(\([a-z0-9._/-]+\))?' + if [[ "$TITLE" =~ ^\[($types)$scope!?\]!?:?[[:space:]]*(.*)$ ]] || + [[ "$TITLE" =~ ^($types)$scope!?:[[:space:]]*(.*)$ ]]; then + what="${BASH_REMATCH[3]}" + fi + if [[ "${what:-}" =~ [^[:space:]] ]]; then + echo "ok: $TITLE" + else + echo "::error::The title should start with a type, like \"feat: what changed\" or \"[feat] what changed\"; types: ${types//|/, }. feat, fix and perf titles are the changelog's lines. Got: $TITLE" + exit 1 + fi diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..676d897 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,83 @@ +name: Release + +# A tag vMAJOR.MINOR.PATCH (or with a suffix, like v0.2.0-rc.1, published +# as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml) +# builds it for macOS and Linux, arm64 and amd64, and publishes a Gitea +# release with the archives, checksums and this release's section of the +# changelog (git-cliff, cliff.toml). Pushing the same tag again (after +# deleting it) replaces the release. +# +# scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes + +on: + push: + tags: + - "v*" + +jobs: + release: + name: Build and publish + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout (full history, for the changelog) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + # The runner mounts Go, the module cache and the build cache as + # volumes (nix-config, gitea-runner.nix): nothing to restore. + cache: false + + - name: Test + run: | + go vet ./... + go test ./... + + - name: Install git-cliff + # Pinned, with checksums. + run: | + set -eo pipefail + V=2.14.2 + case "$(uname -m)" in + x86_64) A=x86_64; SUM=26d1f7c8ea2400f2ccb0b2e7f321635f600b583bf4cd1f7afaa5e8c24068ad1088a2504e0a4d9f4333d4bb20a80329327d9462634343ee3af91e1a7fbdc6f18a ;; + aarch64) A=aarch64; SUM=fd27768ea76fea8c3ba3f3247fef2f6604988b629f15e48bf40be5eaa08b8fef2e4087cc9c8af16bc522bc19cc369611fa7692101b513f63469ad58522708600 ;; + *) echo "::error::no git-cliff for $(uname -m)"; exit 1 ;; + esac + F="git-cliff-$V-$A-unknown-linux-gnu.tar.gz" + curl -fsSL -o "$RUNNER_TEMP/$F" "https://github.com/orhun/git-cliff/releases/download/v$V/$F" + echo "$SUM $RUNNER_TEMP/$F" | sha512sum -c - + tar -xzf "$RUNNER_TEMP/$F" -C "$RUNNER_TEMP" + install "$RUNNER_TEMP/git-cliff-$V/git-cliff" /usr/local/bin/ + + - name: Release notes + env: + TAG: ${{ github.ref_name }} + run: | + set -eo pipefail + # This release's section of the changelog: the feat/fix/perf pull + # requests since the previous release, without its heading (the + # release has a title). A pre-release's tag isn't a release to + # git-cliff (tag_pattern), so it's what's unreleased. Written + # outside the checkout, which GoReleaser wants clean. + if [[ "$TAG" == *-* ]]; then + notes=$(git cliff --unreleased --tag "$TAG" --strip all) + else + notes=$(git cliff --latest --strip all) + fi + printf '%s\n' "$notes" | sed '1{/^## /d;}' | sed '/./,$!d' > "$RUNNER_TEMP/notes.md" + cat "$RUNNER_TEMP/notes.md" + + - name: GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: v2.18.3 + args: release --clean --release-notes ${{ runner.temp }}/notes.md + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a87d716 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# ---> Go +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + + +# Build output +/lazysecrets + +# Release builds (GoReleaser, see .goreleaser.yaml) +/dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..5bfc96c --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,72 @@ +# GoReleaser (https://goreleaser.com) builds and publishes a release when +# .gitea/workflows/release.yml runs on a tag. Try it locally without +# publishing anything (archives land in dist/): +# +# goreleaser release --snapshot --clean +version: 2 + +project_name: lazysecrets + +# The token is GITEA_TOKEN, even if the runner sets GITHUB_TOKEN too. +force_token: gitea + +before: + hooks: + - go mod tidy -diff + +builds: + - main: ./cmd/lazysecrets + binary: lazysecrets + # No cgo: every platform cross-compiles from one runner. + env: + - CGO_ENABLED=0 + goos: [darwin, linux] + goarch: [amd64, arm64] + flags: [-trimpath] + ldflags: + - -s -w -X main.version={{ .Version }} + +archives: + - formats: [tar.gz] + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + wrap_in_directory: true + files: + - LICENSE + - README.md + - docs/*.md + - CHANGELOG.md + +checksum: + name_template: checksums.txt + algorithm: sha256 + +# The notes come from git-cliff (cliff.toml), passed with --release-notes, +# so they match CHANGELOG.md. (Not changelog.disable: that ignores +# --release-notes too, and the release gets no notes.) + +gitea_urls: + api: https://git.kebcloud.no/api/v1 + download: https://git.kebcloud.no + +release: + gitea: + owner: keb + name: lazysecrets + # v0.2.0-rc.1 is a pre-release. + prerelease: auto + # Running again for a tag (after deleting and pushing it again) replaces + # the release's notes and files. + mode: replace + replace_existing_artifacts: true + footer: | + ## Install + + On macOS or Linux, into `~/.local/bin`: + + ```sh + curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh -s -- {{ .Tag }} + ``` + + Or download the archive for your platform below, check it against `checksums.txt`, and put `lazysecrets` on your PATH. + + macOS quarantines files downloaded in a browser; if it refuses to run lazysecrets, clear that with `xattr -d com.apple.quarantine ~/.local/bin/lazysecrets`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dc544e5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +Every release of lazysecrets, newest first. + +## v0.1.0 – 2026-10-10 + +Hello world 👋 + + 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/README.md b/README.md new file mode 100644 index 0000000..1f91d5b --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# lazysecrets + +A terminal UI for finding, reading and sharing secrets, inspired by lazygit. +One place for: + +- **Azure Key Vault**: secrets, keys and certificates, across every az account +- **Databricks** secret scopes +- **SOPS and `.env` files** in your git repos + +It reads with your existing logins (az, Databricks CLI, sops), never stores +values, and gives every item a `lazysecrets://` link a colleague can open. + +Status: early development. See [CHANGELOG.md](CHANGELOG.md) for what's new. + +## Install + +On macOS or Linux (arm64 or x86_64): + +```sh +curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh +``` + +It installs the latest [release](https://git.kebcloud.no/keb/lazysecrets/releases) +into `~/.local/bin` after checking it against the release's checksums. +`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR` +somewhere else. + +lazysecrets looks for a newer release once a day: the footer then shows +`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run +`lazysecrets update`). + +Or build it yourself: +`go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`. + +## Get started + +```sh +az login # once, if you use Azure; lazysecrets reuses it +lazysecrets # inside a repo, it opens on that repo's secret files +lazysecrets --demo # built-in sample data, nothing to sign in to +``` + +Pick a source on the left, then a vault, then a secret. The keys you'll +use most: + +| Key | Action | +|---|---| +| `j`/`k`, `h`/`l`, `enter`, `esc` | move, switch panes, drill in, back | +| `/` | filter the list you're in | +| `space` | reveal / hide the value | +| `y` | copy the value (cleared from the clipboard after 30s) | +| `ctrl+s` | Go to: search everything and jump there | +| `f` / `F` | star / your favourites | +| `s` | copy a link to share | +| `?` | every key, searchable | +| `q` | quit | + +## Docs + +- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys +- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults +- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes +- [SOPS and .env files](docs/repos.md): which repos and files, decrypting +- [Links](docs/links.md): sharing, opening, `lazysecrets get` in scripts, + and opening links from the browser and chat +- [Custom commands](docs/custom-commands.md): run your own commands on a + secret +- [Config](docs/config.md): every option, themes, layered configs, the + state file +- [Development](docs/development.md): building, tests, pull requests and + releasing + +## License + +Copyright © 2026 Karl-Einar Bergstrøm. + +lazysecrets 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. See [LICENSE](LICENSE). diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..7b3e699 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,62 @@ +# git-cliff (https://git-cliff.org) builds CHANGELOG.md and the release +# notes from the commits on main. Pull requests are squash-merged, so each +# one is a single commit whose message is the PR title: a conventional one +# (feat: …, fix: …, perf: …) puts it in the changelog, anything else +# (chore, ci, docs, refactor, test, …) leaves it out. +# +# git cliff --unreleased # what the next release would say + +[changelog] +header = """ +# Changelog + +Every release of lazysecrets, newest first.\n +""" +body = """ +{% if version -%} +## {{ version }} – {{ timestamp | date(format="%Y-%m-%d") }} +{% else -%} +## Unreleased +{% endif -%} +{% if commits | length == 0 %} +Hello world 👋 +{% endif -%} +{% for group, commits in commits | group_by(attribute="group") %} +### {{ group | striptags | trim }} +{% for commit in commits %} +- {% if commit.breaking %}**Breaking:** {% endif %}{{ commit.message | split(pat="\n") | first | upper_first | trim }}\ +{% endfor %} +{% endfor %}\n +""" +footer = "" +trim = true +# Runs on each part (header, every release) separately. +postprocessors = [ + { pattern = "\n{3,}", replace = "\n\n" }, +] + +[git] +conventional_commits = true +filter_unconventional = false +split_commits = false +protect_breaking_commits = true +tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+$" +sort_commits = "oldest" +commit_preprocessors = [ + # A merge commit (not a squash) says "Merge pull request 'feat: …' (#4) + # from branch into main": keep the title and number. + { pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" }, + # Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see + # pr-title.yml); all become "feat: x". + { pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " }, + { pattern = "^(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?):(\\S)", replace = "${1}${2}${3}: ${4}" }, +] +commit_parsers = [ + { body = "\\[skip changelog\\]", skip = true }, + + { message = "^[a-z]+(\\(.*\\))?!:", group = "Added" }, + { message = "^feat", group = "Added" }, + { message = "^perf", group = "Changed" }, + { message = "^fix", group = "Fixed" }, + { message = ".*", skip = true }, +] diff --git a/cmd/lazysecrets/get.go b/cmd/lazysecrets/get.go new file mode 100644 index 0000000..6d33449 --- /dev/null +++ b/cmd/lazysecrets/get.go @@ -0,0 +1,114 @@ +package main + +import ( + "cmp" + "context" + "errors" + "fmt" + "os" + + "golang.org/x/term" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +// get prints the value a link points at, for scripts: +// +// export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv.vault.azure.net/secrets/db-password) +// +// It ends with a newline only when printing to a terminal, so command +// substitution and pipes get the value exactly. +func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error { + v, err := value(ctx, providers, st, r) + if err != nil { + return err + } + out := v.Reveal() + if term.IsTerminal(int(os.Stdout.Fd())) { + out += "\n" + } + _, err = os.Stdout.WriteString(out) + return err +} + +// value reads what r points at without listing its vault: the vault and +// name are enough for Azure, tried with each account (the one that last +// read it first); for Databricks the workspace's profile is found by its +// host. +func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) { + if r.IsSource() || r.IsContainer() { + return provider.SecretValue{}, fmt.Errorf("%s points at a %s, not a secret", r, map[bool]string{true: "subscription or workspace", false: "vault or scope"}[r.IsSource()]) + } + if r.Kind != provider.KindAzure && r.Host == "" { + return provider.SecretValue{}, errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)") + } + key := r + key.Version = "" + node := st.Node(key.String()) + + type try struct { + p provider.Provider + o provider.Object + } + var tries []try + for _, p := range providers { + if p.Kind() != r.Kind { + continue + } + o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version} + if r.Kind != provider.KindAzure { // by host: workspaces, repos + loc, ok := p.(provider.Locator) + if !ok { + continue + } + profile, found, err := loc.Locate(ctx, r.Host) + if err != nil || !found { + continue + } + src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: profile} + o.Container = provider.Container{Source: src, Name: r.Scope} + // Listing the scopes tells a Key Vault-backed one, whose value + // can then be read from the vault if Databricks refuses. + if cs, err := p.Containers(ctx, src); err == nil { + for _, c := range cs { + if c.Name == r.Scope { + c.Source = src + o.Container = c + } + } + } + } else { + src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: node.Source} + o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()} + } + t := try{p, o} + if p.ID() == node.Account { + tries = append([]try{t}, tries...) + } else { + tries = append(tries, t) + } + } + if len(tries) == 0 { + switch r.Kind { + case provider.KindDatabricks: + return provider.SecretValue{}, fmt.Errorf("no Databricks profile for workspace %s", r.Host) + case provider.KindRepo: + return provider.SecretValue{}, fmt.Errorf("repo %s isn't here: run it inside the repo, or list it under providers.repos.paths", r.Host) + } + return provider.SecretValue{}, fmt.Errorf("no %s account to read %s with", r.Kind, r) + } + var errs []error + for _, t := range tries { + v, err := t.p.Value(ctx, t.o) + if err == nil { + return v, nil + } + if len(tries) == 1 { + return provider.SecretValue{}, err + } + errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err)) + } + return provider.SecretValue{}, errors.Join(errs...) +} diff --git a/cmd/lazysecrets/get_test.go b/cmd/lazysecrets/get_test.go new file mode 100644 index 0000000..60dc19b --- /dev/null +++ b/cmd/lazysecrets/get_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "strings" + "testing" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +func TestValue(t *testing.T) { + providers := []provider.Provider{fake.Azure(0), fake.Databricks(0)} + st, _ := store.Load("") + for link, want := range map[string]string{ + "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password": "correct-horse-battery-staple", + "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/0a77c4": "original-password", + "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key", + } { + r, _ := ref.Parse(link) + v, err := value(context.Background(), providers, st, r) + if err != nil || v.Reveal() != want { + t.Errorf("%s: %q, %v", link, v.Reveal(), err) + } + } + for link, wantErr := range map[string]string{ + "lazysecrets://azure/kv-app-prod.vault.azure.net": "not a secret", + "lazysecrets://databricks/adb-9.9.azuredatabricks.net/app/storage-key": "no Databricks profile", + "{{secrets/app/storage-key}}": "names no workspace", + } { + r, _ := ref.Parse(link) + if _, err := value(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) { + t.Errorf("%s: %v, want %q", link, err, wantErr) + } + } +} + +func TestStarredRepos(t *testing.T) { + st, _ := store.Load("") + st.Update("lazysecrets://repo/git.example.com%2Fa/secrets%2Fx.yaml/db.password", func(n *store.Node) { + n.Source, n.StarredBy = "/repos/a", []string{"Repos"} + }) + st.Update("lazysecrets://repo/git.example.com%2Fb", func(n *store.Node) { n.Source, n.Note = "/repos/b", "noted, not starred" }) + st.Update("lazysecrets://azure/kv.vault.azure.net", func(n *store.Node) { n.Source, n.StarredBy = "sub", []string{"Azure"} }) + if got := starredRepos(st); len(got) != 1 || got[0] != "/repos/a" { + t.Errorf("starred repos %v", got) + } +} diff --git a/cmd/lazysecrets/handler.go b/cmd/lazysecrets/handler.go new file mode 100644 index 0000000..9e480c7 --- /dev/null +++ b/cmd/lazysecrets/handler.go @@ -0,0 +1,195 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "runtime" + "strings" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/config" + "git.kebcloud.no/keb/lazysecrets/internal/instance" + "git.kebcloud.no/keb/lazysecrets/internal/linkhandler" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/ui/app" +) + +// handler installs or removes the lazysecrets:// link handler. +func handler(configPath string, args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: lazysecrets handler install [--terminal NAME] | uninstall") + } + switch args[0] { + case "install": + fs := flag.NewFlagSet("handler install", flag.ContinueOnError) + term := fs.String("terminal", "", "terminal to open links in: "+strings.Join(linkhandler.Terminals, ", ")+ + " (default: the one this runs in)") + if err := fs.Parse(args[1:]); err != nil { + return err + } + cfg, err := config.LoadAll(strings.Split(configPath, ",")) + if err != nil { + return err + } + where, opens, err := installHandler(cfg, configPath, *term) + if err != nil { + return err + } + fmt.Printf("lazysecrets:// links now open in a new %s window.\nInstalled %s\n", opens, where) + if runtime.GOOS == "darwin" { + fmt.Println("Try it: open 'lazysecrets://azure/.vault.azure.net'") + } + return nil + case "uninstall": + where, err := linkhandler.Uninstall() + if err != nil { + return err + } + fmt.Printf("Removed %s; lazysecrets:// links no longer open lazysecrets.\n", where) + return nil + } + return fmt.Errorf("unknown handler command %q: install or uninstall", args[0]) +} + +// installHandler registers this lazysecrets for lazysecrets:// links, +// opening them in term (default: the terminal it runs in). It returns +// where it installed and what links open in. +func installHandler(cfg config.Config, configPath, term string) (string, string, error) { + exe, err := linkhandler.Executable() + if err != nil { + return "", "", err + } + if term == "" { + term = linkhandler.Detect(os.Getenv) + } + // Check now that a link could be opened, rather than on the first + // click. + if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil { + return "", "", err + } + launch := []string{exe} + if configPath != defaultConfigPath() { + launch = append(launch, "--config", configPath) + } + launch = append(launch, "launch") + if term != "" { + launch = append(launch, "--terminal", term) + } + where, err := linkhandler.Install(launch, linkhandler.Env(os.Getenv)) + if err != nil { + return "", "", err + } + opens := term + if len(cfg.LinkHandler.Command) > 0 { + opens = strings.Join(cfg.LinkHandler.Command, " ") + } else if cfg.LinkHandler.Terminal != "" { + opens = cfg.LinkHandler.Terminal + } + return where, opens, nil +} + +// handlerSetup offers to set up the link handler at startup, if links +// don't open lazysecrets and it can be: not for a go run binary, which is +// deleted on exit, nor when the config says not to ask. +func handlerSetup(cfg config.Config, configPath string) *app.HandlerSetup { + if !config.On(cfg.LinkHandler.Prompt) { + return nil + } + if _, err := linkhandler.Executable(); err != nil { + return nil + } + reason := linkhandler.Check() + if reason == "" { + return nil + } + return &app.HandlerSetup{ + Reason: reason, + Install: func() (string, error) { + _, opens, err := installHandler(cfg, configPath, "") + return opens, err + }, + } +} + +// launch opens link in lazysecrets in a new terminal window. The +// operating system runs it when a lazysecrets:// link is opened. +func launch(configPath string, args []string) error { + fs := flag.NewFlagSet("launch", flag.ContinueOnError) + term := fs.String("terminal", "", "terminal to open the window in") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("usage: lazysecrets launch [--terminal NAME] ") + } + link := fs.Arg(0) + // Links can come from any web page: open only what lazysecrets can + // read. Opening never acts by itself; the user picks what to do. + if strings.ContainsAny(link, "\r\n") { + return fmt.Errorf("not a link: %q", link) + } + if _, err := ref.Parse(link); err != nil { + return err + } + cfg, err := config.LoadAll(strings.Split(configPath, ",")) + if err != nil { + return err + } + // Where the link was clicked: a terminal gets it there, anywhere else + // the lazysecrets used last does. + front := linkhandler.FrontApp() + inTerminal := linkhandler.IsTerminal(front.Bundle) + if config.On(cfg.LinkHandler.Reuse) && handOver(link, front.Bundle, inTerminal) { + return nil + } + exe, err := os.Executable() + if err != nil { + return err + } + argv, err := launchCommand(cfg, configPath, *term, exe, link) + if err != nil { + return err + } + return linkhandler.Launch(argv) +} + +// handOver gives link to the running lazysecrets last used that can be +// brought into view, if there is one; clicked in a terminal, only to one +// in that terminal. +func handOver(link, front string, inTerminal bool) bool { + stateDir, err := config.StateDir() + if err != nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + best, ok := instance.Best(instance.Running(ctx, instance.Dir(stateDir), front), front, inTerminal) + if !ok { + return false + } + // A fresh deadline: the hand-over must not fail for time the status + // round used, or the link would open twice. + ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return instance.Open(ctx, best, link, front) == nil +} + +// launchCommand is the command opening a new window of the configured +// terminal (or term) running lazysecrets on link. +func launchCommand(cfg config.Config, configPath, term, exe, link string) ([]string, error) { + if cfg.LinkHandler.Terminal != "" { + term = cfg.LinkHandler.Terminal + } + return linkhandler.Command(runtime.GOOS, term, cfg.LinkHandler.Command, innerCommand(configPath, exe, link)) +} + +// innerCommand runs lazysecrets on link, with the environment it needs. +func innerCommand(configPath, exe, link string) []string { + inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe) + if configPath != defaultConfigPath() { + inner = append(inner, "--config", configPath) + } + return append(inner, "open", link) +} diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go new file mode 100644 index 0000000..7958fab --- /dev/null +++ b/cmd/lazysecrets/main.go @@ -0,0 +1,363 @@ +// Command lazysecrets is a terminal UI for browsing secrets in Azure Key +// Vault and Databricks secret scopes. +package main + +import ( + "context" + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "slices" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/commands" + "git.kebcloud.no/keb/lazysecrets/internal/config" + "git.kebcloud.no/keb/lazysecrets/internal/instance" + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/azure" + "git.kebcloud.no/keb/lazysecrets/internal/provider/databricks" + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" + "git.kebcloud.no/keb/lazysecrets/internal/provider/repo" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/store" + "git.kebcloud.no/keb/lazysecrets/internal/ui/app" + "git.kebcloud.no/keb/lazysecrets/internal/ui/keys" + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "lazysecrets:", err) + os.Exit(1) + } +} + +// looksLikeRef tells a reference given without "open" from a mistyped +// subcommand, so the latter still gets the usage text. +func looksLikeRef(s string) bool { + return strings.Contains(s, "://") || strings.HasPrefix(s, "@Microsoft.KeyVault(") || strings.HasPrefix(s, "{{secrets/") +} + +// starredRepos are the repos the user starred something in: the repo, a +// file or a key. Their records keep the repo's folder. +func starredRepos(st *store.Store) []string { + var out []string + for key, n := range st.Nodes() { + r, err := ref.Parse(key) + if err == nil && r.Kind == provider.KindRepo && len(n.StarredBy) > 0 && n.Source != "" && !slices.Contains(out, n.Source) { + out = append(out, n.Source) + } + } + slices.Sort(out) + return out +} + +// checkCommandKeys reports a custom command key that a built-in action or +// another command in an overlapping context takes. +func checkCommandKeys(km *keys.Map, cmds []commands.Command) error { + for i, c := range cmds { + if c.Key == "" { + continue + } + name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command)) + if err := km.CheckExtra(c.Key, name); err != nil { + return err + } + for _, d := range cmds[:i] { + overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global + if d.Key == c.Key && overlap { + return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command)) + } + } + } + return nil +} + +func cmpOr(a, b string) string { + if a != "" { + return a + } + return b +} + +// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the +// config directory. +func defaultConfigPath() string { + if env := os.Getenv("LAZYSECRETS_CONFIG_FILE"); env != "" { + return env + } + dir, err := config.Dir() + if err != nil { + return "config.yaml" + } + return filepath.Join(dir, "config.yaml") +} + +func run() error { + configPath := flag.String("config", defaultConfigPath(), + "config file; several, comma-separated, are layered in order (also LAZYSECRETS_CONFIG_FILE)") + showVersion := flag.Bool("version", false, "print the version and exit") + dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)") + demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends") + timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request") + icons := flag.Bool("icons", true, + "show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0") + clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)") + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), `Usage: + lazysecrets [flags] browse secrets + lazysecrets [flags] [open] + open a lazysecrets:// link, Key Vault URL, + @Microsoft.KeyVault(...) or {{secrets/scope/key}} + lazysecrets [flags] get print the value a lazysecrets:// link points at, + for scripts: VAR=$(lazysecrets get ) + lazysecrets [flags] keybindings print every action and its keys (with your + config applied), as config to copy and edit + lazysecrets handler install [--terminal NAME] + open lazysecrets:// links from the browser and + chat in a new window of this terminal + lazysecrets handler uninstall stop opening them + lazysecrets launch open a link in a new terminal window, as + the link handler does + lazysecrets update [--check] install the newest release in place of this + one (--check: just say if there is one) + +Flags: +`) + flag.PrintDefaults() + } + flag.Parse() + if *showVersion { + fmt.Println("lazysecrets", versionString()) + return nil + } + + var open, getRef *ref.Ref + printKeys := false + switch args := flag.Args(); { + case len(args) == 1 && args[0] == "keybindings": + printKeys = true + case len(args) == 2 && args[0] == "get": + r, err := ref.Parse(args[1]) + if err != nil { + return err + } + getRef = &r + case len(args) > 0 && args[0] == "handler": + return handler(*configPath, args[1:]) + case len(args) > 0 && args[0] == "launch": + return launch(*configPath, args[1:]) + case len(args) > 0 && args[0] == "update": + return updateCmd(args[1:]) + case len(args) == 0: + case args[0] == "open" && len(args) == 2: + r, err := ref.Parse(args[1]) + if err != nil { + return err + } + open = &r + case len(args) == 1 && looksLikeRef(args[0]): + // lazysecrets , like lazysecrets open + r, err := ref.Parse(args[0]) + if err != nil { + return err + } + open = &r + default: + flag.Usage() + return fmt.Errorf("unexpected arguments: %v", args) + } + + cfg, err := config.LoadAll(strings.Split(*configPath, ",")) + if err != nil { + return err + } + theme, defaultFg, err := styles.FromTheme(cfg.Theme) + if err != nil { + return err + } + keymap := keys.Default() + if err := keymap.Apply(cfg.Keybinding); err != nil { + return err + } + if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil { + return err + } + if printKeys { + fmt.Print(keymap.YAML()) + return nil + } + // Precedence: flag > environment > config file > default. + set := map[string]bool{} + flag.Visit(func(f *flag.Flag) { set[f.Name] = true }) + if !set["timeout"] && cfg.Timeout > 0 { + *timeout = cfg.Timeout + } + if !set["clipboard-clear"] && cfg.ClipboardClear != nil { + *clipClear = *cfg.ClipboardClear + } + expiryDays := 30 + if cfg.ExpiryWarningDays != nil { + expiryDays = *cfg.ExpiryWarningDays + } + revealTimeout := 60 * time.Second + if cfg.RevealTimeout != nil { + revealTimeout = *cfg.RevealTimeout + } + loadDelay := 150 * time.Millisecond + if cfg.LoadDelay != nil { + loadDelay = *cfg.LoadDelay + } + if !set["icons"] { + switch { + case os.Getenv("LAZYSECRETS_ICONS") != "": + *icons = os.Getenv("LAZYSECRETS_ICONS") != "0" + case cfg.Icons != nil: + *icons = *cfg.Icons + } + } + + // Stars, notes and the layout. Demo mode keeps them in memory, so + // sample vaults never end up in the real file. + stateDir, err := config.StateDir() + if err != nil { + return err + } + statePath := "" + if !*demo { + statePath = filepath.Join(stateDir, "state.json") + } + st, err := store.Load(statePath) + if err != nil { + return err + } + + var providers []provider.Provider + if *demo { + // Two Azure accounts, like a personal and an admin login in az. + providers = []provider.Provider{ + fake.Azure(400*time.Millisecond).As("Azure:dev@example.com/demo", "Azure · dev"), + fake.AzureOps(700*time.Millisecond).As("Azure:ops@example.com/demo", "Azure · ops"), + fake.Databricks(600 * time.Millisecond), + } + } else { + pc := cfg.Providers + // One provider per az account. Created even when disabled: AKV- + // backed Databricks scopes fall back to reading the vault through + // the default account. + accounts, err := azure.NewAccounts(azure.Settings{ + HideSubscriptions: pc.Azure.HideSubscriptions, + Vaults: pc.Azure.Vaults, + }) + if err != nil { + return fmt.Errorf("azure: %w", err) + } + if config.On(pc.Azure.Enabled) { + for _, a := range accounts { + providers = append(providers, a) + } + } + if config.On(pc.Databricks.Enabled) { + dbx, err := databricks.New(accounts[0], pc.Databricks.HideProfiles) + if err != nil { + return fmt.Errorf("databricks: %w", err) + } + providers = append(providers, dbx) + } + if config.On(pc.Repos.Enabled) { + start := *dir + if start == "" { + start, _ = os.Getwd() + } + roots := []string{"~/git"} + if pc.Repos.Roots != nil { + roots = *pc.Repos.Roots + } + repos := repo.New(repo.Settings{ + Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st), + Include: pc.Repos.Include, Exclude: pc.Repos.Exclude, + }) + // The repo it started in (shown on top as Repo) and the + // others, checked for secret files in the background like + // the other sources; repos without any aren't listed. + providers = append(providers, repos) + } + if len(providers) == 0 { + return fmt.Errorf("all providers are disabled in %s", *configPath) + } + } + + // Libraries (notably the Databricks SDK) log token refreshes through + // the standard logger, which would draw over the TUI. + log.SetOutput(io.Discard) + + if getRef != nil { + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + return get(ctx, providers, st, *getRef) + } + + // Links opened elsewhere (lazysecrets launch) can come here. + var p *tea.Program + var srv *instance.Server + if !*demo { + srv, err = instance.Listen(instance.Dir(stateDir), func(link, front string) error { + r, err := ref.Parse(link) + if err != nil { + return err + } + // Accept at once: launch waits for the answer, and opens a + // window anyway if it's slow. Coming to the front can be + // (activating the terminal app). + go func() { + p.Send(app.OpenLink(r)) + instance.Raise(front) + }() + return nil + }) + if err == nil { + defer srv.Close() + } // else links open in new windows: not worth failing for + } + var setup *app.HandlerSetup + if !*demo { + setup = handlerSetup(cfg, *configPath) + } + onActive := func() {} + if srv != nil { + onActive = srv.Touch + } + + m := app.New(providers, app.Options{ + Timeout: *timeout, + Icons: *icons, + ClipboardClear: *clipClear, + FlatSources: cfg.SourcesView == "flat", + CollapseGroups: config.On(cfg.CollapseGroups), + LoadDelay: loadDelay, + NoHistory: !config.On(cfg.History), + OnActive: onActive, + RevealTimeout: revealTimeout, + ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour, + HideOnBlur: config.On(cfg.HideOnBlur), + HandlerSetup: setup, + Updater: updater(), + UpdateCheck: config.On(cfg.UpdateCheck), + Keys: &keymap, + Commands: cfg.CustomCommands, + Version: versionString(), + Store: st, + Open: open, + Styles: &theme, + DefaultFg: defaultFg, + }) + p = tea.NewProgram(m) + _, err = p.Run() + return err +} diff --git a/cmd/lazysecrets/update.go b/cmd/lazysecrets/update.go new file mode 100644 index 0000000..4231642 --- /dev/null +++ b/cmd/lazysecrets/update.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/ui/app" + "git.kebcloud.no/keb/lazysecrets/internal/update" +) + +// updater is the app's way to find and install releases, replacing the +// binary that's running. +func updater() *app.Updater { + c := &update.Client{} + return &app.Updater{ + Latest: c.Latest, + Install: func(ctx context.Context, r update.Release) error { + exe, err := update.Executable() + if err != nil { + return err + } + return c.Install(ctx, r, exe) + }, + } +} + +// updateCmd is lazysecrets update [--check]: the newer release's +// changelog, then (unless --check) install it. +func updateCmd(args []string) error { + check := len(args) == 1 && args[0] == "--check" + if len(args) > 0 && !check { + return errors.New("usage: lazysecrets update [--check]") + } + current := versionString() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + c := &update.Client{} + r, err := c.Latest(ctx) + if err != nil { + return fmt.Errorf("looking for a newer lazysecrets: %w", err) + } + if !update.Newer(current, r.Tag) { + if !update.Newer(current, "v999.0.0") { + return fmt.Errorf("this is a development build (%s); the latest release is %s: install it with install.sh, or go install …@%s", current, r.Tag, r.Tag) + } + fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag) + return nil + } + fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes())) + if check { + fmt.Println("lazysecrets update installs it.") + return nil + } + exe, err := update.Executable() + if err != nil { + return err + } + fmt.Printf("Updating %s…\n", exe) + if err := c.Install(ctx, r, exe); err != nil { + return err + } + fmt.Printf("Updated to %s.\n", r.Tag) + return nil +} + +func indent(s string) string { + if s == "" { + return " (no changelog)" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/cmd/lazysecrets/version.go b/cmd/lazysecrets/version.go new file mode 100644 index 0000000..bd96948 --- /dev/null +++ b/cmd/lazysecrets/version.go @@ -0,0 +1,38 @@ +package main + +import "runtime/debug" + +// version is set by release builds: +// +// go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets +var version string + +// versionString is the release version, or for other builds what Go +// recorded: the module version (go install …@v1.2.3), else the commit. +func versionString() string { + if version != "" { + return version + } + bi, ok := debug.ReadBuildInfo() + if !ok { + return "dev" + } + if v := bi.Main.Version; v != "" && v != "(devel)" { + return v + } + var rev, dirty string + for _, s := range bi.Settings { + switch s.Key { + case "vcs.revision": + rev = s.Value + case "vcs.modified": + if s.Value == "true" { + dirty = "-dirty" + } + } + } + if len(rev) >= 7 { + return "dev-" + rev[:7] + dirty + } + return "dev" +} diff --git a/docs/azure.md b/docs/azure.md new file mode 100644 index 0000000..ebe9744 --- /dev/null +++ b/docs/azure.md @@ -0,0 +1,54 @@ +# Azure Key Vault + +lazysecrets reads Azure with your az CLI login: run `az login` once, and +it reuses it. + +## Accounts + +Every account signed in to az gets its own group in the Sources pane +(`az login` again with another account to add one), and each account's +vaults are read with that account. A subscription several accounts can see +is listed under each, since their permissions can differ. + +A service principal is used instead when `AZURE_CLIENT_ID`, +`AZURE_TENANT_ID` and a secret or certificate are set in the environment. + +If your az login expires mid-session, the status line says so: press `L` to +run `az login` without leaving lazysecrets, and everything reloads. + +## Permissions + +Vaults are discovered with Resource Graph, so you need read access to the +vault resources (e.g. *Reader*). Key Vault data-plane access is separate +(Azure RBAC or access policies). With RBAC, typically: + +| To | Role | +|---|---| +| list secrets, keys and certificates | *Key Vault Reader* | +| read secret values | *Key Vault Secrets User* | +| read certificate details | *Key Vault Certificate User* | + +A missing permission is shown in the pane, including the action that was +denied. + +## Vaults you can't discover + +A vault you can read but not see through Resource Graph can be pinned in +the [config](config.md); it's listed under "Pinned vaults" and read with +az's default account: + +```yaml +providers: + azure: + vaults: + - https://kv-foo.vault.azure.net/ + hideSubscriptions: [platform-mgmt] # by name or ID +``` + +## What's shown + +Secrets, keys and certificates, each on their own tab of the Secrets pane +(`[`/`]`). `space` reveals a secret's value, or the public material of a +key (PEM public key) or certificate; private key material is never +fetched. `v` lists an item's versions. Items expiring within +`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`. diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..c5c7023 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,119 @@ +# Config + +Optional, at `~/.config/lazysecrets/config.yaml` (or +`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override +it. Unknown keys are an error, so typos don't go unnoticed. + +```yaml +icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0 +clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables +revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown +hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on) +expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗ +timeout: 30s # per backend request +loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once +sourcesView: grouped # or flat; ` toggles it +collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one +updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way) +history: true # count uses of items for Recent (R) +providers: + azure: # applies to every az account + enabled: true # default + hideSubscriptions: [platform-mgmt] # by name or ID + vaults: # listed under "Pinned vaults", for vaults + - https://kv-foo.vault.azure.net/ # you can read but not discover + databricks: + enabled: true + hideProfiles: [test] + repos: + enabled: true + roots: [~/git] + paths: [~/work/infra] + include: ["secrets/**"] + exclude: ["**/testdata/**"] +``` + +More, each on its own page: [repos](repos.md) (`providers.repos`), the +[link handler](links.md#from-the-browser-and-chat) (`linkHandler`), +[keybindings](keys.md#rebinding-keys) (`keybinding`) and +[custom commands](custom-commands.md) (`customCommands`). + +## Clipboard + +The clipboard is the system clipboard locally and OSC52 over SSH (your +terminal must allow OSC52 writes; in tmux, `set -g set-clipboard on`). + +## Theme + +Colours follow your terminal's 16-colour palette by default. A `theme:` +section overrides roles with hex colours, palette numbers (0-255) or names, +plus attributes (`bold`, `italic`, `underline`, `reverse`, `faint`). Role +names follow lazygit's `gui.theme` where they mean the same thing: + +```yaml +theme: + activeBorderColor: ["#ff966c", bold] # focused pane + inactiveBorderColor: ["#589ed7"] + selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8) + inactiveViewSelectedLineBgColor: ["#2f334d"] + defaultFgColor: ["#c8d3f5"] + optionsTextColor: ["#82aaff"] # keys and labels + searchMatchColor: ["#ffc777", bold] + errorColor: ["#c53b53"] # ✗ failures + noticeColor: ["#c3e88d"] + successColor: ["#c3e88d", bold] # the ✓ before confirmations + warningColor: ["#ffc777"] # ⚠ what can't be done here + mutedColor: ["#636da6"] + favouriteColor: ["#ffc777"] + valueColor: ["#ffc777"] + azureColor: ["#82aaff"] + databricksColor: ["#ff757f"] + repoColor: ["#c099ff"] +``` + +Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render +as boxes, turn them off with `icons: false`. + +## Layering + +Several config files can be layered, later ones winning (theme roles and +keybindings merge one by one), with a comma-separated `--config` or +`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`. An editor +plugin can so add a generated theme file to your own config, the way +snacks.nvim themes lazygit from Neovim's highlight groups: + +```sh +LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets +``` + +## State + +Stars, notes, usage and the layout are kept in +`~/.local/state/lazysecrets/state.json` (`$XDG_STATE_HOME`), readable only +by you: one record per source, vault or secret, keyed by its +`lazysecrets://` link, never with values. A star belongs to the account you +starred the item under (a vault two of your accounts can see can be starred +for one); a note describes the item, so it shows under every account. It's +JSON so an editor plugin can read it with a built-in parser; several +lazysecrets windows can save to it without losing each other's changes. + +Revealing, copying, opening in the browser, sharing and jumping to an item +count as using it: its record keeps when it was last used and how often, +for Recent (`R`). Records kept only for usage are dropped after 90 days +unused, and beyond the 100 most recent. `history: false` stops counting. + +## Updates + +Once a day (`updateCheck`), in the background, lazysecrets asks the +release server for the latest release; within the day it reuses the +answer from the state file. Offline, or with the server down, it says +nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or +`lazysecrets update --check`) shows its changelog. + +Updating (`u` there, or `lazysecrets update`) downloads the release for +your OS and CPU, checks it against the release's `checksums.txt`, makes +sure it runs, and only then replaces the binary, atomically: a +lazysecrets that's running keeps working, and the next start is the new +version. A binary a package manager owns (Homebrew, Nix, a distro +package) is left to it. Development builds (`dev-…`) aren't offered +releases. diff --git a/docs/custom-commands.md b/docs/custom-commands.md new file mode 100644 index 0000000..0b92347 --- /dev/null +++ b/docs/custom-commands.md @@ -0,0 +1,50 @@ +# Custom commands + +Run your own shell commands on the selected item, like lazygit's custom +commands: by their key in the pane they're for, or from the commands menu +(`x`, also listed under `?`). + +```yaml +customCommands: + - key: K + context: secrets # sources | vaults | secrets | global + description: Create Kubernetes secret + command: kubectl create secret generic {{.Name}} -n {{.Prompt.ns}} --from-file=value=/dev/stdin + value: stdin # the value on the command's input; or env:NAME + prompts: + - {name: ns, title: Namespace, default: default} + output: popup # quiet (default) | popup | terminal + - key: E + context: secrets + description: Add to .env + command: printf '%s=%s\n' {{.Name}} "$(cat)" >> .env + value: stdin + - context: vaults + description: Show in az + command: az keyvault show --name {{.Vault}} + output: terminal +``` + +## Fields + +`.Kind`, `.Account`, `.Source`, `.SourceID` (subscription ID or Databricks +profile), `.Vault`, `.VaultURL`, `.Name`, `.Type`, `.Version` (from the +versions view), `.Link`, `.Reference`, `.URL` (web page), `.Note`, +`.Expires`, and `.Prompt.`. They're shell-quoted when filled in, so a +note can't break or inject into the command; `{{raw .Note}}` opts out. + +## The value + +The secret value is never a field, so it can't end up on a command line +(where `ps` and shell history see it). `value: stdin` hands it to the +command's input, `value: env:NAME` puts it in that environment variable for +the command alone; without `value` it isn't fetched. Commands that get the +value ask first, showing the command (`confirm: false` skips that). + +## Running + +Output: `quiet` runs in the background with ✓ or ✗ in the footer, `popup` +shows what it printed, `terminal` hands it the terminal and waits for +enter. Commands run with `$SHELL -c` in the directory lazysecrets started +in, and come only from your config: a link can never run one. Their keys +are checked against the [keybindings](keys.md#rebinding-keys) at startup. diff --git a/docs/databricks.md b/docs/databricks.md new file mode 100644 index 0000000..d44ca7d --- /dev/null +++ b/docs/databricks.md @@ -0,0 +1,22 @@ +# Databricks + +Workspaces come from the profiles in `~/.databrickscfg` (or +`$DATABRICKS_CONFIG_FILE`); account-console profiles are skipped. Each +workspace is a source, its secret scopes are the vaults. + +Credentials are resolved the same way as the Databricks CLI, so +`auth_type = azure-cli` uses your az login and OAuth profiles use +`databricks auth login`. When a login expires, `L` runs the right one for +that profile. + +Secrets in Azure Key Vault-backed scopes are read through Databricks; if +Databricks refuses, lazysecrets reads the backing vault directly with your +az login (which then needs the [Key Vault roles](azure.md#permissions)). + +Hide profiles you don't want in the [config](config.md): + +```yaml +providers: + databricks: + hideProfiles: [test] +``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..ee9167e --- /dev/null +++ b/docs/development.md @@ -0,0 +1,63 @@ +# Development + +```sh +go run ./cmd/lazysecrets +go run ./cmd/lazysecrets --demo # built-in sample data, no Azure needed +go test ./... +go test ./internal/ui/app -run TestGolden -update # after intentional UI changes +``` + +A stripped binary is about 21 MB instead of 30 MB: + +```sh +go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets +``` + +Development builds show `dev-` as their version (`-dirty` with +uncommitted changes); `go install …@v0.2.0` shows the tag, releases the +version they were built for. + +## Pull requests + +Pull requests into `main` are squash-merged, so each is one commit titled +like the PR. The title is a [conventional commit](https://www.conventionalcommits.org) +subject, checked by `pr-title.yml`: + +| Type | For | In the changelog | +|---|---|---| +| `feat` | a new feature | Added | +| `perf` | faster or smaller, same behaviour | Changed | +| `fix` | a bug fix | Fixed | +| `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no | + +`feat!:` marks a breaking change, `feat(scope):` names a part. Brackets +and a missing space work too: `[feat] x`, `[feat]: x` and `feat:x` all +become `feat: x` in the changelog. `[skip changelog]` in the description +leaves a pull request out. + +CI (`ci.yml`) runs gofmt, vet, staticcheck, the tests and a build for each +platform on pull requests into `main`. `main` is protected: a pull request +merges only when it's up to date with `main` (Gitea's "Update branch" +button, or a rebase, then CI again), so what lands on `main` is exactly +what its pull request tested, and CI doesn't run again there. + +## Releasing + +Merge as many pull requests as you like, then release `main`: + +```sh +scripts/release.sh --dry-run v0.2.0 # the notes the release would have +scripts/release.sh v0.2.0 # CHANGELOG.md, commit, tag, push +``` + +It refuses unless you're on a clean `main` that matches `origin/main`. +[git-cliff](https://git-cliff.org) (`cliff.toml`) writes `CHANGELOG.md` +from the pull request titles. The tag runs the release workflow +(`.gitea/workflows/release.yml`): it tests, then +[GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the +archives and publishes a Gitea release with them and the release's +changelog section. `goreleaser release --snapshot --clean` builds the same +archives locally, into `dist/`, without publishing. + +A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md` +alone. diff --git a/docs/keys.md b/docs/keys.md new file mode 100644 index 0000000..0ca0908 --- /dev/null +++ b/docs/keys.md @@ -0,0 +1,146 @@ +# Keys + +`?` lists every key with what it does, the focused pane's first; type to +filter, `enter` runs one. The help bar at the bottom shows the most useful +keys for where you are. Every key can be [rebound](#rebinding-keys). + +The rule: a letter in the lists, ctrl+ that letter where you type a filter +(`f` stars, `ctrl+f` stars while filtering). + +## Moving around + +| Key | Action | +|---|---| +| `j`/`k`, `↑`/`↓` | move | +| `g`/`G` | top / bottom | +| `tab`/`l`, `shift+tab`/`h` | next / previous pane | +| `1`–`4` | jump to pane | +| `enter` / `esc`, `⌫` | drill in / back (also clears a filter or leaves the versions view) | +| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets | +| `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars | +| `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it | +| `r` | refresh the focused pane | +| `q`, `ctrl+c` | quit | + +## Searching + +`/` in a pane, Go to, `?` and other popups with a text field all search +the same way, with [fzf](https://github.com/junegunn/fzf)'s scoring (the +same as snacks.nvim's picker): letters at the start of a word or in one +run count far more than letters scattered through a name, matches in an +item's own name more than in its path or note, and equally good matches +put the shorter name first. + +| Query | Finds | +|---|---| +| `dbpw` | fuzzy: `db-password` | +| `pass prod` | both terms, in any order | +| `smtp \| key` | either | +| `'prod` | exactly `prod`, not fuzzy | +| `^kv-` / `-dev$` | names starting / ending with it | +| `!test` | without `test` | + +Lowercase ignores case; typing an uppercase letter makes that term +match case exactly. + +## Secrets + +| Key | Action | +|---|---| +| `space` | reveal / hide the selected value (public material for keys and certificates) | +| `y` | copy the value without revealing it; cleared from the clipboard after 30s | +| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) | +| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | +| `o` | open the selected item in the browser (over SSH: copy the link) | +| `s` / `S` | copy a [link](links.md) to the selected item / share menu | +| `ctrl+o` | open a link or reference (or just paste one) | +| `x` | [custom commands](custom-commands.md) | +| `L` | sign in again (shown when a login has expired) | +| `U` | a newer lazysecrets: its changelog, and update in place (restart to use it); or look for one | + +A revealed value hides again after `revealTimeout` (60s) and when the +terminal loses focus (`hideOnBlur`); see [config](config.md). + +## Stars and notes + +| Key | Action | +|---|---| +| `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list | +| `n` | write a short note on it, shown dimmed on its row and in the detail pane | + +A star belongs to the account you starred the item under; a note shows +under every account. Both are kept in the [state file](config.md#state). + +## Go to + +`ctrl+s` opens Go to on its Search tab, `F` on Favourites (everything you +starred) and `R` on Recent (what you used last). `enter` goes to the +selected item. + +Search has the scope on the left (All; all of Azure, Databricks or repos; +or one source; with `○ ◐ ●` for how much of it is loaded) and what's in +it on the right. All repos searches every repo without asking Azure or +Databricks for anything. It opens on the +source you're in. Typing a search loads what it covers: everything on +All, else the source picked; results appear as lists arrive. That's once +each time Go to opens, and only what isn't loaded yet; `ctrl+a` loads +again, lists that failed included. + +Like a pane, Go to is either browsing or typing a filter: + +| Browsing | Typing | Action | +|---|---|---| +| `j`/`k` | `ctrl+j`/`ctrl+k` | move | +| `h`/`l`, `tab` | `ctrl+h`/`ctrl+l`, `tab` | scope list / results | +| `[` / `]` | | previous / next tab | +| `f` | `ctrl+f` | star | +| `r` | `ctrl+r` | load the selected item, or the scope when nothing is selected | +| `a` | `ctrl+a` | load everything in the scope (again) | +| `/` | `esc` | start typing / clear it and go back to browsing | +| `esc`, `q` | | close | + +Favourites and Recent come from the state file, so they work before +anything loads. + +## Layout + +| Key | Action | +|---|---| +| `ctrl+←`/`ctrl+→` or `<`/`>` | move the divider between the left panes and the detail pane | +| `ctrl+↓`/`ctrl+↑` or `}`/`{` | make the focused left pane taller / shorter | +| `+` / `_` | zoom the focused pane: normal → half → full screen, and back | +| `=` | reset the layout (divider, heights, zoom) | + +The divider and pane heights are remembered (zoom isn't). macOS uses +ctrl+arrows for Mission Control and switching desktops by default, hence +the `<` `>` `{` `}` alternatives. + +## Rebinding keys + +Every action can be rebound in the [config](config.md), per context: + +```yaml +keybinding: + lists: # the panes, when not typing + star: [x] # replaces f + versions: [] # unbound + filter: # typing a filter in a pane + star: [ctrl+x] + popup: # popups with a text field: help, notes, open link, Go to while typing + down: [ctrl+j, down] + menu: # popups without a text field + search: # Go to's own actions while typing (on top of popup) + goto: # Go to while browsing +``` + +An action you set gets exactly the keys you list, replacing its defaults; +`[]` unbinds it. Actions you don't set keep their defaults, also across +layered config files. `lazysecrets keybindings` prints every action with +its keys (your config applied), ready to copy and edit. A key bound to two +actions that apply together is an error at startup, naming both. The help +bar, `?` and every hint show the keys you bound. Fixed: the letters inside +menus (they're part of each entry), and `ctrl+c`, which always quits. + +Without the kitty keyboard protocol (or tmux `extended-keys`), some +combinations never reach a terminal app, such as `ctrl+shift+x`, `ctrl+1` +or `ctrl+enter`. diff --git a/docs/links.md b/docs/links.md new file mode 100644 index 0000000..b4cc246 --- /dev/null +++ b/docs/links.md @@ -0,0 +1,118 @@ +# Links + +Every subscription, workspace, vault, scope, repo, file and secret has a +`lazysecrets://` link. Share one in chat or a ticket, and whoever opens it +lands on that item, reading it with their own account and permissions: +links carry names, never values. + +``` +lazysecrets://azure/kv-app-prod.vault.azure.net vault +lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab +lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password secret +lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version +lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key +lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password +``` + +## Sharing + +`s` copies a link to the selected item (from the versions view, pinned to +that version). `S` opens the share menu: + +- secrets: the link (`o`), the plain Key Vault URL (`u`), the config + reference (`y`) or the web page (`b`) +- vaults and scopes: a link (`o`), a link that opens on the current tab + (`t`), the plain vault URL (`u`) or the web page (`b`) +- subscriptions and workspaces: a link (`o`) or the web page (`b`) + +## Opening + +- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard + and fills in any reference it finds there. Key Vault URLs, + `@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too. +- `lazysecrets ` (or `lazysecrets open `) in a terminal. +- Click it anywhere, once the [link handler](#from-the-browser-and-chat) is + set up. + +If you copy a `lazysecrets://` link elsewhere and switch back, lazysecrets +offers to open it, once per link. It looks at the clipboard at startup, +when the terminal regains focus (in tmux: `set -g focus-events on`) and, +for terminals that don't report focus, on a key press at most every 3 +seconds. It keeps nothing but `lazysecrets://` links, doesn't offer a link +to what's already selected (like one you just copied with `s`), and can't +read the clipboard over SSH. + +lazysecrets finds the vault in your accounts (one Resource Graph query per +account) and jumps to it, asking which account to use when several can see +it. Links say what, not what to do: opening a link to a secret asks whether +to just go to it, also copy or show its value, or (`y`) copy its value +without going there. + +## In scripts + +`lazysecrets get ` prints the value, for scripts and pipes: + +```sh +export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password) +lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin +``` + +It reads the value directly, with your az accounts (the one that last read +it first), the Databricks profile for the workspace, or sops in your clone +of the repo; a version in the link reads that version. The value ends with +a newline only on a terminal. + +## From the browser and chat + +```sh +lazysecrets handler install # run it in the terminal links should open in +lazysecrets handler uninstall +``` + +Use an installed binary (`go run`'s is temporary). lazysecrets offers to do +this at startup when links don't open it (or open a lazysecrets that no +longer exists): set it up, not now, or don't ask again, which is +remembered in the state file. `linkHandler.prompt: false` in the config +turns the question off too. + +`handler install` registers `lazysecrets://` with the system: clicking a +link then opens a new window of the terminal you ran it in, with +lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2, +Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside +tmux; `--terminal NAME` picks another. It records your `PATH` and config +locations, since apps start without your shell's environment (and so +without `az`): run it again after moving lazysecrets or changing those. + +- macOS: a small background app, `~/Applications/Lazysecrets Links.app` +- Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as + the default with `xdg-mime` (desktops following freedesktop.org) +- Windows: not yet + +The config can override the terminal, or run any other: + +```yaml +linkHandler: + terminal: ghostty # instead of the one install detected + command: [foot, --title, secrets, "{cmd}"] # any terminal; {cmd} is the lazysecrets command + reuse: true # hand links to a running lazysecrets first + prompt: true # offer to set up the handler at startup +``` + +### Where a clicked link opens + +- **In a terminal** (a link in a terminal app): a lazysecrets that + terminal shows (or whose tmux session one of its tmux clients can switch + to) gets it, never one elsewhere; otherwise a new window opens. +- **Anywhere else** (Slack, a browser): the lazysecrets you used last (key + press or focus), preferring one already on screen; a tmux client is + switched to its session if needed, its terminal comes to the front, and + a hidden dropdown terminal (kitty's quick access terminal) is shown. + With none running, a new window opens. + +The link is then offered as if pasted. `linkHandler.reuse: false` always +opens a new window. + +Running instances listen on a socket in `$XDG_RUNTIME_DIR/lazysecrets`, or +`run/` in the state directory, readable only by you. A link from a web page +can only make lazysecrets go somewhere: it asks before copying or showing +anything. diff --git a/docs/repos.md b/docs/repos.md new file mode 100644 index 0000000..565b072 --- /dev/null +++ b/docs/repos.md @@ -0,0 +1,75 @@ +# SOPS and .env files in git repos + +lazysecrets shows git repositories under **Repos**: their +[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON, +`.env`, INI) and plain env files as vaults, and the keys in them +(`db.password`, `list[0]`) as secrets. + +## Which repos + +**Repos** lists every repo with secret files under the `roots` (`~/git` +by default; one or two levels down, `repo` or `org/repo`), any listed in +`paths`, and any you starred something in (the repo, a file or a key), +wherever it is. They're checked in the background. + +Started inside a repo, that repo gets a row of its own at the top, +`Repo · `, and lazysecrets opens on it, with the files under the +directory you started in first. In the flat list (`` ` ``) it sorts first, marked +`· here`. + +A repo without secret files isn't listed. `--dir ` starts somewhere +else, for example in one project of a monorepo. + +Repos load themselves as they're found, files and keys, since they're +local and cheap: [Go to](keys.md#go-to) searches them without loading +anything by hand. + +## Which files + +**Env files**: `.env`, `.env.local`, `prod.env` and the like, also when +git-ignored (as they usually are), in the folders git tracks files in; +not `.env.example`, `.sample`, `.template` and the like. They're read +directly; a file with SOPS metadata is always treated as SOPS, whatever +its name. + +Folders of dependencies and caches are never looked in, tracked or not: +`node_modules`, `vendor`, `.venv`, `venv`, `__pycache__`, `.terraform`, +`.gradle`, `.yarn`, `.next` and the like. + +**SOPS files**: those tracked by git, then the first of + +1. `include`/`exclude` in the repo's own `.lazysecrets.yaml` (committed, + so everyone sees the same files), +2. `include`/`exclude` in the config, +3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go, +4. the whole repo. + +`include`/`exclude` apply to env files too. + +```yaml +providers: + repos: + roots: [~/git] # folders of repos (the default; [] turns it off) + paths: [~/work/infra] # single repos elsewhere + include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth + exclude: ["**/testdata/**"] +``` + +## Decrypting + +Listing never decrypts: SOPS encrypts values only, so keys and metadata +(encryption, last modified) are read straight from the files. Revealing, +copying or `lazysecrets get` decrypts one value with your `sops` binary, so +whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP +KMS, PGP). When sops can't decrypt, its whole error message is shown. + +## Links + +Links name the repo by its git remote, so they open in a colleague's clone +wherever it is: + +``` +lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password +``` + +See [links](links.md). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..43c2fb4 --- /dev/null +++ b/go.mod @@ -0,0 +1,81 @@ +module git.kebcloud.no/keb/lazysecrets + +go 1.26.4 + +require ( + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.0.10 + charm.land/lipgloss/v2 v2.0.6 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.5.0 + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0 + github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/x/ansi v0.11.8 + github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f + github.com/databricks/databricks-sdk-go v0.187.0 + github.com/junegunn/fzf v0.74.4 + go.yaml.in/yaml/v3 v3.0.5 + golang.org/x/mod v0.38.0 + golang.org/x/term v0.45.0 + gopkg.in/ini.v1 v1.67.0 +) + +require ( + cloud.google.com/go/auth v0.4.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect + github.com/aymanbagabas/go-udiff v0.4.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/api v0.182.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..257f731 --- /dev/null +++ b/go.sum @@ -0,0 +1,263 @@ +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o= +charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/auth v0.4.2 h1:sb0eyLkhRtpq5jA+a8KWw0W70YcdVca7KJ8TM0AFYDg= +cloud.google.com/go/auth v0.4.2/go.mod h1:Kqvlz1cf1sNA0D+sYJnkPQOP+JMHkuHeIgVmCRtZOLc= +cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= +cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1/go.mod h1:oXtinPO4OLj9d1DOTrqrL1oRwGhcqadvAmrl6wTeGlk= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.0 h1:+1fJwTilk/X7inNqwREnYEOgFCdg8ut7GULxARDbu34= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.0/go.mod h1:EGwSLlGqrrfYQhtCi9JcIkPQKl9WxsL6ZPJd+63Vy1A= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.5.0 h1:15MiSirdxWpIKFEHwfwfMWYieETCUnPTERPIlnytQeo= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.5.0/go.mod h1:0AAf/fHAqhIsvD6XGKlCsDO8u+cCiYBBuro+lWS6os4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0 h1:aMFOzch6ZJo4Ct9hI4A9Y2fPen5YNRTPmkSBhe5m0ZQ= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0/go.mod h1:Oct8bx+g+DXKngU7i/LzFzYt44rmLdMu4uoofIpooVo= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 h1:Nljr4q1GRA/5vCrMONS+g4u4LRHNgOXVSh3O43J2CnI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0/go.mod h1:Y33QHnf0FfdVewFFISOGe20mkZbxX4H839o955/PoeI= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f h1:8CnFOYzrMArVN42jYaGvnBo3mxdONgt09fly+9B96GY= +github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/databricks/databricks-sdk-go v0.187.0 h1:V1rGbEUaEdGS7tTZSyplDLnE2Vp5N1aIPiIh/LzjOC8= +github.com/databricks/databricks-sdk-go v0.187.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg= +github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= +github.com/junegunn/fzf v0.74.4 h1:DSqIW3ZxtkuuPzeLKrVnHdoWqT9VCIGRbjc/lXiGCso= +github.com/junegunn/fzf v0.74.4/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM= +github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs= +github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= +golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.182.0 h1:if5fPvudRQ78GeRx3RayIoiuV7modtErPIZC/T2bIvE= +google.golang.org/api v0.182.0/go.mod h1:cGhjy4caqA5yXRzEhkHI8Y9mfyC2VLTlER2l08xaqtM= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..6618290 --- /dev/null +++ b/install.sh @@ -0,0 +1,84 @@ +#!/bin/sh +# Installs lazysecrets from its releases: the archive for this OS and CPU, +# checked against the release's checksums.txt, into ~/.local/bin. +# +# curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh +# curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh -s -- v0.2.0 +# +# LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64 +# and x86_64. +set -eu + +repo=https://git.kebcloud.no/keb/lazysecrets +api=https://git.kebcloud.no/api/v1/repos/keb/lazysecrets +dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}" + +fail() { + echo "lazysecrets install: $*" >&2 + exit 1 +} + +if command -v curl >/dev/null 2>&1; then + fetch() { curl -fsSL "$1"; } + download() { curl -fsSL -o "$2" "$1"; } +elif command -v wget >/dev/null 2>&1; then + fetch() { wget -qO- "$1"; } + download() { wget -qO "$2" "$1"; } +else + fail "needs curl or wget" +fi + +case "$(uname -s)" in +Darwin) os=darwin ;; +Linux) os=linux ;; +*) fail "no release for $(uname -s); build it with: go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest" ;; +esac +case "$(uname -m)" in +x86_64 | amd64) arch=amd64 ;; +arm64 | aarch64) arch=arm64 ;; +*) fail "no release for $(uname -m)" ;; +esac + +tag="${1:-}" +if [ -z "$tag" ]; then + tag=$(fetch "$api/releases/latest" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p') || + fail "couldn't look up the latest release" + [ -n "$tag" ] || fail "couldn't look up the latest release" +fi +case "$tag" in +v*) ;; +*) tag="v$tag" ;; +esac +version="${tag#v}" + +name="lazysecrets_${version}_${os}_${arch}" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +echo "Downloading lazysecrets $tag for $os/$arch" +download "$repo/releases/download/$tag/$name.tar.gz" "$tmp/$name.tar.gz" || + fail "couldn't download $tag for $os/$arch: see $repo/releases" +download "$repo/releases/download/$tag/checksums.txt" "$tmp/checksums.txt" || + fail "couldn't download $tag's checksums.txt" + +want=$(awk -v f="$name.tar.gz" '$2 == f { print $1 }' "$tmp/checksums.txt") +[ -n "$want" ] || fail "$name.tar.gz isn't in checksums.txt" +if command -v sha256sum >/dev/null 2>&1; then + got=$(sha256sum "$tmp/$name.tar.gz" | awk '{ print $1 }') +else + got=$(shasum -a 256 "$tmp/$name.tar.gz" | awk '{ print $1 }') +fi +[ "$got" = "$want" ] || fail "checksum mismatch for $name.tar.gz: the download is damaged or not the release" + +tar -xzf "$tmp/$name.tar.gz" -C "$tmp" +mkdir -p "$dir" +# Replaced, not overwritten: a running lazysecrets keeps its binary. +cp "$tmp/$name/lazysecrets" "$dir/.lazysecrets.new" +chmod 755 "$dir/.lazysecrets.new" +mv -f "$dir/.lazysecrets.new" "$dir/lazysecrets" +echo "Installed $("$dir/lazysecrets" --version) in $dir" + +case ":$PATH:" in +*":$dir:"*) ;; +*) echo "$dir isn't on your PATH: add it to your shell's profile, e.g. export PATH=\"$dir:\$PATH\"" ;; +esac diff --git a/internal/browser/browser.go b/internal/browser/browser.go new file mode 100644 index 0000000..51d342c --- /dev/null +++ b/internal/browser/browser.go @@ -0,0 +1,34 @@ +// Package browser opens URLs in the user's web browser. +package browser + +import ( + "errors" + "os" + "os/exec" + "runtime" +) + +// ErrRemote is returned over SSH, where a browser would open on the remote +// machine rather than in front of the user. +var ErrRemote = errors.New("can't open a browser over SSH") + +// Open starts the system's URL handler without waiting for it. +func Open(url string) error { + if os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "" { + return ErrRemote + } + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + cmd = exec.Command("xdg-open", url) + } + if err := cmd.Start(); err != nil { + return err + } + go func() { _ = cmd.Wait() }() // reap the process + return nil +} diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go new file mode 100644 index 0000000..cbab604 --- /dev/null +++ b/internal/clipboard/clipboard.go @@ -0,0 +1,92 @@ +// Package clipboard copies text to the system clipboard, or via the OSC52 +// terminal escape sequence over SSH or when no native clipboard exists. +package clipboard + +import ( + "crypto/sha256" + "errors" + "os" + + tea "charm.land/bubbletea/v2" + "github.com/atotto/clipboard" +) + +type Clipboard struct { + native bool + read func() (string, error) + write func(string) error +} + +// ErrorMsg reports a failed clipboard write. +type ErrorMsg struct{ Err error } + +// New picks the native clipboard locally and OSC52 over SSH. +// LAZYSECRETS_CLIPBOARD_FILE substitutes a file, for end-to-end tests that +// mustn't touch the real clipboard. +func New() Clipboard { + if path := os.Getenv("LAZYSECRETS_CLIPBOARD_FILE"); path != "" { + return Clipboard{ + native: true, + read: func() (string, error) { + b, err := os.ReadFile(path) + return string(b), err + }, + write: func(s string) error { return os.WriteFile(path, []byte(s), 0o600) }, + } + } + remote := os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "" + return Clipboard{ + native: !remote && !clipboard.Unsupported, + read: clipboard.ReadAll, + write: clipboard.WriteAll, + } +} + +// Native reports whether the system clipboard is used rather than OSC52. +func (c Clipboard) Native() bool { return c.native } + +// Write puts s on the clipboard. +func (c Clipboard) Write(s string) tea.Cmd { + if !c.native { + return tea.SetClipboard(s) + } + return func() tea.Msg { + if err := c.write(s); err != nil { + return ErrorMsg{Err: err} + } + return nil + } +} + +// ErrNoRead is returned where the clipboard can't be read back (OSC52). +var ErrNoRead = errors.New("clipboard can't be read here") + +// Read returns the clipboard's text, where that's possible. +func (c Clipboard) Read() (string, error) { + if !c.native { + return "", ErrNoRead + } + return c.read() +} + +// Fingerprint identifies copied text for Clear without keeping it in memory. +func Fingerprint(s string) [32]byte { return sha256.Sum256([]byte(s)) } + +// Clear empties the clipboard if it still holds the text with fingerprint +// fp, so it doesn't wipe something the user copied since. OSC52 can't read +// the clipboard back, so there it always clears. +func (c Clipboard) Clear(fp [32]byte) tea.Cmd { + if !c.native { + return tea.SetClipboard("") + } + return func() tea.Msg { + cur, err := c.read() + if err != nil || Fingerprint(cur) != fp { + return nil + } + if err := c.write(""); err != nil { + return ErrorMsg{Err: err} + } + return nil + } +} diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go new file mode 100644 index 0000000..ae34465 --- /dev/null +++ b/internal/clipboard/clipboard_test.go @@ -0,0 +1,33 @@ +package clipboard + +import "testing" + +func fakeNative() (*Clipboard, *string) { + var board string + return &Clipboard{ + native: true, + read: func() (string, error) { return board, nil }, + write: func(s string) error { board = s; return nil }, + }, &board +} + +func TestClearOnlyIfUnchanged(t *testing.T) { + c, board := fakeNative() + c.Write("secret")() + if *board != "secret" { + t.Fatalf("board = %q", *board) + } + fp := Fingerprint("secret") + + *board = "something the user copied later" + c.Clear(fp)() + if *board != "something the user copied later" { + t.Error("cleared text the user copied afterwards") + } + + *board = "secret" + c.Clear(fp)() + if *board != "" { + t.Errorf("not cleared: %q", *board) + } +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go new file mode 100644 index 0000000..d1fd71e --- /dev/null +++ b/internal/commands/commands.go @@ -0,0 +1,166 @@ +// Package commands defines custom commands: shell commands from the +// config, run on the selected item with its details filled in. +// +// customCommands: +// - key: K +// context: secrets +// description: Create Kubernetes secret +// command: kubectl create secret generic {{.Name}} -n {{.Prompt.namespace}} --from-file=value=/dev/stdin +// value: stdin +// prompts: [{name: namespace, title: Namespace, default: default}] +// output: popup +// +// Fields are shell-quoted when filled in ({{raw .Note}} opts out), and +// the secret value is never one of them: value: stdin or value: env:NAME +// hands it to the command without putting it on its command line. +package commands + +import ( + "fmt" + "regexp" + "strings" + "text/template" +) + +// Contexts say where a command applies: the pane with focus (the detail +// pane counts as secrets), or anywhere. +const ( + Sources = "sources" + Vaults = "vaults" + Secrets = "secrets" + Global = "global" +) + +// Outputs say how a command runs. +const ( + Terminal = "terminal" // in the full terminal, lazysecrets stepping aside + Popup = "popup" // output captured and shown in a popup + Quiet = "quiet" // in the background, success or failure in the footer +) + +type Command struct { + Key string `yaml:"key"` + Context string `yaml:"context"` + Description string `yaml:"description"` + Command string `yaml:"command"` + Value string `yaml:"value"` // "", "stdin" or "env:NAME" + Confirm *bool `yaml:"confirm"` // defaults to true when the value is passed + Output string `yaml:"output"` // defaults to quiet + Prompts []Prompt `yaml:"prompts"` +} + +// A Prompt asks for text before the command runs: {{.Prompt.name}}. +type Prompt struct { + Name string `yaml:"name"` + Title string `yaml:"title"` + Default string `yaml:"default"` +} + +var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// Validate checks a command from the config. +func (c Command) Validate() error { + name := c.Description + if name == "" { + name = c.Command + } + switch { + case c.Command == "": + return fmt.Errorf("custom command %q has no command", c.Description) + case c.Context != Sources && c.Context != Vaults && c.Context != Secrets && c.Context != Global: + return fmt.Errorf("custom command %q: context %q is not sources, vaults, secrets or global", name, c.Context) + case c.Output != "" && c.Output != Terminal && c.Output != Popup && c.Output != Quiet: + return fmt.Errorf("custom command %q: output %q is not terminal, popup or quiet", name, c.Output) + } + if c.Value != "" { + if c.Context != Secrets { + return fmt.Errorf("custom command %q: value needs context: secrets", name) + } + env, isEnv := strings.CutPrefix(c.Value, "env:") + if c.Value != "stdin" && !(isEnv && envName.MatchString(env)) { + return fmt.Errorf("custom command %q: value %q is not stdin or env:NAME", name, c.Value) + } + if c.Value == "stdin" && c.Output == Terminal { + return fmt.Errorf("custom command %q: output: terminal needs the terminal's input; use value: env:NAME", name) + } + } + seen := map[string]bool{} + for _, p := range c.Prompts { + if p.Name == "" || seen[p.Name] { + return fmt.Errorf("custom command %q: prompts need distinct names", name) + } + seen[p.Name] = true + } + // A trial run catches unknown fields ({{.Nmae}}) and prompts. + trial := Item{Prompt: map[string]Q{}} + for _, p := range c.Prompts { + trial.Prompt[p.Name] = "" + } + if _, err := c.Render(trial); err != nil { + return fmt.Errorf("custom command %q: %w", name, err) + } + return nil +} + +// Confirms reports whether to ask before running. +func (c Command) Confirms() bool { + if c.Confirm != nil { + return *c.Confirm + } + return c.Value != "" +} + +// Out is the output mode, quiet unless set. +func (c Command) Out() string { + if c.Output == "" { + return Quiet + } + return c.Output +} + +// Item is what's filled into a command: the selected item's details, +// shell-quoted when printed, and the answers to its prompts. +type Item struct { + Kind, Account, Source, SourceID Q + Vault, VaultURL Q + Name, Type, Version Q + Link, Reference, URL, Note, Expires Q + Prompt map[string]Q +} + +// Q is a field, shell-quoted when printed in a template. +type Q string + +func (q Q) String() string { return Quote(string(q)) } + +var safe = regexp.MustCompile(`^[A-Za-z0-9_./:@%+=,-]+$`) + +// Quote quotes s for a POSIX shell, leaving plain words as they are. +func Quote(s string) string { + if safe.MatchString(s) { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func (c Command) template() (*template.Template, error) { + return template.New("command").Option("missingkey=error"). + Funcs(template.FuncMap{"raw": func(q Q) string { return string(q) }}). + Parse(c.Command) +} + +// Render fills the item into the command. +func (c Command) Render(it Item) (string, error) { + t, err := c.template() + if err != nil { + return "", err + } + if it.Prompt == nil { + it.Prompt = map[string]Q{} + } + var b strings.Builder + if err := t.Execute(&b, it); err != nil { + return "", err + } + return b.String(), nil +} diff --git a/internal/commands/commands_test.go b/internal/commands/commands_test.go new file mode 100644 index 0000000..51f2bf4 --- /dev/null +++ b/internal/commands/commands_test.go @@ -0,0 +1,45 @@ +package commands + +import ( + "strings" + "testing" +) + +func TestRenderQuotes(t *testing.T) { + c := Command{Context: Secrets, Command: `kubectl create secret generic {{.Name}} -n {{.Prompt.ns}} # {{.Note}} {{raw .Name}}`} + got, err := c.Render(Item{Name: "db-password", Note: "it's $(rm -rf ~)", Prompt: map[string]Q{"ns": "team a"}}) + if err != nil { + t.Fatal(err) + } + want := `kubectl create secret generic db-password -n 'team a' # 'it'\''s $(rm -rf ~)' db-password` + if got != want { + t.Errorf("got %s\nwant %s", got, want) + } +} + +func TestValidate(t *testing.T) { + ok := Command{Context: Secrets, Command: "gh secret set {{.Name}}", Value: "stdin"} + if err := ok.Validate(); err != nil { + t.Errorf("valid command: %v", err) + } + if !ok.Confirms() || ok.Out() != Quiet { + t.Error("defaults: confirm with a value, quiet output") + } + for _, c := range []struct { + cmd Command + want string + }{ + {Command{Context: "panes", Command: "x"}, "context"}, + {Command{Context: Vaults, Command: "x", Value: "stdin"}, "needs context: secrets"}, + {Command{Context: Secrets, Command: "x", Value: "env:1BAD"}, "not stdin or env:NAME"}, + {Command{Context: Secrets, Command: "x", Value: "stdin", Output: Terminal}, "terminal's input"}, + {Command{Context: Global, Command: "{{.Nope"}, "unclosed"}, + {Command{Context: Global, Command: "x", Output: "loud"}, "output"}, + {Command{Context: Global, Command: "echo {{.Nmae}}"}, "Nmae"}, + {Command{Context: Global, Command: "echo {{.Prompt.ns}}"}, "ns"}, + } { + if err := c.cmd.Validate(); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%+v: %v, want %q", c.cmd, err, c.want) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d770861 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,224 @@ +// Package config loads the optional YAML config file. +package config + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "go.yaml.in/yaml/v3" + + "git.kebcloud.no/keb/lazysecrets/internal/commands" +) + +type Config struct { + Icons *bool `yaml:"icons"` + ClipboardClear *time.Duration `yaml:"clipboardClear"` + // RevealTimeout hides a revealed value after this long; 0s never. + // Defaults to 60s. + RevealTimeout *time.Duration `yaml:"revealTimeout"` + // ExpiryWarningDays marks secrets, keys and certificates expiring + // within this many days; 0 marks only expired ones. Defaults to 30. + ExpiryWarningDays *int `yaml:"expiryWarningDays"` + // HideOnBlur hides a revealed value when the terminal loses focus. + // Defaults to true. + HideOnBlur *bool `yaml:"hideOnBlur"` + Timeout time.Duration `yaml:"timeout"` + // LoadDelay is how long the cursor must rest on a row before its + // contents load; 0s loads at once. Defaults to 150ms. + LoadDelay *time.Duration `yaml:"loadDelay"` + // SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it. + SourcesView string `yaml:"sourcesView"` + // History counts uses of items for the Recent list; defaults to true. + History *bool `yaml:"history"` + // UpdateCheck looks for a newer release once a day, in the + // background; defaults to true. U and lazysecrets update work either + // way. + UpdateCheck *bool `yaml:"updateCheck"` + // CollapseGroups starts the Sources pane with its groups folded; + // defaults to true. + CollapseGroups *bool `yaml:"collapseGroups"` + Providers Providers `yaml:"providers"` + // LinkHandler sets how lazysecrets:// links open; see linkhandler. + LinkHandler LinkHandler `yaml:"linkHandler"` + // CustomCommands run shell commands on the selected item; see + // package commands. + CustomCommands []commands.Command `yaml:"customCommands"` + // Keybinding sets keys per context and action, replacing the + // defaults of each action given; see keys.Map.Apply. + Keybinding map[string]map[string][]string `yaml:"keybinding"` + // Theme maps roles to colours, like lazygit's gui.theme; see + // styles.Roles. + Theme map[string][]string `yaml:"theme"` +} + +type LinkHandler struct { + // Terminal to open links in, overriding the one handler install + // detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty, + // gnome-terminal, konsole or foot. + Terminal string `yaml:"terminal"` + // Command opens any other terminal: its arguments, with {cmd} where + // the lazysecrets command goes, e.g. [foot, "{cmd}"]. + Command []string `yaml:"command"` + // Reuse hands links to a running lazysecrets, the one last used that + // can be brought into view, before opening a new window. Defaults to + // true. + Reuse *bool `yaml:"reuse"` + // Prompt offers to set up the link handler at startup when links + // don't open lazysecrets. Defaults to true. + Prompt *bool `yaml:"prompt"` +} + +type Providers struct { + Azure Azure `yaml:"azure"` + Databricks Databricks `yaml:"databricks"` + Repos Repos `yaml:"repos"` +} + +// Repos are git repositories' secret files (SOPS, .env). Inside a repo, +// that repo is the only one shown; elsewhere, the ones in Paths and under +// Roots. +type Repos struct { + // Enabled defaults to true. + Enabled *bool `yaml:"enabled"` + Paths []string `yaml:"paths"` + // Roots are folders of repos. Defaults to ~/git; an empty list + // turns it off. + Roots *[]string `yaml:"roots"` + // Include and Exclude are globs relative to a repo's root (** for any + // depth), for repos without their own .lazysecrets.yaml. Without them, + // a repo's .sops.yaml creation_rules say where to look. + Include []string `yaml:"include"` + Exclude []string `yaml:"exclude"` +} + +type Azure struct { + // Enabled defaults to true. + Enabled *bool `yaml:"enabled"` + // HideSubscriptions lists subscriptions to leave out, by name or ID. + HideSubscriptions []string `yaml:"hideSubscriptions"` + // Vaults are shown under "Pinned vaults", for vaults that can't be + // discovered (e.g. data-plane access only). + Vaults []string `yaml:"vaults"` +} + +type Databricks struct { + // Enabled defaults to true. + Enabled *bool `yaml:"enabled"` + HideProfiles []string `yaml:"hideProfiles"` +} + +// Dir is $XDG_CONFIG_HOME/lazysecrets, defaulting to ~/.config/lazysecrets +// on every platform, like most terminal tools. +func Dir() (string, error) { + return xdgDir("XDG_CONFIG_HOME", ".config") +} + +// StateDir is $XDG_STATE_HOME/lazysecrets, defaulting to +// ~/.local/state/lazysecrets. It holds data the app writes, like favourites. +func StateDir() (string, error) { + return xdgDir("XDG_STATE_HOME", filepath.Join(".local", "state")) +} + +func xdgDir(env, fallback string) (string, error) { + base := os.Getenv(env) + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, fallback) + } + return filepath.Join(base, "lazysecrets"), nil +} + +// Load reads path. A missing file is an empty config. Unknown keys are an +// error, so typos don't silently do nothing. +func Load(path string) (Config, error) { + return LoadAll([]string{path}) +} + +// LoadAll reads several config files in order, each on top of the ones +// before, like lazygit's LG_CONFIG_FILE: a later file overrides the +// settings it sets, and theme roles merge one by one. That lets an editor +// plugin add a generated theme file to the user's own config. Missing +// files are skipped. +func LoadAll(paths []string) (Config, error) { + var c Config + keybinding := map[string]map[string][]string{} + for _, path := range paths { + // Keybindings layer per action: a later file setting one action + // in a context keeps the others set before it. + c.Keybinding = nil + if err := decodeInto(&c, path); err != nil { + return c, err + } + for ctx, actions := range c.Keybinding { + if keybinding[ctx] == nil { + keybinding[ctx] = map[string][]string{} + } + for name, keys := range actions { + keybinding[ctx][name] = keys + } + } + } + c.Keybinding = keybinding + return c, c.validate(strings.Join(paths, ", ")) +} + +func decodeInto(c *Config, path string) error { + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + dec := yaml.NewDecoder(bytes.NewReader(b)) + dec.KnownFields(true) + if err := dec.Decode(c); err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("%s: %w", path, err) + } + return nil +} + +func (c Config) validate(path string) error { + if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" { + return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v) + } + for _, cmd := range c.CustomCommands { + if err := cmd.Validate(); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + } + if d := c.ExpiryWarningDays; d != nil && *d < 0 { + return fmt.Errorf("%s: expiryWarningDays: %d is negative", path, *d) + } + if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) { + return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d) + } + for i, v := range c.Providers.Azure.Vaults { + if !strings.HasPrefix(v, "https://") { + return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v) + } + } + return nil +} + +// On reports whether an optional enabled flag is unset or true. +func On(enabled *bool) bool { return enabled == nil || *enabled } + +// Hidden reports whether name or id appears in list, ignoring case. +func Hidden(list []string, name, id string) bool { + for _, h := range list { + if strings.EqualFold(h, name) || strings.EqualFold(h, id) { + return true + } + } + return false +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..d5516a5 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,143 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func write(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoad(t *testing.T) { + c, err := Load(write(t, ` +icons: false +sourcesView: flat +clipboardClear: 45s +timeout: 1m +providers: + azure: + hideSubscriptions: [platform-mgmt] + vaults: + - https://kv-foo.vault.azure.net/ + databricks: + enabled: false + hideProfiles: [test] +`)) + if err != nil { + t.Fatal(err) + } + if c.Icons == nil || *c.Icons || c.ClipboardClear == nil || *c.ClipboardClear != 45*time.Second || + c.Timeout != time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" { + t.Errorf("config = %+v", c) + } + if c.SourcesView != "flat" { + t.Errorf("sourcesView = %q", c.SourcesView) + } + if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) { + t.Error("enabled flags wrong") + } + az := c.Providers.Azure + if !Hidden(az.HideSubscriptions, "PLATFORM-MGMT", "id") || Hidden(az.HideSubscriptions, "other", "id") { + t.Error("Hidden matching wrong") + } +} + +func TestLoadMissingAndEmpty(t *testing.T) { + for name, path := range map[string]string{ + "missing": filepath.Join(t.TempDir(), "nope.yaml"), + "empty": write(t, ""), + } { + c, err := Load(path) + if err != nil || c.Icons != nil || c.ClipboardClear != nil { + t.Errorf("%s: %+v, %v", name, c, err) + } + } +} + +func TestLoadRejectsMistakes(t *testing.T) { + for name, content := range map[string]string{ + "typo": "icon: true\n", + "bad vault": "providers:\n azure:\n vaults: [kv-foo]\n", + "old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n", + "duration": "timeout: soon\n", + "view": "sourcesView: tree\n", + } { + if _, err := Load(write(t, content)); err == nil { + t.Errorf("%s: no error", name) + } else if !strings.Contains(err.Error(), "config.yaml") { + t.Errorf("%s: error doesn't name the file: %v", name, err) + } + } +} + +func TestLoadAllLayers(t *testing.T) { + user := write(t, ` +icons: false +theme: + azureColor: [blue] + errorColor: [red, bold] +providers: + azure: + hideSubscriptions: [a] +`) + theme := write(t, ` +theme: + azureColor: ["#82aaff"] + activeBorderColor: ["#ff966c", bold] +`) + c, err := LoadAll([]string{user, filepath.Join(t.TempDir(), "missing.yaml"), theme}) + if err != nil { + t.Fatal(err) + } + if c.Icons == nil || *c.Icons || len(c.Providers.Azure.HideSubscriptions) != 1 { + t.Errorf("user settings lost: %+v", c) + } + want := map[string][]string{ + "azureColor": {"#82aaff"}, // overridden by the later file + "errorColor": {"red", "bold"}, // kept from the user's file + "activeBorderColor": {"#ff966c", "bold"}, + } + for role, v := range want { + if strings.Join(c.Theme[role], ",") != strings.Join(v, ",") { + t.Errorf("theme %s = %v, want %v", role, c.Theme[role], v) + } + } +} + +func TestLoadDelay(t *testing.T) { + c, err := Load(write(t, "loadDelay: 300ms\n")) + if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond { + t.Errorf("loadDelay = %v, %v", c.LoadDelay, err) + } + if _, err := Load(write(t, "loadDelay: 1m\n")); err == nil { + t.Error("a minute's delay accepted") + } +} + +func TestKeybindingLayers(t *testing.T) { + a := write(t, "keybinding:\n lists:\n star: [x]\n note: [N]\n") + b := write(t, "keybinding:\n lists:\n note: [m]\n filter:\n star: [ctrl+x]\n") + c, err := LoadAll([]string{a, b}) + if err != nil { + t.Fatal(err) + } + kb := c.Keybinding + if got := kb["lists"]["star"]; len(got) != 1 || got[0] != "x" { + t.Errorf("lists.star from the first file lost: %v", kb["lists"]) + } + if got := kb["lists"]["note"]; len(got) != 1 || got[0] != "m" { + t.Errorf("lists.note = %v, want the later file's", got) + } + if got := kb["filter"]["star"]; len(got) != 1 || got[0] != "ctrl+x" { + t.Errorf("filter.star = %v", got) + } +} diff --git a/internal/instance/instance.go b/internal/instance/instance.go new file mode 100644 index 0000000..f15375f --- /dev/null +++ b/internal/instance/instance.go @@ -0,0 +1,222 @@ +// Package instance lets a lazysecrets:// link go to a lazysecrets that is +// already running, rather than a new window. +// +// Each running lazysecrets listens on a Unix socket of its own in a +// directory only the user can read (Unix sockets work the same on macOS, +// Linux and Windows 10 and later). The link handler asks each one when it +// was last used and whether it can be brought into view, and hands the +// link to the best one, which shows it and comes to the front. +package instance + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// Dir holds the sockets: $XDG_RUNTIME_DIR/lazysecrets if set, else +// run/ under the state directory. +func Dir(stateDir string) string { + if d := os.Getenv("XDG_RUNTIME_DIR"); d != "" { + return filepath.Join(d, "lazysecrets") + } + return filepath.Join(stateDir, "run") +} + +// Status is what an instance tells the link handler about itself. +type Status struct { + PID int `json:"pid"` + LastActive time.Time `json:"lastActive"` // last key press or focus + // Reach is how readily it can be shown: 2 in view (outside tmux, or + // in a tmux session a client shows), 1 in a tmux session that a tmux + // client can be switched to, 0 not at all. + Reach int `json:"reach"` + // App is the terminal app it would show in (macOS), preferring the + // app the link was clicked in. + App string `json:"app,omitempty"` +} + +type request struct { + Op string `json:"op"` // "status" or "open" + Link string `json:"link,omitempty"` + Front string `json:"front,omitempty"` // app the link was clicked in +} + +type response struct { + Status + Err string `json:"err,omitempty"` +} + +// Server is the running lazysecrets' end. +type Server struct { + ln net.Listener + path string + open func(link, front string) error // shows the link and comes to the front + + mu sync.Mutex + active time.Time +} + +// Listen starts listening in dir. open is called, from another goroutine, +// with each link handed over and the app it was clicked in; it should +// return quickly, as the link handler waits for it, and come to the front +// (Raise) afterwards. +func Listen(dir string, open func(link, front string) error) (*Server, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + _ = os.Chmod(dir, 0o700) // a pre-existing directory too + path := filepath.Join(dir, strconv.Itoa(os.Getpid())+".sock") + _ = os.Remove(path) // a crashed process with this PID + ln, err := net.Listen("unix", path) + if err != nil { + return nil, err + } + s := &Server{ln: ln, path: path, open: open, active: time.Now()} + go s.serve() + return s, nil +} + +// Touch records that the user just used this instance. +func (s *Server) Touch() { + s.mu.Lock() + s.active = time.Now() + s.mu.Unlock() +} + +// Close stops listening and removes the socket. +func (s *Server) Close() error { + err := s.ln.Close() + _ = os.Remove(s.path) + return err +} + +func (s *Server) serve() { + for { + c, err := s.ln.Accept() + if err != nil { + return + } + go s.handle(c) + } +} + +func (s *Server) handle(c net.Conn) { + defer c.Close() + _ = c.SetDeadline(time.Now().Add(5 * time.Second)) + var req request + if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil { + return + } + var resp response + switch req.Op { + case "status": + _, app, reach := place(req.Front) + s.mu.Lock() + resp.Status = Status{PID: os.Getpid(), LastActive: s.active, Reach: reach, App: app} + s.mu.Unlock() + case "open": + if err := s.open(req.Link, req.Front); err != nil { + resp.Err = err.Error() + } + default: + resp.Err = fmt.Sprintf("unknown request %q", req.Op) + } + _ = json.NewEncoder(c).Encode(resp) +} + +// Found is a running instance. +type Found struct { + Status + path string +} + +// Running asks every instance in dir for its status for a link clicked in +// front, removing sockets no one answers on (left by a crash). +func Running(ctx context.Context, dir, front string) []Found { + entries, _ := os.ReadDir(dir) + var ( + mu sync.Mutex + out []Found + wg sync.WaitGroup + ) + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ".sock") { + continue + } + path := filepath.Join(dir, e.Name()) + wg.Add(1) + go func() { + defer wg.Done() + var resp response + err := call(ctx, path, request{Op: "status", Front: front}, &resp) + if errors.Is(err, errGone) { + _ = os.Remove(path) + return + } + if err != nil || resp.Err != "" { + return + } + mu.Lock() + out = append(out, Found{resp.Status, path}) + mu.Unlock() + }() + } + wg.Wait() + return out +} + +// Best is the instance to hand a link clicked in front to: the most +// recently used of those most readily shown. A link clicked in a terminal +// goes only to one showing in that terminal. ok is false if there's none. +func Best(found []Found, front string, terminal bool) (Found, bool) { + var best Found + for _, f := range found { + if terminal && f.App != front { + continue + } + if f.Reach > best.Reach || (f.Reach == best.Reach && f.LastActive.After(best.LastActive)) { + best = f + } + } + return best, best.Reach > 0 +} + +// Open hands link, clicked in front, to f. +func Open(ctx context.Context, f Found, link, front string) error { + var resp response + if err := call(ctx, f.path, request{Op: "open", Link: link, Front: front}, &resp); err != nil { + return err + } + if resp.Err != "" { + return errors.New(resp.Err) + } + return nil +} + +var errGone = errors.New("no instance listening") + +func call(ctx context.Context, path string, req request, resp *response) error { + var d net.Dialer + c, err := d.DialContext(ctx, "unix", path) + if err != nil { + return fmt.Errorf("%w: %v", errGone, err) + } + defer c.Close() + if dl, ok := ctx.Deadline(); ok { + _ = c.SetDeadline(dl) + } + if err := json.NewEncoder(c).Encode(req); err != nil { + return err + } + return json.NewDecoder(c).Decode(resp) +} diff --git a/internal/instance/instance_test.go b/internal/instance/instance_test.go new file mode 100644 index 0000000..9f3cd6d --- /dev/null +++ b/internal/instance/instance_test.go @@ -0,0 +1,99 @@ +package instance + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +// shortDir is a temporary directory with a path short enough for a +// socket (macOS allows 104 bytes). +func shortDir(t *testing.T) string { + d, err := os.MkdirTemp("", "ls") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(d) }) + return d +} + +func TestHandOver(t *testing.T) { + t.Setenv("TMUX", "") + dir := shortDir(t) + got := make(chan string, 1) + s, err := Listen(dir, func(link, front string) error { got <- link; return nil }) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if fi, _ := os.Stat(dir); fi.Mode().Perm() != 0o700 { + t.Errorf("dir mode %v", fi.Mode().Perm()) + } + + // A socket left by a crashed instance. + stale := filepath.Join(dir, "999999.sock") + ln, err := net.Listen("unix", stale) + if err != nil { + t.Fatal(err) + } + ln.(*net.UnixListener).SetUnlinkOnClose(false) + ln.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + found := Running(ctx, dir, "") + if len(found) != 1 || found[0].PID != os.Getpid() || found[0].Reach != 2 { + t.Fatalf("found %+v", found) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Error("stale socket left behind") + } + best, ok := Best(found, "", false) + if !ok { + t.Fatal("no instance to hand over to") + } + if err := Open(ctx, best, "lazysecrets://azure/kv.vault.azure.net", ""); err != nil { + t.Fatal(err) + } + if l := <-got; l != "lazysecrets://azure/kv.vault.azure.net" { + t.Errorf("got %q", l) + } +} + +func TestBest(t *testing.T) { + now := time.Now() + f := func(reach int, ago time.Duration) Found { + return Found{Status: Status{Reach: reach, LastActive: now.Add(-ago), PID: int(ago)}} + } + // In view beats more recently used but needing a tmux switch. + if b, _ := Best([]Found{f(1, time.Second), f(2, time.Hour), f(2, time.Minute)}, "", false); b.PID != int(time.Minute) { + t.Errorf("best = %+v", b) + } + if _, ok := Best([]Found{f(0, 0)}, "", false); ok { + t.Error("handed over to an instance that can't be shown") + } + if _, ok := Best(nil, "", false); ok { + t.Error("best of none") + } + + // Clicked in a terminal: only an instance showing there will do, + // however recently another was used. + kitty, dropdown := "/Applications/kitty.app", "/Applications/kitty.app/Contents/kitty-quick-access.app" + inDropdown := f(2, time.Second) + inDropdown.App = dropdown + inKitty := f(1, time.Hour) + inKitty.App = kitty + if b, ok := Best([]Found{inDropdown, inKitty}, kitty, true); !ok || b.App != kitty { + t.Errorf("clicked in kitty: %+v %v", b, ok) + } + if _, ok := Best([]Found{inDropdown}, kitty, true); ok { + t.Error("clicked in kitty, went to the dropdown") + } + // Clicked in Slack: the one used last, wherever. + if b, _ := Best([]Found{inDropdown, inKitty}, "/Applications/Slack.app", false); b.App != dropdown { + t.Errorf("clicked in Slack: %+v", b) + } +} diff --git a/internal/instance/tmux.go b/internal/instance/tmux.go new file mode 100644 index 0000000..c53e461 --- /dev/null +++ b/internal/instance/tmux.go @@ -0,0 +1,123 @@ +package instance + +import ( + "os" + "os/exec" + "slices" + "strconv" + "strings" + + "git.kebcloud.no/keb/lazysecrets/internal/linkhandler" +) + +// Where an instance shows depends on its terminal app (on macOS): for an +// instance in tmux, the app of a tmux client showing its session, or one +// that could be switched to it. front is the app the link was clicked in; +// clients in it are preferred, so a link clicked in a terminal opens +// there. + +// tmux runs tmux on the server this instance runs in. +func tmux(args ...string) (string, error) { + out, err := exec.Command("tmux", args...).Output() + return strings.TrimSpace(string(out)), err +} + +func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" } + +type client struct { + name, session, activity string // activity: Unix seconds + pid int + app string +} + +// clients lists the tmux server's clients, the one last typed in first. +func clients() []client { + out, err := tmux("list-clients", "-F", "#{client_activity} #{client_pid} #{client_name} #{session_name}") + if err != nil { + return nil + } + var cs []client + for _, l := range strings.Split(out, "\n") { + f := strings.SplitN(l, " ", 4) + if len(f) < 4 { + continue + } + pid, err := strconv.Atoi(f[1]) + if err != nil { + continue + } + cs = append(cs, client{name: f[2], session: f[3], activity: f[0], pid: pid, app: linkhandler.HostApp(pid)}) + } + // Activity has the same width for centuries: strings compare like + // numbers. + slices.SortStableFunc(cs, func(a, b client) int { return strings.Compare(b.activity, a.activity) }) + return cs +} + +// place is where this instance would show: the tmux client (zero outside +// tmux), the app, and reach: 2 already on screen there (outside tmux, or +// a client on its session), 1 after switching a client, 0 nowhere. +func place(front string) (client, string, int) { + if !inTmux() { + return client{}, linkhandler.HostApp(os.Getpid()), 2 + } + session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}") + if err != nil { + return client{}, "", 0 + } + cs := clients() + // Best first: in the front app on its session, in the front app, + // on its session, any. + rank := func(c client) int { + r := 0 + if front != "" && c.app == front { + r += 2 + } + if c.session == session { + r++ + } + return r + } + var best client + found := false + for _, c := range cs { + if !found || rank(c) > rank(best) { + best, found = c, true + } + } + if !found { + return client{}, "", 0 + } + if best.session == session { + return best, best.app, 2 + } + return best, best.app, 1 +} + +// Raise brings this instance into view for a link clicked in front: in +// tmux its window and pane, switching a client to its session if needed; +// then its app to the front, showing a hidden dropdown terminal. +func Raise(front string) { + c, app, reach := place(front) + if reach == 0 { + return + } + if inTmux() { + pane := os.Getenv("TMUX_PANE") + _, _ = tmux("select-window", "-t", pane) + _, _ = tmux("select-pane", "-t", pane) + if reach == 1 { + _, _ = tmux("switch-client", "-c", c.name, "-t", pane) + } + } + switch { + case app == "" || app == front: + // already in front (or unknown) + case linkhandler.IsDropdown(app): + linkhandler.ShowDropdown(app) // it isn't front, so hidden: this shows it + default: + // The bundle, not the name: "kitty" may pick an instance without + // windows, which then opens a new one. + linkhandler.Activate(app) + } +} diff --git a/internal/linkhandler/apps.go b/internal/linkhandler/apps.go new file mode 100644 index 0000000..2d0194b --- /dev/null +++ b/internal/linkhandler/apps.go @@ -0,0 +1,100 @@ +package linkhandler + +import ( + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" +) + +// macOS apps: where a link was clicked (the frontmost app; the link +// handler runs in the background, so it doesn't change it), and which app +// a process's terminal belongs to. Elsewhere these know nothing. + +// App is a running macOS app: its bundle path and process ID. +type App struct { + Bundle string + PID int +} + +// FrontApp is the frontmost app. +func FrontApp() App { + if runtime.GOOS != "darwin" { + return App{} + } + asn, err := exec.Command("lsappinfo", "front").Output() + if err != nil { + return App{} + } + out, err := exec.Command("lsappinfo", "info", "-only", "pid", "-only", "bundlepath", strings.TrimSpace(string(asn))).Output() + if err != nil { + return App{} + } + var a App + for _, l := range strings.Split(string(out), "\n") { + k, v, _ := strings.Cut(strings.TrimSpace(l), "=") + switch strings.Trim(k, `"`) { + case "pid": + a.PID, _ = strconv.Atoi(v) + case "LSBundlePath": + a.Bundle = strings.Trim(v, `"`) + } + } + return a +} + +// HostApp is the app bundle among pid's ancestors (the terminal it runs +// in), "" if none. +func HostApp(pid int) string { + if runtime.GOOS != "darwin" { + return "" + } + for range 32 { + out, err := exec.Command("ps", "-o", "ppid=,comm=", "-p", strconv.Itoa(pid)).Output() + if err != nil { + return "" + } + ppid, comm, _ := strings.Cut(strings.TrimSpace(string(out)), " ") + // The innermost bundle: kitty's quick access terminal is one + // inside kitty.app. + if i := strings.LastIndex(comm, ".app/Contents/MacOS/"); i >= 0 { + return comm[:i+len(".app")] + } + if pid, err = strconv.Atoi(ppid); err != nil || pid <= 1 { + return "" + } + } + return "" +} + +// IsTerminal reports whether bundle is a terminal app. +func IsTerminal(bundle string) bool { + name := strings.ToLower(filepath.Base(bundle)) + for _, t := range []string{"kitty", "ghostty", "wezterm", "iterm", "terminal.app", "alacritty", "warp"} { + if strings.Contains(name, t) { + return true + } + } + return false +} + +// IsDropdown reports whether bundle is a terminal shown only by a hotkey +// (kitty's quick access terminal): activating it doesn't show it. +func IsDropdown(bundle string) bool { return strings.Contains(bundle, "quick-access") } + +// ShowDropdown shows a hidden dropdown terminal (it toggles: call it only +// when the dropdown isn't frontmost). +func ShowDropdown(bundle string) { + if i := strings.Index(bundle, "kitty.app"); i >= 0 { + kitten := bundle[:i+len("kitty.app")] + "/Contents/MacOS/kitten" + _ = exec.Command(kitten, "quick-access-terminal").Start() + } +} + +// Activate brings the app to the front. +func Activate(bundle string) { + if runtime.GOOS == "darwin" && bundle != "" { + _ = exec.Command("open", "-a", bundle).Run() + } +} diff --git a/internal/linkhandler/install_darwin.go b/internal/linkhandler/install_darwin.go new file mode 100644 index 0000000..030bd0f --- /dev/null +++ b/internal/linkhandler/install_darwin.go @@ -0,0 +1,141 @@ +package linkhandler + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// On macOS a URL scheme belongs to an app bundle, and links arrive as an +// Apple Event rather than an argument. A small AppleScript applet, +// registered for the scheme, passes them on to "lazysecrets launch". + +const bundleID = "no.kebcloud.lazysecrets.links" + +// AppPath is where the applet is installed. +func AppPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Applications", "Lazysecrets Links.app"), nil +} + +const lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" + +// Install registers launch (lazysecrets launch and its flags, the link +// appended) as the handler for lazysecrets:// links. env is set for it, +// since apps start with a minimal environment (no Homebrew on PATH, so no +// az). +func Install(launch []string, env []string) (string, error) { + app, err := AppPath() + if err != nil { + return "", err + } + if err := buildApp(app, launch, env); err != nil { + return "", err + } + if err := run(lsregister, "-f", app); err != nil { + return "", err + } + return app, nil +} + +// buildApp writes the applet to app. +func buildApp(app string, launch []string, env []string) error { + cmd := shellJoin(append(append([]string{"/usr/bin/env"}, env...), launch...)) + script := fmt.Sprintf(`on open location theURL + do shell script %s & " " & quoted form of theURL & " >/dev/null 2>&1 &" +end open location + +on run + display dialog "Lazysecrets Links opens lazysecrets:// links in lazysecrets. It has nothing to show on its own." buttons {"OK"} default button 1 with title "Lazysecrets Links" +end run +`, appleString(cmd)) + + if err := os.MkdirAll(filepath.Dir(app), 0o755); err != nil { + return err + } + _ = os.RemoveAll(app) + f, err := os.CreateTemp("", "lazysecrets-links-*.applescript") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if _, err := f.WriteString(script); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + src := f.Name() + plist := filepath.Join(app, "Contents", "Info.plist") + steps := [][]string{ + {"osacompile", "-o", app, src}, + {"plutil", "-replace", "CFBundleIdentifier", "-string", bundleID, plist}, + {"plutil", "-replace", "LSUIElement", "-bool", "true", plist}, // no Dock icon + {"plutil", "-replace", exeKey, "-string", launch[0], plist}, // for Check + {"plutil", "-replace", "CFBundleURLTypes", "-json", + `[{"CFBundleURLName":"lazysecrets link","CFBundleURLSchemes":["lazysecrets"]}]`, plist}, + // Editing Info.plist breaks osacompile's signature; sign again, + // ad hoc, so macOS runs it. + {"codesign", "--force", "--sign", "-", app}, + } + for _, s := range steps { + if err := run(s...); err != nil { + return err + } + } + return nil +} + +func run(argv ...string) error { + if out, err := exec.Command(argv[0], argv[1:]...).CombinedOutput(); err != nil { + return fmt.Errorf("%s: %v: %s", filepath.Base(argv[0]), err, strings.TrimSpace(string(out))) + } + return nil +} + +// exeKey records, in the applet's Info.plist, the lazysecrets it runs. +const exeKey = "LazysecretsExecutable" + +// Check reports why lazysecrets:// links don't open lazysecrets, or "" if +// they do (as far as can be told without opening one). +func Check() string { + app, err := AppPath() + if err != nil { + return "" + } + if _, err := os.Stat(app); err != nil { + return "lazysecrets:// links don't open lazysecrets yet" + } + out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output() + if err != nil { + return "" // installed before the key was recorded: assume it works + } + if exe := strings.TrimSpace(string(out)); !exists(exe) { + return "lazysecrets:// links run " + exe + ", which no longer exists" + } + return "" +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// Uninstall removes the applet and its registration. +func Uninstall() (string, error) { + app, err := AppPath() + if err != nil { + return "", err + } + if _, err := os.Stat(app); os.IsNotExist(err) { + return "", fmt.Errorf("not installed (%s doesn't exist)", app) + } + _ = exec.Command(lsregister, "-u", app).Run() + return app, os.RemoveAll(app) +} diff --git a/internal/linkhandler/install_darwin_test.go b/internal/linkhandler/install_darwin_test.go new file mode 100644 index 0000000..ea301d9 --- /dev/null +++ b/internal/linkhandler/install_darwin_test.go @@ -0,0 +1,53 @@ +package linkhandler + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// The applet builds, claims the scheme and passes its signature check. It +// isn't registered, so the system's handler is untouched. +func TestBuildApp(t *testing.T) { + app := filepath.Join(t.TempDir(), "Lazysecrets Links.app") + if err := buildApp(app, []string{"/usr/local/bin/lazysecrets", "launch", "--terminal", "kitty"}, []string{"PATH=/opt/homebrew/bin:/usr/bin"}); err != nil { + t.Fatal(err) + } + plist := filepath.Join(app, "Contents", "Info.plist") + out, err := exec.Command("plutil", "-extract", "CFBundleURLTypes.0.CFBundleURLSchemes.0", "raw", plist).Output() + if err != nil || strings.TrimSpace(string(out)) != "lazysecrets" { + t.Errorf("scheme = %q, %v", out, err) + } + if out, err := exec.Command("codesign", "--verify", "--strict", app).CombinedOutput(); err != nil { + t.Errorf("signature: %v: %s", err, out) + } + src, err := exec.Command("osadecompile", filepath.Join(app, "Contents", "Resources", "Scripts", "main.scpt")).Output() + if err != nil || !strings.Contains(string(src), `'/usr/bin/env' 'PATH=/opt/homebrew/bin:/usr/bin' '/usr/local/bin/lazysecrets' 'launch'`) { + t.Errorf("script:\n%s %v", src, err) + } +} + +// Check, against an applet in a temporary home (never registered). +func TestCheck(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if r := Check(); !strings.Contains(r, "don't open lazysecrets yet") { + t.Errorf("nothing installed: %q", r) + } + app, _ := AppPath() + exe := filepath.Join(t.TempDir(), "lazysecrets") + if err := os.WriteFile(exe, nil, 0o755); err != nil { + t.Fatal(err) + } + if err := buildApp(app, []string{exe, "launch"}, nil); err != nil { + t.Fatal(err) + } + if r := Check(); r != "" { + t.Errorf("installed: %q", r) + } + os.Remove(exe) + if r := Check(); !strings.Contains(r, "no longer exists") { + t.Errorf("binary gone: %q", r) + } +} diff --git a/internal/linkhandler/install_linux.go b/internal/linkhandler/install_linux.go new file mode 100644 index 0000000..f9c895e --- /dev/null +++ b/internal/linkhandler/install_linux.go @@ -0,0 +1,108 @@ +package linkhandler + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// On Linux a desktop entry claims the x-scheme-handler/lazysecrets type, +// and xdg-open passes links to it as %u. + +const desktopFile = "lazysecrets-links.desktop" + +// AppPath is where the desktop entry is installed. +func AppPath() (string, error) { + dir := os.Getenv("XDG_DATA_HOME") + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir = filepath.Join(home, ".local", "share") + } + return filepath.Join(dir, "applications", desktopFile), nil +} + +// Install registers launch (lazysecrets launch and its flags, the link +// appended) as the handler for lazysecrets:// links, with env set. +func Install(launch []string, env []string) (string, error) { + path, err := AppPath() + if err != nil { + return "", err + } + argv := append(append([]string{"env"}, env...), launch...) + entry := fmt.Sprintf(`[Desktop Entry] +Type=Application +Name=Lazysecrets Links +Comment=Opens lazysecrets:// links in lazysecrets +Exec=%s %%u +MimeType=x-scheme-handler/lazysecrets; +NoDisplay=true +Terminal=false +%s=%s +`, desktopExec(argv), exeKey, launch[0]) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(path, []byte(entry), 0o644); err != nil { + return "", err + } + if out, err := exec.Command("xdg-mime", "default", desktopFile, "x-scheme-handler/lazysecrets").CombinedOutput(); err != nil { + return "", fmt.Errorf("xdg-mime: %v: %s", err, strings.TrimSpace(string(out))) + } + _ = exec.Command("update-desktop-database", filepath.Dir(path)).Run() // optional + return path, nil +} + +// desktopExec quotes args for a desktop entry's Exec key. +func desktopExec(args []string) string { + q := make([]string, len(args)) + for i, a := range args { + a = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", "$", `\$`, "%", "%%").Replace(a) + q[i] = `"` + a + `"` + } + return strings.Join(q, " ") +} + +// exeKey records, in the desktop entry, the lazysecrets it runs. +const exeKey = "X-Lazysecrets-Executable" + +// Check reports why lazysecrets:// links don't open lazysecrets, or "" if +// they do (as far as can be told without opening one). +func Check() string { + path, err := AppPath() + if err != nil { + return "" + } + b, err := os.ReadFile(path) + if err != nil { + return "lazysecrets:// links don't open lazysecrets yet" + } + if out, err := exec.Command("xdg-mime", "query", "default", "x-scheme-handler/lazysecrets").Output(); err == nil && + strings.TrimSpace(string(out)) != desktopFile { + return "lazysecrets:// links open " + strings.TrimSpace(string(out)) + ", not lazysecrets" + } + for _, l := range strings.Split(string(b), "\n") { + if exe, ok := strings.CutPrefix(l, exeKey+"="); ok { + if _, err := os.Stat(exe); err != nil { + return "lazysecrets:// links run " + exe + ", which no longer exists" + } + } + } + return "" +} + +// Uninstall removes the desktop entry. +func Uninstall() (string, error) { + path, err := AppPath() + if err != nil { + return "", err + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return "", fmt.Errorf("not installed (%s doesn't exist)", path) + } + return path, os.Remove(path) +} diff --git a/internal/linkhandler/install_other.go b/internal/linkhandler/install_other.go new file mode 100644 index 0000000..7a3c4be --- /dev/null +++ b/internal/linkhandler/install_other.go @@ -0,0 +1,15 @@ +//go:build !darwin && !linux + +package linkhandler + +import ( + "errors" + "runtime" +) + +var errUnsupported = errors.New("registering lazysecrets:// links isn't supported on " + runtime.GOOS + " yet") + +func AppPath() (string, error) { return "", errUnsupported } +func Install(launch []string, env []string) (string, error) { return "", errUnsupported } +func Check() string { return "" } // nothing to set up +func Uninstall() (string, error) { return "", errUnsupported } diff --git a/internal/linkhandler/terminal.go b/internal/linkhandler/terminal.go new file mode 100644 index 0000000..536de7b --- /dev/null +++ b/internal/linkhandler/terminal.go @@ -0,0 +1,184 @@ +// Package linkhandler makes lazysecrets:// links open lazysecrets: it +// registers the scheme with the operating system, and launches a new +// terminal window running "lazysecrets open " when a link is opened. +// +// The terminal is the one install ran in, detected from its environment, +// unless the config names another or gives a command of its own. +package linkhandler + +import ( + "fmt" + "os" + "os/exec" + "slices" + "strings" +) + +// Terminals lists the terminals launch knows, by name. +var Terminals = []string{"kitty", "ghostty", "wezterm", "iterm2", "terminal", "alacritty", "gnome-terminal", "konsole", "foot"} + +// Detect names the terminal from its environment (getenv), or "" if it +// isn't one launch knows. Terminal-specific variables come first: inside +// tmux, TERM_PROGRAM is "tmux" but the terminal's own variables are +// usually inherited. +func Detect(getenv func(string) string) string { + switch { + case getenv("KITTY_WINDOW_ID") != "" || getenv("TERM") == "xterm-kitty": + return "kitty" + case getenv("GHOSTTY_RESOURCES_DIR") != "" || getenv("TERM") == "xterm-ghostty": + return "ghostty" + case getenv("WEZTERM_EXECUTABLE") != "" || getenv("WEZTERM_PANE") != "": + return "wezterm" + case getenv("LC_TERMINAL") == "iTerm2" || getenv("ITERM_SESSION_ID") != "": + return "iterm2" + case getenv("ALACRITTY_WINDOW_ID") != "" || getenv("ALACRITTY_SOCKET") != "": + return "alacritty" + case getenv("KONSOLE_VERSION") != "": + return "konsole" + case getenv("GNOME_TERMINAL_SCREEN") != "": + return "gnome-terminal" + case getenv("TERM") == "foot": + return "foot" + } + switch getenv("TERM_PROGRAM") { + case "iTerm.app": + return "iterm2" + case "Apple_Terminal": + return "terminal" + case "WezTerm": + return "wezterm" + case "ghostty": + return "ghostty" + } + return "" +} + +// Placeholder in a custom command stands for the command to run in it. +const Placeholder = "{cmd}" + +// Command is the command that opens a new window of terminal running +// inner, for the operating system goos. custom, if set, is used instead, +// with Placeholder replaced by inner's arguments. +func Command(goos, terminal string, custom, inner []string) ([]string, error) { + if len(custom) > 0 { + if !slices.Contains(custom, Placeholder) { + return nil, fmt.Errorf("linkHandler.command must contain %s, where the lazysecrets command goes", Placeholder) + } + var out []string + for _, a := range custom { + if a == Placeholder { + out = append(out, inner...) + } else { + out = append(out, a) + } + } + return out, nil + } + if goos == "darwin" { + // Apps are started through open(1), so they needn't be on PATH. + // -n: a new instance, which opens a window even if one runs. + app := func(name string, args ...string) []string { + return append([]string{"open", "-na", name, "--args"}, append(args, inner...)...) + } + switch terminal { + case "kitty": + // Its own instance, so quit it with the window: kitty + // otherwise stays running without windows. + return app("kitty", "-o", "macos_quit_when_last_window_closed=yes"), nil + case "ghostty": + return app("Ghostty", "-e"), nil + case "wezterm": + return app("WezTerm", "start", "--"), nil + case "alacritty": + return app("Alacritty", "-e"), nil + case "iterm2": + return osascript(`tell application "iTerm" + activate + create window with default profile command %s +end tell`, inner), nil + case "terminal": + return osascript(`tell application "Terminal" + activate + do script %s +end tell`, inner), nil + } + } else { + switch terminal { + case "kitty", "foot": + return append([]string{terminal}, inner...), nil + case "ghostty", "alacritty", "konsole": + return append([]string{terminal, "-e"}, inner...), nil + case "wezterm": + return append([]string{"wezterm", "start", "--"}, inner...), nil + case "gnome-terminal": + return append([]string{"gnome-terminal", "--"}, inner...), nil + } + } + if terminal == "" { + return nil, fmt.Errorf("no terminal set: run lazysecrets handler install from your terminal, or set linkHandler.terminal or linkHandler.command in the config") + } + return nil, fmt.Errorf("unknown terminal %q (known: %s); linkHandler.command runs any other", terminal, strings.Join(Terminals, ", ")) +} + +// osascript runs an AppleScript with inner, shell-quoted, as a string +// literal in place of %s. +func osascript(script string, inner []string) []string { + return []string{"osascript", "-e", fmt.Sprintf(script, appleString(shellJoin(inner)))} +} + +// shellJoin quotes args for a POSIX shell. +func shellJoin(args []string) string { + q := make([]string, len(args)) + for i, a := range args { + q[i] = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'" + } + return strings.Join(q, " ") +} + +// appleString is s as an AppleScript string literal. +func appleString(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` +} + +// Launch opens a new terminal window running inner, without waiting for +// it. +func Launch(argv []string) error { + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil + if err := cmd.Start(); err != nil { + return err + } + return cmd.Process.Release() +} + +// Executable is the path of the running lazysecrets, to register. A +// binary from "go run" is deleted when it exits, so it's refused. +func Executable() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + if strings.Contains(exe, "go-build") { + return "", fmt.Errorf("this is a temporary binary from go run: build or install lazysecrets first (go install ./cmd/lazysecrets), then run handler install with it") + } + return exe, nil +} + +// passEnv lists the variables carried from the install's environment to +// lazysecrets in the new window: apps start with a minimal environment, +// without Homebrew's PATH (so no az) or the user's config locations. +var passEnv = []string{ + "PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "XDG_CONFIG_HOME", "XDG_STATE_HOME", + "AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE", +} + +// Env returns NAME=value for each variable to carry that is set. +func Env(getenv func(string) string) []string { + var out []string + for _, k := range passEnv { + if v := getenv(k); v != "" { + out = append(out, k+"="+v) + } + } + return out +} diff --git a/internal/linkhandler/terminal_test.go b/internal/linkhandler/terminal_test.go new file mode 100644 index 0000000..664868e --- /dev/null +++ b/internal/linkhandler/terminal_test.go @@ -0,0 +1,123 @@ +package linkhandler + +import ( + "os" + "os/exec" + "slices" + "strings" + "testing" +) + +func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } } + +func TestDetect(t *testing.T) { + for _, c := range []struct { + env map[string]string + want string + }{ + {map[string]string{"KITTY_WINDOW_ID": "3", "TERM_PROGRAM": "tmux"}, "kitty"}, // kitty, inside tmux + {map[string]string{"TERM_PROGRAM": "ghostty", "GHOSTTY_RESOURCES_DIR": "/x"}, "ghostty"}, + {map[string]string{"TERM_PROGRAM": "WezTerm"}, "wezterm"}, + {map[string]string{"TERM_PROGRAM": "tmux", "LC_TERMINAL": "iTerm2"}, "iterm2"}, + {map[string]string{"TERM_PROGRAM": "Apple_Terminal"}, "terminal"}, + {map[string]string{"ALACRITTY_WINDOW_ID": "1"}, "alacritty"}, + {map[string]string{"TERM_PROGRAM": "vscode"}, ""}, + } { + if got := Detect(env(c.env)); got != c.want { + t.Errorf("Detect(%v) = %q, want %q", c.env, got, c.want) + } + } +} + +var inner = []string{"/usr/bin/env", "PATH=/opt/homebrew/bin:/usr/bin", "/usr/local/bin/lazysecrets", "open", "lazysecrets://azure/kv.vault.azure.net/secrets/it's"} + +func TestCommandDarwin(t *testing.T) { + got, err := Command("darwin", "kitty", nil, inner) + if err != nil || !slices.Equal(got, append([]string{"open", "-na", "kitty", "--args", "-o", "macos_quit_when_last_window_closed=yes"}, inner...)) { + t.Errorf("kitty: %q %v", got, err) + } + got, _ = Command("darwin", "ghostty", nil, inner) + if !slices.Equal(got[:5], []string{"open", "-na", "Ghostty", "--args", "-e"}) { + t.Errorf("ghostty: %q", got) + } + got, _ = Command("darwin", "iterm2", nil, inner) + if got[0] != "osascript" || !strings.Contains(got[2], `create window with default profile command "'/usr/bin/env'`) { + t.Errorf("iterm2: %q", got) + } +} + +// The AppleScript for Terminal.app (and iTerm2, if installed) compiles, with the quote in the link. +func TestAppleScriptCompiles(t *testing.T) { + if _, err := exec.LookPath("osacompile"); err != nil { + t.Skip("no osacompile") + } + terms := []string{"terminal"} + if _, err := os.Stat("/Applications/iTerm.app"); err == nil { + terms = append(terms, "iterm2") // its commands come from the app + } + for _, term := range terms { + argv, _ := Command("darwin", term, nil, inner) + out, err := exec.Command("osacompile", "-o", t.TempDir()+"/x.scpt", "-e", argv[2]).CombinedOutput() + if err != nil { + t.Errorf("%s: %v: %s", term, err, out) + } + } +} + +func TestCommandLinux(t *testing.T) { + got, _ := Command("linux", "gnome-terminal", nil, inner) + if !slices.Equal(got, append([]string{"gnome-terminal", "--"}, inner...)) { + t.Errorf("gnome-terminal: %q", got) + } +} + +func TestCommandCustom(t *testing.T) { + got, err := Command("linux", "kitty", []string{"foot", "--title", "secrets", Placeholder}, inner) + if err != nil || !slices.Equal(got, append([]string{"foot", "--title", "secrets"}, inner...)) { + t.Errorf("custom: %q %v", got, err) + } + if _, err := Command("linux", "", []string{"foot"}, inner); err == nil { + t.Error("custom command without {cmd} accepted") + } +} + +func TestCommandErrors(t *testing.T) { + if _, err := Command("darwin", "", nil, inner); err == nil || !strings.Contains(err.Error(), "handler install") { + t.Errorf("no terminal: %v", err) + } + if _, err := Command("darwin", "xterm2000", nil, inner); err == nil || !strings.Contains(err.Error(), "unknown terminal") { + t.Errorf("unknown terminal: %v", err) + } +} + +func TestShellJoinRoundTrip(t *testing.T) { + out, err := exec.Command("sh", "-c", `printf '%s\n' `+shellJoin(inner)).Output() + if err != nil { + t.Fatal(err) + } + if got := strings.Split(strings.TrimSuffix(string(out), "\n"), "\n"); !slices.Equal(got, inner) { + t.Errorf("round trip = %q", got) + } +} + +func TestEnv(t *testing.T) { + got := Env(env(map[string]string{"PATH": "/bin", "HOME": "/h", "AZURE_CONFIG_DIR": "/az"})) + if !slices.Equal(got, []string{"PATH=/bin", "AZURE_CONFIG_DIR=/az"}) { + t.Errorf("Env = %q", got) + } +} + +func TestIsTerminal(t *testing.T) { + for b, want := range map[string]bool{ + "/Applications/kitty.app": true, "/Applications/kitty.app/Contents/kitty-quick-access.app": true, + "/Applications/Ghostty.app": true, "/System/Applications/Utilities/Terminal.app": true, + "/Applications/Slack.app": false, "/Applications/Safari.app": false, "": false, + } { + if IsTerminal(b) != want { + t.Errorf("IsTerminal(%q) = %v", b, !want) + } + } + if !IsDropdown("/Applications/kitty.app/Contents/kitty-quick-access.app") || IsDropdown("/Applications/kitty.app") { + t.Error("IsDropdown") + } +} diff --git a/internal/match/match.go b/internal/match/match.go new file mode 100644 index 0000000..d8be6be --- /dev/null +++ b/internal/match/match.go @@ -0,0 +1,218 @@ +// Package match filters and ranks lists the way fzf (and snacks.nvim's +// picker, a port of it) does: fzf's own scoring, so a match at the start +// of a word or in one run of characters beats letters scattered through a +// name, and fzf's extended search syntax: +// +// prod db both terms, in any order +// db | pw either +// 'prod exactly "prod", not fuzzy +// ^kv- -dev$ starts with / ends with +// !test without "test" +// +// ^ and $ anchor to an item's name, the part of it matches count most +// for (a secret's name rather than the path to it). Lowercase ignores case; an uppercase letter makes that term exact +// about case (smartcase). Results sort by score, then the shorter text, +// then their order in the list. +package match + +import ( + "slices" + "strings" + + "github.com/junegunn/fzf/src/algo" + "github.com/junegunn/fzf/src/util" +) + +func init() { algo.Init("default") } + +// nameBonus is added to a term's score when all its letters are in the +// item's name rather than in its context (a path, a note), like snacks' +// filename_bonus: "pass" should find db-password before a secret that +// only lives in a vault called "passwords". +const nameBonus = 24 + +type kind int + +const ( + fuzzy kind = iota + exact + prefix + suffix + equal +) + +type term struct { + kind kind + inverse bool + caseSensitive bool + text []rune +} + +// Query is a parsed search: every group must match (AND), a group +// matches when any of its terms does (OR). +type Query struct { + groups [][]term +} + +// Parse reads a search in fzf's extended syntax. +func Parse(q string) Query { + var out Query + or := false + for _, tok := range strings.Fields(q) { + if tok == "|" { + or = true + continue + } + t, ok := parseTerm(tok) + if !ok { + continue + } + if n := len(out.groups); or && n > 0 { + out.groups[n-1] = append(out.groups[n-1], t) + } else { + out.groups = append(out.groups, []term{t}) + } + or = false + } + return out +} + +func parseTerm(tok string) (term, bool) { + t := term{kind: fuzzy} + if s, ok := strings.CutPrefix(tok, "!"); ok { + t.inverse, t.kind, tok = true, exact, s + } + switch { + case strings.HasPrefix(tok, "'"): + t.kind, tok = exact, tok[1:] + case strings.HasPrefix(tok, "^"): + t.kind, tok = prefix, tok[1:] + } + if s, ok := strings.CutSuffix(tok, "$"); ok && s != "" { + if t.kind == prefix { + t.kind = equal + } else { + t.kind = suffix + } + tok = s + } + if tok == "" { + return term{}, false + } + t.caseSensitive = strings.ToLower(tok) != tok + if !t.caseSensitive { + tok = strings.ToLower(tok) + } + t.text = []rune(tok) + return t, true +} + +// Empty reports whether the query has no terms: everything matches. +func (q Query) Empty() bool { return len(q.groups) == 0 } + +// Item is a row to match: Text is everything searched, and runes +// NameStart up to NameEnd of it are the row's name (the rest is context, +// like a path or a note). Without a name, nothing gets the name bonus. +type Item struct { + Text string + NameStart, NameEnd int +} + +// Hit is a matching item: its index in the list, score, and the rune +// positions of the matched letters in its Text. +type Hit struct { + Index int + Score int + Pos []int +} + +// Rank returns the items matching q, best first. +func Rank(q Query, items []Item) []Hit { + var hits []Hit + for i, it := range items { + if h, ok := q.match(it); ok { + h.Index = i + hits = append(hits, h) + } + } + slices.SortStableFunc(hits, func(a, b Hit) int { + if a.Score != b.Score { + return b.Score - a.Score + } + return len(items[a.Index].Text) - len(items[b.Index].Text) + }) + return hits +} + +func (q Query) match(it Item) (Hit, bool) { + chars := util.ToChars([]byte(it.Text)) + // ^ and $ anchor to the name: "vaults starting with kv-", not to the + // path before it. + name := chars + if it.NameEnd > 0 { + runes := []rune(it.Text) + name = util.RunesToChars(runes[it.NameStart:min(it.NameEnd, len(runes))]) + } + var h Hit + for _, group := range q.groups { + best, matched := 0, false + var bestPos []int + for _, t := range group { + in, offset := &chars, 0 + if t.kind == prefix || t.kind == suffix || t.kind == equal { + in, offset = &name, it.NameStart + } + score, pos, ok := t.match(in, offset, it.NameStart, it.NameEnd) + if ok && (!matched || score > best) { + best, bestPos, matched = score, pos, true + } + } + if !matched { + return Hit{}, false + } + h.Score += best + h.Pos = append(h.Pos, bestPos...) + } + slices.Sort(h.Pos) + h.Pos = slices.Compact(h.Pos) + return h, true +} + +// match matches t in chars, whose first rune is at offset in the item's +// text: positions are in the text. +func (t term) match(chars *util.Chars, offset, nameStart, nameEnd int) (int, []int, bool) { + fn := algo.FuzzyMatchV2 + switch t.kind { + case exact: + fn = algo.ExactMatchNaive + case prefix: + fn = algo.PrefixMatch + case suffix: + fn = algo.SuffixMatch + case equal: + fn = algo.EqualMatch + } + res, pos := fn(t.caseSensitive, false, true, chars, t.text, !t.inverse, nil) + found := res.Start >= 0 + if t.inverse { + return 0, nil, !found + } + if !found { + return 0, nil, false + } + var p []int + if pos != nil { + for _, i := range *pos { + p = append(p, i+offset) + } + } else { // prefix, suffix and equal give a range + for i := res.Start; i < res.End; i++ { + p = append(p, i+offset) + } + } + score := res.Score + if len(p) > 0 && slices.Min(p) >= nameStart && slices.Max(p) < nameEnd { + score += nameBonus + } + return score, p, true +} diff --git a/internal/match/match_test.go b/internal/match/match_test.go new file mode 100644 index 0000000..ad3161c --- /dev/null +++ b/internal/match/match_test.go @@ -0,0 +1,107 @@ +package match + +import ( + "slices" + "testing" +) + +func names(items []Item, hits []Hit) []string { + var out []string + for _, h := range hits { + out = append(out, items[h.Index].Text) + } + return out +} + +func plain(texts ...string) []Item { + var out []Item + for _, t := range texts { + out = append(out, Item{Text: t, NameEnd: len([]rune(t))}) + } + return out +} + +func TestRanking(t *testing.T) { + for _, c := range []struct { + query string + items []string + want []string // the order of the hits + }{ + // A word that's there beats letters scattered through a name. + {"prod", []string{"p-r-o-d-ucer", "kv-app-prod"}, []string{"kv-app-prod", "p-r-o-d-ucer"}}, + // At the start of a word beats inside one. + {"pass", []string{"bypass-token", "db-password"}, []string{"db-password", "bypass-token"}}, + // Equal scores: the shorter first. + {"db", []string{"db-password-old-backup", "db-password"}, []string{"db-password", "db-password-old-backup"}}, + // Terms in any order, all of them. + {"pass prod", []string{"kv-app-prod/db-password", "kv-app-dev/db-password", "kv-app-prod/api-key"}, []string{"kv-app-prod/db-password"}}, + // Either. + {"pass | key", []string{"db-password", "api-key", "storage-account"}, []string{"db-password", "api-key"}}, + // Exact, prefix, suffix, without. + {"'prod", []string{"p-r-o-d", "kv-prod"}, []string{"kv-prod"}}, + {"^kv-", []string{"old-kv-app", "kv-app"}, []string{"kv-app"}}, + {"-dev$", []string{"kv-dev-old", "kv-dev"}, []string{"kv-dev"}}, + {"kv !dev", []string{"kv-dev", "kv-prod"}, []string{"kv-prod"}}, + // Smartcase. + {"API", []string{"api-key", "API-KEY"}, []string{"API-KEY"}}, + {"api", []string{"api-key", "API-KEY"}, []string{"api-key", "API-KEY"}}, + } { + items := plain(c.items...) + if got := names(items, Rank(Parse(c.query), items)); !slices.Equal(got, c.want) { + t.Errorf("%q: %v, want %v", c.query, got, c.want) + } + } +} + +func TestNameBeatsContext(t *testing.T) { + // "secret · note": a hit in the name beats the same hit in the note. + items := []Item{ + {Text: "api-key the password for the api", NameEnd: len("api-key")}, + {Text: "sub / vault / password", NameStart: len("sub / vault / "), NameEnd: len("sub / vault / password")}, + } + got := names(items, Rank(Parse("password"), items)) + if got[0] != "sub / vault / password" { + t.Errorf("%v: the name should win over a note", got) + } +} + +func TestPositions(t *testing.T) { + items := plain("kv-app-prød") + hits := Rank(Parse("prød"), items) + if len(hits) != 1 || !slices.Equal(hits[0].Pos, []int{7, 8, 9, 10}) { + t.Errorf("%+v: want rune positions 7-10", hits) + } + hits = Rank(Parse("^kv"), items) + if len(hits) != 1 || !slices.Equal(hits[0].Pos, []int{0, 1}) { + t.Errorf("prefix: %+v", hits) + } +} + +func TestAnchorsOnTheName(t *testing.T) { + path := func(p, name string) Item { + return Item{Text: p + name + " · ops", NameStart: len([]rune(p)), NameEnd: len([]rune(p + name))} + } + items := []Item{ + path("󰠅 platform-prod / ", "kv-app-prod"), + path("󰠅 platform-prod / kv-app-prod / ", "db-password"), + path("󰠅 platform-dev / ", "kv-app-dev"), + } + got := names(items, Rank(Parse("^kv- !dev"), items)) + if len(got) != 1 || got[0] != items[0].Text { + t.Errorf("^kv- !dev: %v, want just the kv-app-prod vault", got) + } + hits := Rank(Parse("^kv"), items[:1]) + start := len([]rune("󰠅 platform-prod / ")) + if len(hits) != 1 || !slices.Equal(hits[0].Pos, []int{start, start + 1}) { + t.Errorf("^kv positions %v, want %d-%d", hits, start, start+1) + } + if got := names(items, Rank(Parse("password$"), items)); len(got) != 1 || got[0] != items[1].Text { + t.Errorf("password$: %v", got) + } +} + +func TestEmpty(t *testing.T) { + if !Parse(" | ").Empty() || Parse("x").Empty() { + t.Error("Empty") + } +} diff --git a/internal/provider/azure/accounts.go b/internal/provider/azure/accounts.go new file mode 100644 index 0000000..46d3da8 --- /dev/null +++ b/internal/provider/azure/accounts.go @@ -0,0 +1,189 @@ +package azure + +import ( + "bytes" + "cmp" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// account is one az CLI identity in one tenant. +type account struct { + user, tenant string + tenantName string + anchor string // a subscription az maps to this account, to pin it + anchorEnabled bool + isDefault bool + subscriptions int // how many subscriptions az maps to it +} + +// azProfilePath is az's account list. It holds no tokens. +func azProfilePath() (string, error) { + dir := os.Getenv("AZURE_CONFIG_DIR") + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir = filepath.Join(home, ".azure") + } + return filepath.Join(dir, "azureProfile.json"), nil +} + +// readAccounts lists the accounts signed in to az, default first. +func readAccounts(path string) ([]account, error) { + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf")) // az writes a BOM + var profile struct { + Subscriptions []struct { + ID string `json:"id"` + State string `json:"state"` + IsDefault bool `json:"isDefault"` + TenantID string `json:"tenantId"` + TenantName string `json:"tenantDisplayName"` + User struct { + Name string `json:"name"` + } `json:"user"` + } `json:"subscriptions"` + } + if err := json.Unmarshal(b, &profile); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + + var out []account + for _, s := range profile.Subscriptions { + if s.User.Name == "" || s.TenantID == "" { + continue + } + i := slices.IndexFunc(out, func(a account) bool { return a.user == s.User.Name && a.tenant == s.TenantID }) + if i < 0 { + out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName}) + i = len(out) - 1 + } + a := &out[i] + a.subscriptions++ + // Anchor on az's default subscription if it is this account's, + // otherwise on an enabled one. + enabled := s.State == "Enabled" + switch { + case s.IsDefault: + a.anchor, a.anchorEnabled, a.isDefault = s.ID, enabled, true + case a.isDefault: + case a.anchor == "" || enabled && !a.anchorEnabled: + a.anchor, a.anchorEnabled = s.ID, enabled + } + } + slices.SortStableFunc(out, func(a, b account) int { + if a.isDefault != b.isDefault { + if a.isDefault { + return -1 + } + return 1 + } + return cmp.Compare(a.user, b.user) + }) + return out, nil +} + +// accountLabels names accounts as briefly as stays unambiguous: the user +// name before the @, the full user if another user shares that, plus the +// tenant if one user is signed in to several. +func accountLabels(accounts []account) []string { + short := func(u string) string { s, _, _ := strings.Cut(u, "@"); return s } + labels := make([]string, len(accounts)) + for i, a := range accounts { + l, tenants := short(a.user), 0 + for _, b := range accounts { + if b.user != a.user && short(b.user) == short(a.user) { + l = a.user + } + if b.user == a.user { + tenants++ + } + } + if tenants > 1 { + l += " · " + cmp.Or(a.tenantName, a.tenant) + } + labels[i] = l + } + return labels +} + +// NewAccounts returns one provider per account signed in to az, the default +// first, or a single provider if AZURE_* configures a service principal or +// az has no accounts yet. Pinned vaults go to the first. +func NewAccounts(s Settings) ([]*Provider, error) { + if os.Getenv("AZURE_CLIENT_ID") != "" && os.Getenv("AZURE_TENANT_ID") != "" { + cred, err := azidentity.NewEnvironmentCredential(nil) + if err != nil { + return nil, err + } + p, err := newWithOptions(authCredential{cred: cred}, options{Settings: s}) + if err != nil { + return nil, err + } + return []*Provider{p}, nil + } + + path, err := azProfilePath() + if err != nil { + return nil, err + } + accounts, err := readAccounts(path) + if err != nil { + return nil, err + } + if len(accounts) == 0 { + // Not signed in yet: the first call fails with an AuthError and + // the UI offers az login. + cred := authCredential{cred: newCLICredential("", ""), login: []string{"az", "login"}} + p, err := newWithOptions(cred, options{Settings: s}) + if err != nil { + return nil, err + } + return []*Provider{p}, nil + } + + labels := accountLabels(accounts) + var out []*Provider + for i, a := range accounts { + settings := s + if i > 0 { + settings.Vaults = nil + } + label := "" + if len(accounts) > 1 { + label = string(provider.KindAzure) + " · " + labels[i] + } + cred := authCredential{ + cred: newCLICredential(a.anchor, a.tenant), + login: []string{"az", "login", "--tenant", a.tenant}, + account: label, // so "sign-in required" says which account + } + p, err := newWithOptions(cred, options{Settings: settings}) + if err != nil { + return nil, err + } + if label != "" { + p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant + p.label = label + } + out = append(out, p) + } + return out, nil +} diff --git a/internal/provider/azure/accounts_test.go b/internal/provider/azure/accounts_test.go new file mode 100644 index 0000000..d2898f5 --- /dev/null +++ b/internal/provider/azure/accounts_test.go @@ -0,0 +1,121 @@ +package azure + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" +) + +const profileJSON = "\xef\xbb\xbf" + `{"installationId": "x", "subscriptions": [ + {"id": "s-admin-1", "name": "platform", "state": "Enabled", "isDefault": false, "tenantId": "t1", "tenantDisplayName": "Contoso", + "user": {"name": "admin.alice@contoso.onmicrosoft.com", "type": "user"}}, + {"id": "s-admin-2", "name": "old", "state": "Disabled", "isDefault": false, "tenantId": "t1", + "user": {"name": "admin.alice@contoso.onmicrosoft.com", "type": "user"}}, + {"id": "s-alice-1", "name": "app-dev", "state": "Enabled", "isDefault": false, "tenantId": "t1", + "user": {"name": "alice@contoso.com", "type": "user"}}, + {"id": "s-alice-2", "name": "app-prod", "state": "Enabled", "isDefault": true, "tenantId": "t1", + "user": {"name": "alice@contoso.com", "type": "user"}} +]}` + +func TestReadAccounts(t *testing.T) { + path := filepath.Join(t.TempDir(), "azureProfile.json") + if err := os.WriteFile(path, []byte(profileJSON), 0o600); err != nil { + t.Fatal(err) + } + got, err := readAccounts(path) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("accounts = %+v", got) + } + // Default account first, anchored on the default subscription. + if got[0].user != "alice@contoso.com" || got[0].anchor != "s-alice-2" || !got[0].isDefault { + t.Errorf("default account = %+v", got[0]) + } + // Anchored on an enabled subscription, not the disabled one. + if got[1].user != "admin.alice@contoso.onmicrosoft.com" || got[1].anchor != "s-admin-1" || got[1].subscriptions != 2 { + t.Errorf("second account = %+v", got[1]) + } + + if accts, err := readAccounts(filepath.Join(t.TempDir(), "missing.json")); err != nil || accts != nil { + t.Errorf("missing profile: %v, %v", accts, err) + } +} + +func TestAccountLabels(t *testing.T) { + got := accountLabels([]account{ + {user: "alice@contoso.com", tenant: "t1"}, + {user: "admin@a.example", tenant: "t1"}, + {user: "admin@b.example", tenant: "t1"}, + {user: "guest@x.example", tenant: "t1", tenantName: "Home"}, + {user: "guest@x.example", tenant: "t2", tenantName: "Partner"}, + }) + want := []string{"alice", "admin@a.example", "admin@b.example", "guest · Home", "guest · Partner"} + if !reflect.DeepEqual(got, want) { + t.Errorf("labels = %q\nwant %q", got, want) + } +} + +func TestCLICredentialPinsAccount(t *testing.T) { + var calls [][]string + c := newCLICredential("s-alice-2", "t1") + c.run = func(_ context.Context, args ...string) ([]byte, error) { + calls = append(calls, args) + exp := time.Now().Add(time.Hour).Unix() + return []byte(fmt.Sprintf(`{"accessToken": "tok", "expires_on": %d}`, exp)), nil + } + ctx := context.Background() + vault := policy.TokenRequestOptions{Scopes: []string{"https://vault.azure.net/.default"}} + + // Same tenant (Key Vault's challenge names it): pin with --subscription only. + vault.TenantID = "t1" + if tok, err := c.GetToken(ctx, vault); err != nil || tok.Token != "tok" { + t.Fatalf("token: %v, %v", tok, err) + } + if got := strings.Join(calls[0], " "); !strings.Contains(got, "--subscription s-alice-2") || strings.Contains(got, "--tenant") { + t.Errorf("same tenant args: %s", got) + } + + // Cached: no second az call. + if _, err := c.GetToken(ctx, vault); err != nil || len(calls) != 1 { + t.Errorf("token not cached: %d calls", len(calls)) + } + + // Another tenant: --tenant only, since az refuses both. + vault.TenantID = "t2" + if _, err := c.GetToken(ctx, vault); err != nil { + t.Fatal(err) + } + if got := strings.Join(calls[1], " "); !strings.Contains(got, "--tenant t2") || strings.Contains(got, "--subscription") { + t.Errorf("other tenant args: %s", got) + } +} + +func TestCLICredentialRenewsAndReportsErrors(t *testing.T) { + n := 0 + c := newCLICredential("", "") + c.run = func(context.Context, ...string) ([]byte, error) { + n++ + if n == 2 { + return nil, errors.New("AADSTS700082: The refresh token has expired. Please run 'az login'") + } + // Expires inside the refresh margin, so the next call renews. + return []byte(`{"accessToken": "tok", "expiresOn": "` + time.Now().Add(time.Minute).Format("2006-01-02 15:04:05.000000") + `"}`), nil + } + opts := policy.TokenRequestOptions{Scopes: []string{"https://management.azure.com/.default"}} + if _, err := c.GetToken(context.Background(), opts); err != nil { + t.Fatal(err) + } + if _, err := c.GetToken(context.Background(), opts); err == nil || !strings.Contains(err.Error(), "AADSTS700082") { + t.Errorf("err = %v", err) + } +} diff --git a/internal/provider/azure/auth.go b/internal/provider/azure/auth.go new file mode 100644 index 0000000..c267ac5 --- /dev/null +++ b/internal/provider/azure/auth.go @@ -0,0 +1,27 @@ +package azure + +import ( + "context" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// authCredential turns every token failure into a *provider.AuthError, so +// the UI can tell "log in again" apart from other failures however deep in +// the SDK the token was requested. +type authCredential struct { + cred azcore.TokenCredential + login []string + account string // label when there are several accounts +} + +func (c authCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) { + tok, err := c.cred.GetToken(ctx, opts) + if err != nil && ctx.Err() == nil { + err = &provider.AuthError{Kind: provider.KindAzure, Account: c.account, Login: c.login, Err: err} + } + return tok, err +} diff --git a/internal/provider/azure/azure.go b/internal/provider/azure/azure.go new file mode 100644 index 0000000..a140dad --- /dev/null +++ b/internal/provider/azure/azure.go @@ -0,0 +1,524 @@ +// Package azure is the Azure Key Vault provider. Sources are subscriptions, +// containers are vaults (found via Resource Graph), and objects are secrets, +// keys and certificates. +package azure + +import ( + "cmp" + "context" + "crypto/x509" + "errors" + "fmt" + "regexp" + "slices" + "strings" + "sync" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" + + "git.kebcloud.no/keb/lazysecrets/internal/config" + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} + +// Provider serves one Azure identity: an az CLI account or a service +// principal. Creating it doesn't contact Azure; authentication problems +// surface as *provider.AuthError from the first call. +type Provider struct { + id, label string // "" for the single-account defaults + + cred azcore.TokenCredential + opts options + subs *armsubscriptions.Client + graph *armresourcegraph.Client + + mu sync.Mutex + vaults map[string]*vaultClients // by vault URL + tenants map[string]string // tenant ID by subscription ID, for portal links +} + +type vaultClients struct { + secrets *azsecrets.Client + keys *azkeys.Client + certs *azcertificates.Client +} + +var _ provider.Provider = (*Provider)(nil) + +// Settings come from the config file. +type Settings struct { + // HideSubscriptions lists subscriptions to leave out, by name or ID. + HideSubscriptions []string + // Vaults are listed under a "Pinned vaults" source. + Vaults []string +} + +// pinnedID identifies the source holding vaults from the config file. +const pinnedID = "pinned-vaults" + +// options lets tests point the SDK clients at a local server. +type options struct { + Settings + + arm *arm.ClientOptions + client azcore.ClientOptions + skipChallengeValidation bool +} + +func newWithOptions(cred azcore.TokenCredential, opts options) (*Provider, error) { + subs, err := armsubscriptions.NewClient(cred, opts.arm) + if err != nil { + return nil, err + } + graph, err := armresourcegraph.NewClient(cred, opts.arm) + if err != nil { + return nil, err + } + return &Provider{ + cred: cred, opts: opts, subs: subs, graph: graph, + vaults: map[string]*vaultClients{}, tenants: map[string]string{}, + }, nil +} + +func (p *Provider) Kind() provider.Kind { return provider.KindAzure } + +func (p *Provider) ID() string { return cmp.Or(p.id, string(provider.KindAzure)) } + +func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindAzure)) } + +// Sources lists the subscriptions the signed-in identity can see, skipping +// disabled and deleted ones. +func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) { + var out []provider.Source + pager := p.subs.NewListPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, cleanError(err) + } + for _, s := range page.Value { + switch deref(s.State) { + case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted: + continue + } + id, tenant := deref(s.SubscriptionID), deref(s.TenantID) + if config.Hidden(p.opts.HideSubscriptions, deref(s.DisplayName), id) { + continue + } + p.mu.Lock() + p.tenants[id] = tenant + p.mu.Unlock() + out = append(out, provider.Source{ + Kind: provider.KindAzure, + ID: id, + Name: deref(s.DisplayName), + Detail: "subscription " + id, + URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"), + }) + } + } + slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) + if len(p.opts.Vaults) > 0 { + out = append(out, provider.Source{ + Kind: provider.KindAzure, ID: pinnedID, Name: "Pinned vaults", Detail: "from the config file", + }) + } + return out, nil +} + +// pinnedVaults lists the vaults from the config file. Without a resource ID +// they have no portal link. +func (p *Provider) pinnedVaults(src provider.Source) []provider.Container { + out := make([]provider.Container, 0, len(p.opts.Vaults)) + for _, u := range p.opts.Vaults { + if !strings.HasSuffix(u, "/") { + u += "/" + } + name, _, _ := strings.Cut(strings.TrimPrefix(u, "https://"), ".") + out = append(out, provider.Container{Source: src, ID: u, Name: name, Detail: u, Types: objectTypes}) + } + return out +} + +// Containers finds the subscription's vaults with Resource Graph, falling +// back to the Key Vault management API if the query fails. +func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) { + if src.ID == pinnedID { + return p.pinnedVaults(src), nil + } + vaults, err := p.graphVaults(ctx, src) + if err != nil { + var auth *provider.AuthError + if errors.As(err, &auth) || ctx.Err() != nil { + return nil, err + } + vaults, err = p.armVaults(ctx, src) + if err != nil { + return nil, err + } + } + tenant := p.tenant(src.ID) + for i := range vaults { + vaults[i].URL = PortalResourceURL(tenant, vaults[i].ID, "overview") + } + slices.SortFunc(vaults, func(a, b provider.Container) int { + return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) + }) + return vaults, nil +} + +func (p *Provider) tenant(subscriptionID string) string { + p.mu.Lock() + defer p.mu.Unlock() + return p.tenants[subscriptionID] +} + +const vaultQuery = `resources +| where type =~ 'microsoft.keyvault/vaults' +| project id, name, uri = tostring(properties.vaultUri)` + +func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) { + var out []provider.Container + req := armresourcegraph.QueryRequest{ + Query: to.Ptr(vaultQuery), + Subscriptions: []*string{to.Ptr(src.ID)}, + Options: &armresourcegraph.QueryRequestOptions{ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray)}, + } + for { + resp, err := p.graph.Resources(ctx, req, nil) + if err != nil { + return nil, cleanError(err) + } + rows, ok := resp.Data.([]any) + if !ok { + return nil, fmt.Errorf("resource graph: unexpected result %T", resp.Data) + } + for _, r := range rows { + if c, ok := graphVault(src, r); ok { + out = append(out, c) + } + } + if resp.SkipToken == nil || *resp.SkipToken == "" { + return out, nil + } + req.Options.SkipToken = resp.SkipToken + } +} + +func graphVault(src provider.Source, row any) (provider.Container, bool) { + m, ok := row.(map[string]any) + if !ok { + return provider.Container{}, false + } + id, _ := m["id"].(string) + name, _ := m["name"].(string) + uri, _ := m["uri"].(string) + if id == "" || name == "" || uri == "" { + return provider.Container{}, false + } + return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes}, true +} + +func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) { + client, err := armkeyvault.NewVaultsClient(src.ID, p.cred, p.opts.arm) + if err != nil { + return nil, err + } + var out []provider.Container + pager := client.NewListBySubscriptionPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, cleanError(err) + } + for _, v := range page.Value { + if v.Properties == nil || v.Properties.VaultURI == nil { + continue + } + out = append(out, provider.Container{ + Source: src, ID: deref(v.ID), Name: deref(v.Name), + Detail: *v.Properties.VaultURI, Types: objectTypes, + }) + } + } + return out, nil +} + +func (p *Provider) clients(vaultURL string) (*vaultClients, error) { + p.mu.Lock() + defer p.mu.Unlock() + if c, ok := p.vaults[vaultURL]; ok { + return c, nil + } + skip := p.opts.skipChallengeValidation + s, err := azsecrets.NewClient(vaultURL, p.cred, &azsecrets.ClientOptions{ + ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip, + }) + if err != nil { + return nil, err + } + k, err := azkeys.NewClient(vaultURL, p.cred, &azkeys.ClientOptions{ + ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip, + }) + if err != nil { + return nil, err + } + c, err := azcertificates.NewClient(vaultURL, p.cred, &azcertificates.ClientOptions{ + ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip, + }) + if err != nil { + return nil, err + } + vc := &vaultClients{secrets: s, keys: k, certs: c} + p.vaults[vaultURL] = vc + return vc, nil +} + +func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) { + vc, err := p.clients(c.Detail) + if err != nil { + return nil, err + } + var out []provider.Object + switch t { + case provider.TypeSecret: + pager := vc.secrets.NewListSecretPropertiesPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, cleanError(err) + } + for _, s := range page.Value { + // Certificate-backing secrets appear under Certificates. + if s.ID == nil || deref(s.Managed) { + continue + } + out = append(out, secretObject(c, s)) + } + } + case provider.TypeKey: + pager := vc.keys.NewListKeyPropertiesPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, cleanError(err) + } + for _, k := range page.Value { + if k.KID == nil || deref(k.Managed) { + continue + } + out = append(out, keyObject(c, k)) + } + } + case provider.TypeCertificate: + pager := vc.certs.NewListCertificatePropertiesPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, cleanError(err) + } + for _, cp := range page.Value { + if cp.ID == nil { + continue + } + out = append(out, certObject(c, cp)) + } + } + default: + return nil, provider.ErrUnsupported + } + slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) + return out, nil +} + +// Describe fetches key and certificate details. Secrets are returned as-is: +// the only way to get more is GetSecret, which returns the value. +func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) { + vc, err := p.clients(o.Container.Detail) + if err != nil { + return o, err + } + switch o.Type { + case provider.TypeKey: + resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil) + if err != nil { + return o, cleanError(err) + } + if resp.Key != nil { + if resp.Key.KID != nil { + o.Version = resp.Key.KID.Version() + o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID)) + } + o.Attrs = keyAttrs(resp.Key) + } + case provider.TypeCertificate: + resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil) + if err != nil { + return o, cleanError(err) + } + if resp.ID != nil { + o.Version = resp.ID.Version() + o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID)) + } + if pol := resp.Policy; pol != nil && pol.SecretProperties != nil { + o.ContentType = deref(pol.SecretProperties.ContentType) + } + if cert, err := x509.ParseCertificate(resp.CER); err == nil { + o.Attrs = certAttrs(cert, resp.X509Thumbprint) + } + } + return o, nil +} + +func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) { + vc, err := p.clients(o.Container.Detail) + if err != nil { + return nil, err + } + var out []provider.Object + switch o.Type { + case provider.TypeSecret: + pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, cleanError(err) + } + for _, s := range page.Value { + if s.ID != nil { + out = append(out, secretObject(o.Container, s)) + } + } + } + case provider.TypeKey: + pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, cleanError(err) + } + for _, k := range page.Value { + if k.KID != nil { + out = append(out, keyObject(o.Container, k)) + } + } + } + case provider.TypeCertificate: + pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, cleanError(err) + } + for _, cp := range page.Value { + if cp.ID != nil { + out = append(out, certObject(o.Container, cp)) + } + } + } + default: + return nil, provider.ErrUnsupported + } + for i := range out { + out[i].Reference = versionedReference(out[i]) + } + slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) }) + return out, nil +} + +// versionedReference pins an object's reference to its version, e.g. +// @Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/name/v1). +func versionedReference(o provider.Object) string { + if o.Version == "" { + return o.Reference + } + if ref, ok := strings.CutSuffix(o.Reference, ")"); ok && o.Type == provider.TypeSecret { + return ref + "/" + o.Version + ")" + } + return o.Reference + "/" + o.Version +} + +// Value returns a secret's value, a key's public key or a certificate's +// public certificate, as of o.Version (latest if empty). +func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { + vc, err := p.clients(o.Container.Detail) + if err != nil { + return provider.SecretValue{}, err + } + switch o.Type { + case provider.TypeSecret: + resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil) + if err != nil { + return provider.SecretValue{}, cleanError(err) + } + return provider.NewSecretValue([]byte(deref(resp.Value))), nil + case provider.TypeKey: + resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil) + if err != nil { + return provider.SecretValue{}, cleanError(err) + } + pemKey, err := publicKeyPEM(resp.Key) + if err != nil { + return provider.SecretValue{}, err + } + return provider.NewSecretValue([]byte(pemKey)), nil + case provider.TypeCertificate: + resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil) + if err != nil { + return provider.SecretValue{}, cleanError(err) + } + if len(resp.CER) == 0 { + return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)") + } + return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil + } + return provider.SecretValue{}, provider.ErrUnsupported +} + +var _ provider.Locator = (*Provider)(nil) + +// validHost guards the Resource Graph query against odd input. +var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`) + +// Locate finds the subscription of the vault with the given host: a pinned +// vault, or one Resource Graph query across the account's subscriptions. +// Vaults in hidden subscriptions aren't found. +func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) { + host = strings.ToLower(host) + if !validHost.MatchString(host) { + return "", false, fmt.Errorf("not a vault host: %q", host) + } + url := "https://" + host + "/" + for _, v := range p.opts.Vaults { + if strings.EqualFold(strings.TrimSuffix(v, "/")+"/", url) { + return pinnedID, true, nil + } + } + resp, err := p.graph.Resources(ctx, armresourcegraph.QueryRequest{ + Query: to.Ptr(`resources +| where type =~ 'microsoft.keyvault/vaults' and tostring(properties.vaultUri) =~ '` + url + `' +| project subscriptionId`), + Options: &armresourcegraph.QueryRequestOptions{ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray)}, + }, nil) + if err != nil { + return "", false, cleanError(err) + } + rows, _ := resp.Data.([]any) + for _, r := range rows { + m, _ := r.(map[string]any) + sub, _ := m["subscriptionId"].(string) + if sub != "" && !config.Hidden(p.opts.HideSubscriptions, "", sub) { + return sub, true, nil + } + } + return "", false, nil +} diff --git a/internal/provider/azure/azure_test.go b/internal/provider/azure/azure_test.go new file mode 100644 index 0000000..4f6bc03 --- /dev/null +++ b/internal/provider/azure/azure_test.go @@ -0,0 +1,419 @@ +package azure + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "math/big" + "net" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +type staticCred struct{ err error } + +func (c staticCred) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + if c.err != nil { + return azcore.AccessToken{}, c.err + } + return azcore.AccessToken{Token: "token", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +// fakeAzure serves just enough of ARM and the Key Vault data plane over TLS, +// including Key Vault's 401 auth challenge. +func fakeAzure(t *testing.T, cred azcore.TokenCredential, handler http.HandlerFunc) (*Provider, *httptest.Server) { + t.Helper() + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + w.Header().Set("WWW-Authenticate", + `Bearer authorization="https://login.microsoftonline.com/tenant", resource="https://vault.azure.net"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + handler(w, r) + })) + t.Cleanup(srv.Close) + + client := azcore.ClientOptions{ + Transport: srv.Client(), + Retry: policy.RetryOptions{MaxRetries: -1}, + Cloud: cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/", + Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ + cloud.ResourceManager: {Endpoint: srv.URL, Audience: "https://management.azure.com"}, + }, + }, + } + p, err := newWithOptions(cred, options{ + arm: &arm.ClientOptions{ClientOptions: client}, + client: client, + skipChallengeValidation: true, + }) + if err != nil { + t.Fatal(err) + } + return p, srv +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func vault(srv *httptest.Server) provider.Container { + return provider.Container{ID: "/subscriptions/s/vault", Name: "kv", Detail: srv.URL + "/", Types: objectTypes} +} + +func TestTokenFailureIsAuthError(t *testing.T) { + login := []string{"az", "login"} + cred := authCredential{cred: staticCred{err: errors.New("AADSTS700082: refresh token expired")}, login: login} + p, srv := fakeAzure(t, cred, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("request sent without a token: %s", r.URL) + }) + + check := func(name string, err error) { + t.Helper() + var auth *provider.AuthError + if !errors.As(err, &auth) { + t.Fatalf("%s: error %v is not an AuthError", name, err) + } + if !reflect.DeepEqual(auth.Login, login) || !strings.Contains(auth.Error(), "AADSTS700082") { + t.Errorf("%s: unexpected AuthError %+v", name, auth) + } + } + _, err := p.Sources(context.Background()) + check("Sources (ARM)", err) + _, err = p.Objects(context.Background(), vault(srv), provider.TypeSecret) + check("Objects (Key Vault)", err) + _, err = p.Containers(context.Background(), provider.Source{ID: "s"}) + check("Containers (no ARM fallback on auth errors)", err) +} + +func TestAuthErrorNamesAccount(t *testing.T) { + cred := authCredential{cred: staticCred{err: errors.New("AADSTS53003")}, account: "Azure · admin"} + _, err := cred.GetToken(context.Background(), policy.TokenRequestOptions{Scopes: []string{"x"}}) + var auth *provider.AuthError + if !errors.As(err, &auth) || auth.Who() != "Azure · admin" || !strings.HasPrefix(auth.Error(), "Azure · admin:") { + t.Errorf("err = %v", err) + } +} + +func TestListSecretsSkipsManagedAndPages(t *testing.T) { + var srvURL string + p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/secrets" && r.URL.Query().Get("page") == "": + writeJSON(w, 200, map[string]any{ + "value": []any{ + map[string]any{"id": srvURL + "/secrets/b-secret", "contentType": "text/plain", + "attributes": map[string]any{"enabled": true, "updated": 1767225600}, "tags": map[string]any{"env": "prod"}}, + map[string]any{"id": srvURL + "/secrets/tls-cert", "managed": true}, + }, + "nextLink": srvURL + "/secrets?page=2&api-version=" + r.URL.Query().Get("api-version"), + }) + case r.URL.Path == "/secrets": + writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": srvURL + "/secrets/a-secret"}}}) + default: + http.NotFound(w, r) + } + }) + srvURL = srv.URL + + got, err := p.Objects(context.Background(), vault(srv), provider.TypeSecret) + if err != nil { + t.Fatal(err) + } + var names []string + for _, o := range got { + names = append(names, o.Name) + } + if !reflect.DeepEqual(names, []string{"a-secret", "b-secret"}) { + t.Fatalf("names = %v, want sorted and without the managed secret", names) + } + b := got[1] + if b.ContentType != "text/plain" || b.Tags["env"] != "prod" || b.Enabled == nil || !*b.Enabled || + b.Updated.IsZero() || b.Type != provider.TypeSecret { + t.Errorf("metadata not mapped: %+v", b) + } + if want := "@Microsoft.KeyVault(SecretUri=" + srv.URL + "/secrets/b-secret)"; b.Reference != want { + t.Errorf("Reference = %q, want %q", b.Reference, want) + } +} + +func TestForbiddenIsShortened(t *testing.T) { + p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 403, map[string]any{"error": map[string]any{ + "code": "Forbidden", "message": "Caller is not authorized to perform action on resource.", + }}) + }) + _, err := p.Objects(context.Background(), vault(srv), provider.TypeKey) + if err == nil { + t.Fatal("expected error") + } + if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource."; got != want { + t.Errorf("error = %q, want %q", got, want) + } + var re *azcore.ResponseError + if !errors.As(err, &re) || re.StatusCode != 403 { + t.Error("original ResponseError lost from chain") + } +} + +func TestShortenDenied(t *testing.T) { + msg := "Caller is not authorized to perform action on resource.\r\nIf role assignments, deny assignments or role definitions were changed recently, please observe propagation time.\r\nCaller: appid=04b07795;oid=1234\r\nAction: 'Microsoft.KeyVault/vaults/secrets/readMetadata/action'\r\nResource: '/subscriptions/x/vaults/kv'" + want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action" + for _, variant := range []string{msg, strings.ReplaceAll(msg, "\r\n", " ")} { + if got := shortenDenied(variant); got != want { + t.Errorf("got %q", got) + } + } + if got := shortenDenied("Public network access is disabled."); got != "Public network access is disabled." { + t.Errorf("unrelated message changed: %q", got) + } +} + +func TestSecretValue(t *testing.T) { + var srvURL string + p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/secrets/db-password/v2" && r.URL.Path != "/secrets/db-password/" { + http.NotFound(w, r) + return + } + writeJSON(w, 200, map[string]any{"id": srvURL + r.URL.Path, "value": "value-of-" + r.URL.Path}) + }) + srvURL = srv.URL + o := provider.Object{Container: vault(srv), Type: provider.TypeSecret, Name: "db-password"} + for version, want := range map[string]string{"": "value-of-/secrets/db-password/", "v2": "value-of-/secrets/db-password/v2"} { + o.Version = version + v, err := p.Value(context.Background(), o) + if err != nil { + t.Fatal(err) + } + if v.Reveal() != want { + t.Errorf("version %q: got %q", version, v.Reveal()) + } + } +} + +func TestDescribeCertificate(t *testing.T) { + der, cert := selfSigned(t) + thumb := sha1.Sum(der) + var srvURL string + p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{ + "id": srvURL + "/certificates/app-tls/abc123", + "cer": base64.StdEncoding.EncodeToString(der), + "x5t": base64.RawURLEncoding.EncodeToString(thumb[:]), + "policy": map[string]any{"secret_props": map[string]any{"contentType": "application/x-pem-file"}}, + }) + }) + srvURL = srv.URL + o, err := p.Describe(context.Background(), provider.Object{Container: vault(srv), Type: provider.TypeCertificate, Name: "app-tls"}) + if err != nil { + t.Fatal(err) + } + attrs := map[string]string{} + for _, a := range o.Attrs { + attrs[a.Label] = a.Value + } + if o.Version != "abc123" || o.ContentType != "application/x-pem-file" { + t.Errorf("version/content type = %q/%q", o.Version, o.ContentType) + } + if attrs["subject"] != "CN=app.example.com" || attrs["SANs"] != "app.example.com, 10.0.0.1" || + !strings.HasPrefix(attrs["thumbprint"], hexColon(thumb[:2])) { + t.Errorf("attrs = %v", attrs) + } + + v, err := p.Value(context.Background(), o) + if err != nil { + t.Fatal(err) + } + block, _ := pem.Decode([]byte(v.Reveal())) + if block == nil || block.Type != "CERTIFICATE" || !cert.Equal(mustParse(t, block.Bytes)) { + t.Errorf("certificate PEM does not round-trip: %q", v.Reveal()) + } +} + +func TestPublicKeyPEM(t *testing.T) { + rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048) + ecKey, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + ecX, ecY := ecPoint(t, ecKey) + + for name, tc := range map[string]struct { + jwk azkeys.JSONWebKey + pub any + wantDesc string + }{ + "rsa": { + jwk: azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeRSAHSM), N: rsaKey.N.Bytes(), + E: big.NewInt(int64(rsaKey.E)).Bytes(), KeyOps: []*azkeys.KeyOperation{to.Ptr(azkeys.KeyOperationSign)}}, + pub: &rsaKey.PublicKey, wantDesc: "RSA-HSM 2048", + }, + "ec": { + jwk: azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeEC), Crv: to.Ptr(azkeys.CurveNameP384), X: ecX, Y: ecY}, + pub: &ecKey.PublicKey, wantDesc: "EC P-384", + }, + } { + t.Run(name, func(t *testing.T) { + out, err := publicKeyPEM(&tc.jwk) + if err != nil { + t.Fatal(err) + } + block, _ := pem.Decode([]byte(out)) + got, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + t.Fatal(err) + } + if !got.(interface{ Equal(crypto.PublicKey) bool }).Equal(tc.pub) { + t.Error("public key does not round-trip") + } + if desc := keyAttrs(&tc.jwk)[0].Value; desc != tc.wantDesc { + t.Errorf("key type = %q, want %q", desc, tc.wantDesc) + } + }) + } + + if _, err := publicKeyPEM(&azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeOct)}); err == nil { + t.Error("symmetric key should have no public key") + } +} + +func TestPortalURLs(t *testing.T) { + id := "/subscriptions/s/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv" + c := provider.Container{URL: PortalResourceURL("t1", id, "overview")} + if want := "https://portal.azure.com/#@t1/resource" + id + "/overview"; c.URL != want { + t.Errorf("vault URL = %q", c.URL) + } + if got := ObjectListURL(c, provider.TypeCertificate); got != "https://portal.azure.com/#@t1/resource"+id+"/certificates" { + t.Errorf("certificates URL = %q", got) + } + if got := ObjectListURL(provider.Container{}, provider.TypeSecret); got != "" { + t.Errorf("URL without vault page = %q", got) + } + if got := PortalResourceURL("", "", "overview"); got != "" { + t.Errorf("URL without resource = %q", got) + } + if got := portalAssetURL("", "Key", "https://kv.vault.azure.net/keys/k/v1"); got != + "https://portal.azure.com/#asset/Microsoft_Azure_KeyVault/Key/https://kv.vault.azure.net/keys/k/v1" { + t.Errorf("asset URL = %q", got) + } +} + +func TestVersionedReference(t *testing.T) { + sec := provider.Object{Type: provider.TypeSecret, Version: "v1", + Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"} + if got := versionedReference(sec); got != "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s/v1)" { + t.Errorf("secret: %q", got) + } + key := provider.Object{Type: provider.TypeKey, Version: "v2", Reference: "https://kv.vault.azure.net/keys/k"} + if got := versionedReference(key); got != "https://kv.vault.azure.net/keys/k/v2" { + t.Errorf("key: %q", got) + } +} + +func TestHiddenSubscriptionsAndPinnedVaults(t *testing.T) { + p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"value": []any{ + map[string]any{"subscriptionId": "s1", "displayName": "keep-me", "state": "Enabled", "tenantId": "t"}, + map[string]any{"subscriptionId": "s2", "displayName": "platform-mgmt", "state": "Enabled", "tenantId": "t"}, + map[string]any{"subscriptionId": "s3", "displayName": "by-id", "state": "Enabled", "tenantId": "t"}, + }}) + }) + p.opts.Settings = Settings{ + HideSubscriptions: []string{"PLATFORM-MGMT", "s3"}, + Vaults: []string{"https://kv-pinned.vault.azure.net"}, + } + + srcs, err := p.Sources(context.Background()) + if err != nil { + t.Fatal(err) + } + var names []string + for _, s := range srcs { + names = append(names, s.Name) + } + if !reflect.DeepEqual(names, []string{"keep-me", "Pinned vaults"}) { + t.Fatalf("sources = %v", names) + } + + vaults, err := p.Containers(context.Background(), srcs[1]) + if err != nil { + t.Fatal(err) + } + if len(vaults) != 1 || vaults[0].Name != "kv-pinned" || vaults[0].Detail != "https://kv-pinned.vault.azure.net/" || + len(vaults[0].Types) != 3 { + t.Errorf("pinned vaults = %+v", vaults) + } +} + +func TestGraphVault(t *testing.T) { + src := provider.Source{ID: "sub"} + c, ok := graphVault(src, map[string]any{"id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/"}) + if !ok || c.Name != "kv" || c.Detail != "https://kv.vault.azure.net/" || len(c.Types) != 3 { + t.Errorf("graphVault = %+v, %v", c, ok) + } + if _, ok := graphVault(src, map[string]any{"id": "/x/kv", "name": "kv"}); ok { + t.Error("row without uri accepted") + } +} + +func selfSigned(t *testing.T) ([]byte, *x509.Certificate) { + t.Helper() + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "app.example.com"}, + DNSNames: []string{"app.example.com"}, + IPAddresses: []net.IP{net.ParseIP("10.0.0.1")}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return der, mustParse(t, der) +} + +func mustParse(t *testing.T, der []byte) *x509.Certificate { + t.Helper() + c, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return c +} + +func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) { + t.Helper() + b, err := k.PublicKey.Bytes() + if err != nil { + t.Fatal(err) + } + size := (len(b) - 1) / 2 + return b[1 : 1+size], b[1+size:] +} diff --git a/internal/provider/azure/cli.go b/internal/provider/azure/cli.go new file mode 100644 index 0000000..86ba775 --- /dev/null +++ b/internal/provider/azure/cli.go @@ -0,0 +1,123 @@ +package azure + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + "sync" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" +) + +// cliCredential gets tokens from the az CLI for one signed-in account. +// +// az can hold several accounts, but `az account get-access-token` can't be +// told which user to use: it picks the account a subscription belongs to. +// So each account is pinned with --subscription. az refuses --subscription +// together with --tenant, which the SDK's AzureCLICredential would pass for +// every Key Vault request (the vault's auth challenge names a tenant), so +// this sends --tenant only for vaults in another tenant. +// +// Tokens are cached in memory until shortly before they expire, so the +// many SDK clients don't each start an az process. +type cliCredential struct { + subscription string // pins the account; "" for az's default account + tenant string // the account's tenant, if known + run func(ctx context.Context, args ...string) ([]byte, error) + + mu sync.Mutex + cache map[string]azcore.AccessToken // by resource and tenant argument +} + +func newCLICredential(subscription, tenant string) *cliCredential { + return &cliCredential{subscription: subscription, tenant: tenant, run: runAz, cache: map[string]azcore.AccessToken{}} +} + +// refreshMargin renews tokens this long before they expire. +const refreshMargin = 5 * time.Minute + +func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) { + if len(opts.Scopes) != 1 { + return azcore.AccessToken{}, errors.New("az: exactly one scope is supported") + } + if opts.Claims != "" { + return azcore.AccessToken{}, errors.New("az: this request needs multifactor authentication; run az login again") + } + resource := strings.TrimSuffix(opts.Scopes[0], "/.default") + args := []string{"account", "get-access-token", "--output", "json", "--resource", resource} + switch { + case opts.TenantID != "" && opts.TenantID != c.tenant: + // Another tenant: az picks the account itself. + args = append(args, "--tenant", opts.TenantID) + case c.subscription != "": + args = append(args, "--subscription", c.subscription) + } + key := strings.Join(args[5:], " ") + + c.mu.Lock() + defer c.mu.Unlock() + if tok, ok := c.cache[key]; ok && time.Until(tok.ExpiresOn) > refreshMargin { + return tok, nil + } + out, err := c.run(ctx, args...) + if err != nil { + return azcore.AccessToken{}, err + } + tok, err := parseCLIToken(out) + if err != nil { + return azcore.AccessToken{}, err + } + c.cache[key] = tok + return tok, nil +} + +func parseCLIToken(b []byte) (azcore.AccessToken, error) { + var t struct { + AccessToken string `json:"accessToken"` + ExpiresOn int64 `json:"expires_on"` // az 2.54+ + ExpiresOnS string `json:"expiresOn"` // older az: local time + } + if err := json.Unmarshal(b, &t); err != nil { + return azcore.AccessToken{}, fmt.Errorf("az: unexpected output: %w", err) + } + if t.AccessToken == "" { + return azcore.AccessToken{}, errors.New("az: no access token in output") + } + exp := time.Unix(t.ExpiresOn, 0) + if t.ExpiresOn == 0 { + var err error + exp, err = time.ParseInLocation("2006-01-02 15:04:05.999999", t.ExpiresOnS, time.Local) + if err != nil { + return azcore.AccessToken{}, fmt.Errorf("az: unexpected expiry %q", t.ExpiresOnS) + } + } + return azcore.AccessToken{Token: t.AccessToken, ExpiresOn: exp}, nil +} + +// azTimeout bounds one az call; a hung az would otherwise block a pane. +const azTimeout = 30 * time.Second + +func runAz(ctx context.Context, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, azTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "az", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if errors.Is(err, exec.ErrNotFound) { + return nil, errors.New("az CLI not found; install it or set AZURE_* for a service principal") + } + if err != nil { + if msg := strings.TrimSpace(stderr.String()); msg != "" { + return nil, errors.New(msg) + } + return nil, err + } + return out, nil +} diff --git a/internal/provider/azure/convert.go b/internal/provider/azure/convert.go new file mode 100644 index 0000000..038b316 --- /dev/null +++ b/internal/provider/azure/convert.go @@ -0,0 +1,320 @@ +package azure + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net/http" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +func deref[T any](p *T) T { + var zero T + if p == nil { + return zero + } + return *p +} + +func tags(m map[string]*string) map[string]string { + if len(m) == 0 { + return nil + } + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = deref(v) + } + return out +} + +const portal = "https://portal.azure.com/#" + +// PortalResourceURL links to a blade of an Azure resource in the portal, +// e.g. "overview" or "secrets". tenant may be empty. +func PortalResourceURL(tenant, resourceID, blade string) string { + if resourceID == "" { + return "" + } + return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade +} + +// portalAssetURL links to a Key Vault object's page; objectID must include +// the version. +func portalAssetURL(tenant, kind, objectID string) string { + return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID +} + +func tenantPrefix(tenant string) string { + if tenant == "" { + return "" + } + return "@" + tenant + "/" +} + +// ObjectListURL links to the vault's list of the given object type, +// derived from the container's overview URL. +func ObjectListURL(c provider.Container, t provider.ObjectType) string { + base, ok := strings.CutSuffix(c.URL, "/overview") + if !ok { + return "" + } + switch t { + case provider.TypeKey: + return base + "/keys" + case provider.TypeCertificate: + return base + "/certificates" + } + return base + "/secrets" +} + +// objectURL is the data-plane URL of an object, e.g. +// https://kv.vault.azure.net/secrets/name. +func objectURL(c provider.Container, collection, name string) string { + return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name +} + +func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object { + name := p.ID.Name() + url := objectURL(c, "secrets", name) + o := provider.Object{ + Container: c, + Type: provider.TypeSecret, + ID: url, + Name: name, + Version: p.ID.Version(), + ContentType: deref(p.ContentType), + Tags: tags(p.Tags), + Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")", + URL: ObjectListURL(c, provider.TypeSecret), + } + if a := p.Attributes; a != nil { + o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires) + } + return o +} + +func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object { + name := p.KID.Name() + url := objectURL(c, "keys", name) + o := provider.Object{ + Container: c, + Type: provider.TypeKey, + ID: url, + Name: name, + Version: p.KID.Version(), + Tags: tags(p.Tags), + Reference: url, + URL: ObjectListURL(c, provider.TypeKey), + } + if a := p.Attributes; a != nil { + o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires) + } + return o +} + +func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object { + name := p.ID.Name() + url := objectURL(c, "certificates", name) + o := provider.Object{ + Container: c, + Type: provider.TypeCertificate, + ID: url, + Name: name, + Version: p.ID.Version(), + Tags: tags(p.Tags), + Reference: url, + URL: ObjectListURL(c, provider.TypeCertificate), + } + if a := p.Attributes; a != nil { + o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires) + } + if len(p.X509Thumbprint) > 0 { + o.Attrs = []provider.Attr{{Label: "thumbprint", Value: hexColon(p.X509Thumbprint)}} + } + return o +} + +// keyAttrs describes a JSON Web Key, e.g. "RSA-HSM 3072" or "EC P-256". +func keyAttrs(k *azkeys.JSONWebKey) []provider.Attr { + if k == nil { + return nil + } + kty := string(deref(k.Kty)) + desc := kty + switch { + case len(k.N) > 0: + desc = fmt.Sprintf("%s %d", kty, new(big.Int).SetBytes(k.N).BitLen()) + case k.Crv != nil: + desc = kty + " " + string(*k.Crv) + } + attrs := []provider.Attr{{Label: "key type", Value: desc}} + var ops []string + for _, op := range k.KeyOps { + ops = append(ops, string(deref(op))) + } + if len(ops) > 0 { + attrs = append(attrs, provider.Attr{Label: "ops", Value: strings.Join(ops, ", ")}) + } + return attrs +} + +// publicKeyPEM encodes the public half of a JSON Web Key as PEM. Key Vault +// never returns private material for keys, and this ignores it regardless. +func publicKeyPEM(k *azkeys.JSONWebKey) (string, error) { + if k == nil { + return "", errors.New("key has no key material") + } + var pub any + switch { + case len(k.N) > 0 && len(k.E) > 0: + pub = &rsa.PublicKey{N: new(big.Int).SetBytes(k.N), E: int(new(big.Int).SetBytes(k.E).Int64())} + case len(k.X) > 0 && len(k.Y) > 0: + var curve elliptic.Curve + switch deref(k.Crv) { + case azkeys.CurveNameP256: + curve = elliptic.P256() + case azkeys.CurveNameP384: + curve = elliptic.P384() + case azkeys.CurveNameP521: + curve = elliptic.P521() + default: + return "", fmt.Errorf("cannot encode curve %s as PEM", deref(k.Crv)) + } + size := (curve.Params().BitSize + 7) / 8 + point := make([]byte, 1+2*size) + point[0] = 4 // uncompressed + copy(point[1:], leftPad(k.X, size)) + copy(point[1+size:], leftPad(k.Y, size)) + ec, err := ecdsa.ParseUncompressedPublicKey(curve, point) + if err != nil { + return "", err + } + pub = ec + default: + return "", fmt.Errorf("%s keys have no public key", deref(k.Kty)) + } + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return "", err + } + return strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))), nil +} + +func leftPad(b []byte, n int) []byte { + if len(b) >= n { + return b + } + return append(make([]byte, n-len(b)), b...) +} + +// certAttrs describes an X.509 certificate. +func certAttrs(cert *x509.Certificate, thumbprint []byte) []provider.Attr { + attrs := []provider.Attr{ + {Label: "subject", Value: cert.Subject.String()}, + {Label: "issuer", Value: cert.Issuer.String()}, + } + if len(thumbprint) > 0 { + attrs = append(attrs, provider.Attr{Label: "thumbprint", Value: hexColon(thumbprint)}) + } + var sans []string + sans = append(sans, cert.DNSNames...) + sans = append(sans, cert.EmailAddresses...) + for _, ip := range cert.IPAddresses { + sans = append(sans, ip.String()) + } + for _, u := range cert.URIs { + sans = append(sans, u.String()) + } + if len(sans) > 0 { + attrs = append(attrs, provider.Attr{Label: "SANs", Value: strings.Join(sans, ", ")}) + } + attrs = append(attrs, provider.Attr{Label: "valid from", Value: cert.NotBefore.Local().Format("2006-01-02 15:04")}) + return attrs +} + +func certPEM(der []byte) string { + return strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))) +} + +func hexColon(b []byte) string { + parts := make([]string, len(b)) + for i, x := range b { + parts[i] = fmt.Sprintf("%02X", x) + } + return strings.Join(parts, ":") +} + +// cleanError shortens the SDK's multi-line HTTP errors to "403 Forbidden: +// ", keeping the original in the chain. +func cleanError(err error) error { + var re *azcore.ResponseError + if !errors.As(err, &re) { + return err + } + msg := re.ErrorCode + if re.RawResponse != nil { + if body, rerr := runtime.Payload(re.RawResponse); rerr == nil { + var payload struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(body, &payload) == nil && payload.Error.Message != "" { + msg = payload.Error.Message + } + } + } + return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err} +} + +// shortenDenied reduces Key Vault's RBAC denial, which runs to several +// sentences plus caller and resource IDs, to its first sentence and the +// missing action. +func shortenDenied(msg string) string { + const marker = "Action: '" + i := strings.Index(msg, marker) + if i < 0 { + return msg + } + action, _, ok := strings.Cut(msg[i+len(marker):], "'") + if !ok || action == "" { + return msg + } + first := msg + if k := strings.IndexAny(first, "\r\n"); k >= 0 { + first = first[:k] + } + first, _, _ = strings.Cut(first, ". ") + return strings.TrimSuffix(first, ".") + "; missing " + action +} + +type serviceError struct { + status int + msg string + err error +} + +func (e *serviceError) Error() string { + s := fmt.Sprintf("%d %s", e.status, http.StatusText(e.status)) + if e.msg != "" { + s += ": " + e.msg + } + return s +} + +func (e *serviceError) Unwrap() error { return e.err } diff --git a/internal/provider/databricks/databricks.go b/internal/provider/databricks/databricks.go new file mode 100644 index 0000000..bb54095 --- /dev/null +++ b/internal/provider/databricks/databricks.go @@ -0,0 +1,367 @@ +// Package databricks is the Databricks provider. Sources are workspace +// profiles from ~/.databrickscfg, containers are secret scopes, and objects +// are secrets. Secrets in Azure Key Vault-backed scopes are read through +// Databricks, falling back to the vault itself. +package databricks + +import ( + "cmp" + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/client" + dbconfig "github.com/databricks/databricks-sdk-go/config" + "github.com/databricks/databricks-sdk-go/service/workspace" + "gopkg.in/ini.v1" + + "git.kebcloud.no/keb/lazysecrets/internal/config" + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/azure" +) + +// KeyVault reads Azure Key Vault secrets directly; the Azure provider +// implements it. Containers passed to it only have Detail (the vault URL) +// set. +type KeyVault interface { + Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) + Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) +} + +type profile struct { + name string + host string + authType string +} + +type Provider struct { + profiles []profile + keyVault KeyVault // nil disables the fallback + newClient func(profile) (*workspace.SecretsAPI, error) + + mu sync.Mutex + clients map[string]*workspace.SecretsAPI // by profile name + vaults map[string]string // AKV-backed scope ID → vault URL +} + +var _ provider.Provider = (*Provider)(nil) + +// New reads workspace profiles from the Databricks config file +// ($DATABRICKS_CONFIG_FILE or ~/.databrickscfg), leaving out hideProfiles. +// A missing file yields a provider with no sources. +func New(kv KeyVault, hideProfiles []string) (*Provider, error) { + path := os.Getenv("DATABRICKS_CONFIG_FILE") + if path == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + path = filepath.Join(home, ".databrickscfg") + } + all, err := readProfiles(path) + if err != nil { + return nil, err + } + var profiles []profile + for _, pr := range all { + if !config.Hidden(hideProfiles, pr.name, pr.name) { + profiles = append(profiles, pr) + } + } + return &Provider{ + profiles: profiles, + keyVault: kv, + newClient: profileClient, + clients: map[string]*workspace.SecretsAPI{}, + vaults: map[string]string{}, + }, nil +} + +// profileClient is a client for the secrets API alone. The SDK's +// WorkspaceClient would link in every Databricks service, some 10 MB. +func profileClient(p profile) (*workspace.SecretsAPI, error) { + return secretsClient(&dbconfig.Config{Profile: p.name}) +} + +func secretsClient(cfg *dbconfig.Config) (*workspace.SecretsAPI, error) { + c, err := client.New(cfg) + if err != nil { + return nil, err + } + return workspace.NewSecrets(c), nil +} + +// readProfiles returns the workspace profiles in file order, skipping +// account-console profiles, which have no secret scopes. +func readProfiles(path string) ([]profile, error) { + f, err := ini.Load(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + var out []profile + for _, s := range f.Sections() { + host := s.Key("host").String() + if host == "" || s.HasKey("account_id") || strings.Contains(host, "accounts.") { + continue + } + out = append(out, profile{name: s.Name(), host: host, authType: s.Key("auth_type").String()}) + } + return out, nil +} + +// loginCommand is what signs the user back in for a profile, or nil if +// there is nothing to run (e.g. a personal access token). +func loginCommand(p profile) []string { + switch p.authType { + case "azure-cli": + return []string{"az", "login"} + case "pat": + return nil + default: + return []string{"databricks", "auth", "login", "--profile", p.name} + } +} + +func (p *Provider) Kind() provider.Kind { return provider.KindDatabricks } +func (p *Provider) ID() string { return string(provider.KindDatabricks) } +func (p *Provider) Label() string { return string(provider.KindDatabricks) } + +func (p *Provider) Sources(context.Context) ([]provider.Source, error) { + out := make([]provider.Source, 0, len(p.profiles)) + for _, pr := range p.profiles { + host := strings.TrimPrefix(pr.host, "https://") + out = append(out, provider.Source{ + Kind: provider.KindDatabricks, ID: pr.name, Name: pr.name, + Detail: host, URL: "https://" + host, + }) + } + return out, nil +} + +func (p *Provider) profile(name string) (profile, bool) { + for _, pr := range p.profiles { + if pr.name == name { + return pr, true + } + } + return profile{}, false +} + +func (p *Provider) client(name string) (*workspace.SecretsAPI, profile, error) { + pr, ok := p.profile(name) + if !ok { + return nil, pr, fmt.Errorf("unknown profile %q", name) + } + p.mu.Lock() + defer p.mu.Unlock() + if c, ok := p.clients[name]; ok { + return c, pr, nil + } + c, err := p.newClient(pr) + if err != nil { + return nil, pr, classify(pr, err) + } + p.clients[name] = c + return c, pr, nil +} + +func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) { + w, pr, err := p.client(src.ID) + if err != nil { + return nil, err + } + scopes, err := w.ListScopesAll(ctx) + if err != nil { + return nil, classify(pr, err) + } + out := make([]provider.Container, 0, len(scopes)) + p.mu.Lock() + defer p.mu.Unlock() + for _, s := range scopes { + c := provider.Container{Source: src, ID: src.ID + "/" + s.Name, Name: s.Name, Detail: "Databricks-backed"} + if s.BackendType == workspace.ScopeBackendTypeAzureKeyvault && s.KeyvaultMetadata != nil { + c.Detail = akvDetail + s.KeyvaultMetadata.DnsName + c.URL = azure.PortalResourceURL("", s.KeyvaultMetadata.ResourceId, "overview") + p.vaults[c.ID] = s.KeyvaultMetadata.DnsName + } + out = append(out, c) + } + slices.SortFunc(out, func(a, b provider.Container) int { + return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) + }) + return out, nil +} + +// vaultURL is the backing vault of an AKV-backed scope, or "". It falls +// back to the scope's Detail, so a scope remembered from an earlier session +// (a favourite) works before its workspace's scopes are listed. +func (p *Provider) vaultURL(c provider.Container) string { + p.mu.Lock() + url := p.vaults[c.ID] + p.mu.Unlock() + if url == "" { + url, _ = strings.CutPrefix(c.Detail, akvDetail) + if url == c.Detail { + url = "" + } + } + return url +} + +const akvDetail = "Azure Key Vault-backed: " + +func secretObject(c provider.Container, key string, updated time.Time) provider.Object { + return provider.Object{ + Container: c, + Type: provider.TypeSecret, + ID: c.ID + "/" + key, + Name: key, + Updated: updated, + Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key), + URL: azure.ObjectListURL(c, provider.TypeSecret), // AKV-backed only + } +} + +func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) { + if t != provider.TypeSecret { + return nil, provider.ErrUnsupported + } + w, pr, err := p.client(c.Source.ID) + if err != nil { + return nil, err + } + var out []provider.Object + secrets, err := w.ListSecretsAll(ctx, workspace.ListSecretsRequest{Scope: c.Name}) + if err == nil { + for _, s := range secrets { + var updated time.Time + if s.LastUpdatedTimestamp > 0 { + updated = time.UnixMilli(s.LastUpdatedTimestamp) + } + out = append(out, secretObject(c, s.Key, updated)) + } + } else { + dbxErr := classify(pr, err) + out, err = p.vaultObjects(ctx, c) + if err != nil { + return nil, combine(dbxErr, err) + } + } + slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) + return out, nil +} + +// vaultObjects lists an AKV-backed scope's secrets from the vault itself. +func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) { + url := p.vaultURL(c) + if url == "" || p.keyVault == nil || ctx.Err() != nil { + return nil, errNoFallback + } + secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret) + if err != nil { + return nil, err + } + out := make([]provider.Object, 0, len(secrets)) + for _, s := range secrets { + out = append(out, secretObject(c, s.Name, s.Updated)) + } + return out, nil +} + +func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) { + return o, ctx.Err() +} + +func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object, error) { + return nil, provider.ErrUnsupported +} + +func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { + w, pr, err := p.client(o.Container.Source.ID) + if err != nil { + return provider.SecretValue{}, err + } + resp, err := w.GetSecret(ctx, workspace.GetSecretRequest{Scope: o.Container.Name, Key: o.Name}) + if err == nil { + b, derr := base64.StdEncoding.DecodeString(resp.Value) + if derr != nil { + return provider.SecretValue{}, fmt.Errorf("decoding secret: %w", derr) + } + return provider.NewSecretValue(b), nil + } + dbxErr := classify(pr, err) + + url := p.vaultURL(o.Container) + if url == "" || p.keyVault == nil || ctx.Err() != nil { + return provider.SecretValue{}, dbxErr + } + v, kvErr := p.keyVault.Value(ctx, provider.Object{ + Container: provider.Container{Detail: url}, + Type: provider.TypeSecret, + Name: o.Name, + }) + if kvErr != nil { + return provider.SecretValue{}, combine(dbxErr, kvErr) + } + return v, nil +} + +var errNoFallback = errors.New("no Key Vault fallback") + +// combine reports both the Databricks and the Key Vault failure. An auth +// error from either stays reachable with errors.As. +func combine(dbxErr, kvErr error) error { + if errors.Is(kvErr, errNoFallback) { + return dbxErr + } + return fmt.Errorf("%w; Key Vault fallback: %w", dbxErr, kvErr) +} + +// classify turns authentication failures into *provider.AuthError: a 401 +// from the API, or failing to get a token before the request was sent. +func classify(pr profile, err error) error { + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + var api *apierr.APIError + if errors.As(err, &api) { + if api.StatusCode != 401 { + return err + } + } else if !strings.Contains(err.Error(), "cannot get access token") && + !strings.Contains(err.Error(), " auth: ") { + return err + } + return &provider.AuthError{Kind: provider.KindDatabricks, Login: loginCommand(pr), Err: err} +} + +var _ provider.Locator = (*Provider)(nil) + +// Locate finds the profile for a workspace host. It doesn't contact the +// workspace. +func (p *Provider) Locate(_ context.Context, host string) (string, bool, error) { + want := normalizeHost(host) + for _, pr := range p.profiles { + if normalizeHost(pr.host) == want { + return pr.name, true, nil + } + } + return "", false, nil +} + +func normalizeHost(h string) string { + h = strings.ToLower(strings.TrimSpace(h)) + h = strings.TrimPrefix(h, "https://") + return strings.TrimSuffix(h, "/") +} diff --git a/internal/provider/databricks/databricks_test.go b/internal/provider/databricks/databricks_test.go new file mode 100644 index 0000000..5c47f6b --- /dev/null +++ b/internal/provider/databricks/databricks_test.go @@ -0,0 +1,293 @@ +package databricks + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + dbconfig "github.com/databricks/databricks-sdk-go/config" + "github.com/databricks/databricks-sdk-go/service/workspace" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +func TestReadProfiles(t *testing.T) { + path := filepath.Join(t.TempDir(), "databrickscfg") + cfg := `[DEFAULT] +host = https://adb-1.1.azuredatabricks.net +auth_type = azure-cli + +[INFRA-DEV] +host = adb-2.2.azuredatabricks.net +auth_type = azure-cli + +[ACCOUNT] +host = https://accounts.azuredatabricks.net +auth_type = azure-cli + +[acct-by-id] +host = https://example.cloud.databricks.com +account_id = 123 + +[oauth] +host = https://adb-3.3.azuredatabricks.net +auth_type = databricks-cli + +[no-host] +token = x +` + if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + got, err := readProfiles(path) + if err != nil { + t.Fatal(err) + } + want := []profile{ + {name: "DEFAULT", host: "https://adb-1.1.azuredatabricks.net", authType: "azure-cli"}, + {name: "INFRA-DEV", host: "adb-2.2.azuredatabricks.net", authType: "azure-cli"}, + {name: "oauth", host: "https://adb-3.3.azuredatabricks.net", authType: "databricks-cli"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("profiles = %+v\nwant %+v", got, want) + } + + if got, err := readProfiles(filepath.Join(t.TempDir(), "missing")); err != nil || got != nil { + t.Errorf("missing file: %v, %v", got, err) + } +} + +func TestLoginCommand(t *testing.T) { + for authType, want := range map[string][]string{ + "azure-cli": {"az", "login"}, + "databricks-cli": {"databricks", "auth", "login", "--profile", "p"}, + "": {"databricks", "auth", "login", "--profile", "p"}, + "pat": nil, + } { + if got := loginCommand(profile{name: "p", authType: authType}); !reflect.DeepEqual(got, want) { + t.Errorf("%q: %v, want %v", authType, got, want) + } + } +} + +// fakeKeyVault stands in for the Azure provider. +type fakeKeyVault struct { + secrets map[string]string // name → value, for vault URL kvURL + err error + calls int +} + +const kvURL = "https://kv-app-prod.vault.azure.net/" + +func (k *fakeKeyVault) Objects(_ context.Context, c provider.Container, _ provider.ObjectType) ([]provider.Object, error) { + k.calls++ + if k.err != nil { + return nil, k.err + } + if c.Detail != kvURL { + return nil, errors.New("wrong vault " + c.Detail) + } + var out []provider.Object + for name := range k.secrets { + out = append(out, provider.Object{Name: name}) + } + return out, nil +} + +func (k *fakeKeyVault) Value(_ context.Context, o provider.Object) (provider.SecretValue, error) { + k.calls++ + if k.err != nil { + return provider.SecretValue{}, k.err + } + if o.Container.Detail != kvURL { + return provider.SecretValue{}, errors.New("wrong vault " + o.Container.Detail) + } + return provider.NewSecretValue([]byte(k.secrets[o.Name])), nil +} + +// workspaceAPI serves the secrets API. The "akv" scope behaves like an Azure +// Key Vault-backed scope whose secrets Databricks refuses to read. +func workspaceAPI(t *testing.T, status int) *httptest.Server { + t.Helper() + write := func(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if status != 0 { + write(w, status, map[string]any{"error_code": "UNAUTHENTICATED", "message": "Token is expired"}) + return + } + q := r.URL.Query() + switch { + case r.URL.Path == "/api/2.0/secrets/scopes/list": + write(w, 200, map[string]any{"scopes": []any{ + map[string]any{"name": "app", "backend_type": "DATABRICKS"}, + map[string]any{"name": "akv", "backend_type": "AZURE_KEYVAULT", + "keyvault_metadata": map[string]any{"dns_name": kvURL, "resource_id": "/x/kv-app-prod"}}, + }}) + case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "app": + write(w, 200, map[string]any{"secrets": []any{ + map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000}, + map[string]any{"key": "client-secret"}, + }}) + case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app": + write(w, 200, map[string]any{"key": q.Get("key"), + "value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))}) + case strings.HasPrefix(r.URL.Path, "/api/2.0/secrets/") && q.Get("scope") == "akv": + write(w, 400, map[string]any{"error_code": "BAD_REQUEST", + "message": "Reading secrets from Azure KeyVault-backed scopes is not supported"}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func newTestProvider(t *testing.T, srv *httptest.Server, kv KeyVault) *Provider { + t.Helper() + return &Provider{ + profiles: []profile{{name: "prod", host: srv.URL, authType: "azure-cli"}}, + keyVault: kv, + newClient: func(profile) (*workspace.SecretsAPI, error) { + return secretsClient(&dbconfig.Config{Host: srv.URL, Token: "token"}) + }, + clients: map[string]*workspace.SecretsAPI{}, + vaults: map[string]string{}, + } +} + +func scopes(t *testing.T, p *Provider) map[string]provider.Container { + t.Helper() + srcs, _ := p.Sources(context.Background()) + cs, err := p.Containers(context.Background(), srcs[0]) + if err != nil { + t.Fatal(err) + } + out := map[string]provider.Container{} + for _, c := range cs { + out[c.Name] = c + } + return out +} + +func TestScopesSecretsAndValues(t *testing.T) { + p := newTestProvider(t, workspaceAPI(t, 0), nil) + sc := scopes(t, p) + if sc["app"].Detail != "Databricks-backed" || sc["akv"].Detail != "Azure Key Vault-backed: "+kvURL { + t.Errorf("scope details = %q / %q", sc["app"].Detail, sc["akv"].Detail) + } + + objs, err := p.Objects(context.Background(), sc["app"], provider.TypeSecret) + if err != nil { + t.Fatal(err) + } + if len(objs) != 2 || objs[0].Name != "client-secret" || objs[1].Name != "storage-key" { + t.Fatalf("objects = %+v", objs) + } + if objs[1].Updated.IsZero() || objs[1].Reference != "{{secrets/app/storage-key}}" { + t.Errorf("metadata = %+v", objs[1]) + } + + v, err := p.Value(context.Background(), objs[1]) + if err != nil || v.Reveal() != "value-of-storage-key" { + t.Errorf("value = %q, %v", v.Reveal(), err) + } +} + +func TestAKVScopeFallsBackToKeyVault(t *testing.T) { + kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}} + p := newTestProvider(t, workspaceAPI(t, 0), kv) + akv := scopes(t, p)["akv"] + + objs, err := p.Objects(context.Background(), akv, provider.TypeSecret) + if err != nil { + t.Fatal(err) + } + if len(objs) != 1 || objs[0].Name != "db-password" || objs[0].Reference != "{{secrets/akv/db-password}}" { + t.Fatalf("objects = %+v", objs) + } + v, err := p.Value(context.Background(), objs[0]) + if err != nil || v.Reveal() != "from-the-vault" { + t.Errorf("value = %q, %v", v.Reveal(), err) + } +} + +func TestAKVFallbackForRememberedScope(t *testing.T) { + // A favourite from an earlier session: scopes not listed yet. + kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}} + p := newTestProvider(t, workspaceAPI(t, 0), kv) + akv := provider.Container{ + Source: provider.Source{Kind: provider.KindDatabricks, ID: "prod"}, + ID: "prod/akv", Name: "akv", Detail: "Azure Key Vault-backed: " + kvURL, + } + v, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"}) + if err != nil || v.Reveal() != "from-the-vault" { + t.Errorf("value = %q, %v", v.Reveal(), err) + } +} + +func TestAKVFallbackFailureReportsBoth(t *testing.T) { + kvAuth := &provider.AuthError{Kind: provider.KindAzure, Login: []string{"az", "login"}, Err: errors.New("expired")} + p := newTestProvider(t, workspaceAPI(t, 0), &fakeKeyVault{err: kvAuth}) + akv := scopes(t, p)["akv"] + + _, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"}) + if err == nil || !strings.Contains(err.Error(), "not supported") || !strings.Contains(err.Error(), "Key Vault fallback") { + t.Fatalf("err = %v", err) + } + var auth *provider.AuthError + if !errors.As(err, &auth) { + t.Error("Key Vault auth error not reachable for the sign-in prompt") + } +} + +func TestNoFallbackForDatabricksBackedScopes(t *testing.T) { + kv := &fakeKeyVault{} + p := newTestProvider(t, workspaceAPI(t, 0), kv) + app := scopes(t, p)["app"] + if _, err := p.Value(context.Background(), provider.Object{Container: app, Name: "missing"}); err != nil { + // app/get returns a value for any key; just make sure the vault wasn't asked + t.Fatal(err) + } + if kv.calls != 0 { + t.Errorf("Key Vault consulted for a Databricks-backed scope") + } +} + +func TestUnauthorizedIsAuthError(t *testing.T) { + p := newTestProvider(t, workspaceAPI(t, 401), nil) + srcs, _ := p.Sources(context.Background()) + _, err := p.Containers(context.Background(), srcs[0]) + var auth *provider.AuthError + if !errors.As(err, &auth) { + t.Fatalf("err = %v, want AuthError", err) + } + if auth.Kind != provider.KindDatabricks || !reflect.DeepEqual(auth.Login, []string{"az", "login"}) { + t.Errorf("auth = %+v", auth) + } +} + +func TestClassify(t *testing.T) { + pr := profile{name: "p", authType: "databricks-cli"} + var auth *provider.AuthError + if !errors.As(classify(pr, errors.New("default auth: azure-cli: cannot get access token: run az login")), &auth) { + t.Error("token failure not classified as auth") + } + if errors.As(classify(pr, errors.New("dial tcp: no such host")), &auth) { + t.Error("network error classified as auth") + } + if errors.As(classify(pr, context.Canceled), &auth) { + t.Error("cancellation classified as auth") + } +} diff --git a/internal/provider/fake/fake.go b/internal/provider/fake/fake.go new file mode 100644 index 0000000..816437a --- /dev/null +++ b/internal/provider/fake/fake.go @@ -0,0 +1,407 @@ +// Package fake is an in-memory provider used by tests and --demo mode. +package fake + +import ( + "cmp" + "context" + "errors" + "fmt" + "strings" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// Provider serves a fixed tree of sources, containers and objects, +// optionally sleeping before every call to simulate network latency. +type Provider struct { + kind provider.Kind + id, label string + delay time.Duration + sources []provider.Source + containers map[string][]provider.Container // by source ID + objects map[listKey][]provider.Object + errs map[listKey]error + values map[string]string // by object ID + versions map[string][]provider.Object // by object ID +} + +type listKey struct { + container string + typ provider.ObjectType +} + +var _ provider.Provider = (*Provider)(nil) + +func newProvider(kind provider.Kind, delay time.Duration) *Provider { + return &Provider{ + kind: kind, + delay: delay, + containers: map[string][]provider.Container{}, + objects: map[listKey][]provider.Object{}, + errs: map[listKey]error{}, + values: map[string]string{}, + versions: map[string][]provider.Object{}, + } +} + +func (p *Provider) Kind() provider.Kind { return p.kind } + +func (p *Provider) ID() string { + if p.id != "" { + return p.id + } + return string(p.kind) +} + +func (p *Provider) Label() string { + if p.label != "" { + return p.label + } + return string(p.kind) +} + +// As renames the provider, to simulate several accounts of one kind. +func (p *Provider) As(id, label string) *Provider { + p.id, p.label = id, label + return p +} + +func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) { + if err := p.wait(ctx); err != nil { + return nil, err + } + return p.sources, nil +} + +func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) { + if err := p.wait(ctx); err != nil { + return nil, err + } + return p.containers[src.ID], nil +} + +func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) { + if err := p.wait(ctx); err != nil { + return nil, err + } + k := listKey{p.containerID(c), t} + if err := p.errs[k]; err != nil { + return nil, err + } + return p.objects[k], nil +} + +// Describe returns o unchanged; the fixtures already carry their details. +func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) { + return o, ctx.Err() +} + +func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) { + if err := p.wait(ctx); err != nil { + return nil, err + } + if p.kind == provider.KindDatabricks { + return nil, provider.ErrUnsupported + } + vs := p.versions[o.ID] + if len(vs) == 0 { // every object has at least its current version + return []provider.Object{o}, nil + } + out := make([]provider.Object, len(vs)) + for i, v := range vs { + v.Container, v.Type, v.ID, v.Name, v.Reference, v.URL = o.Container, o.Type, o.ID, o.Name, o.Reference, o.URL + out[i] = v + } + return out, nil +} + +func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { + if err := p.wait(ctx); err != nil { + return provider.SecretValue{}, err + } + if o.ID == "" { // built from a link, like the real providers read it + o.ID = p.containerID(o.Container) + "/" + strings.ToLower(string(cmp.Or(o.Type, provider.TypeSecret))) + "/" + o.Name + } + v, ok := p.values[o.ID+"@"+o.Version] + if !ok { + v, ok = p.values[o.ID] + } + if !ok { + return provider.SecretValue{}, fmt.Errorf("%s %q not found", strings.ToLower(string(o.Type)), o.Name) + } + return provider.NewSecretValue([]byte(v)), nil +} + +// Locate finds the source holding the vault (by host) or the workspace. +func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) { + if err := p.wait(ctx); err != nil { + return "", false, err + } + for _, src := range p.sources { + if p.kind == provider.KindDatabricks && src.Detail == host { + return src.ID, true, nil + } + for _, c := range p.containers[src.ID] { + if c.Detail == "https://"+host { + return src.ID, true, nil + } + } + } + return "", false, nil +} + +func (p *Provider) wait(ctx context.Context) error { + if p.delay <= 0 { + return ctx.Err() + } + t := time.NewTimer(p.delay) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + +const portal = "https://portal.azure.com/#resource" + +// containerID finds a container by ID, or by vault URL like the real Azure +// provider, which only needs the URL (vaults opened from links). +func (p *Provider) containerID(c provider.Container) string { + for _, cs := range p.containers { + for _, x := range cs { + if x.ID == c.ID { + return x.ID + } + } + } + // Not one listed here (built from a link): by vault URL, or a scope + // by name in its workspace. + for _, cs := range p.containers { + for _, x := range cs { + if c.Detail != "" && strings.TrimSuffix(x.Detail, "/") == strings.TrimSuffix(c.Detail, "/") { + return x.ID + } + if c.Detail == "" && c.Name == x.Name && c.Source.ID != "" && c.Source.ID == x.Source.ID { + return x.ID + } + } + } + return c.ID +} + +func (p *Provider) addSource(id, name, detail string) provider.Source { + s := provider.Source{Kind: p.kind, ID: id, Name: name, Detail: detail} + if p.kind == provider.KindAzure { + s.URL = portal + "/subscriptions/" + id + "/overview" + } else { + s.URL = "https://" + detail + } + p.sources = append(p.sources, s) + return s +} + +func (p *Provider) addContainer(src provider.Source, name, detail string, types ...provider.ObjectType) provider.Container { + c := provider.Container{Source: src, ID: src.ID + "/" + name, Name: name, Detail: detail, Types: types} + vault := name + if p.kind == provider.KindDatabricks { + vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ") + } + if p.kind == provider.KindAzure || vault != detail { + c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview" + } + p.containers[src.ID] = append(p.containers[src.ID], c) + return c +} + +func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object { + o.Container = c + o.Type = t + o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name + if base, ok := strings.CutSuffix(c.URL, "/overview"); ok { + o.URL = base + map[provider.ObjectType]string{ + provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates", + }[t] + } + k := listKey{c.ID, t} + p.objects[k] = append(p.objects[k], o) + p.values[o.ID] = value + return o +} + +func (p *Provider) fail(c provider.Container, err error, types ...provider.ObjectType) { + for _, t := range types { + p.errs[listKey{c.ID, t}] = err + } +} + +var ( + base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC) + yes = true + no = false +) + +// ErrForbidden mimics a data-plane permission error from a backend. +var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets") + +// ErrForbiddenKeys mimics a vault where the caller can read secrets but not keys. +var ErrForbiddenKeys = errors.New("403 Forbidden: caller lacks permission to list keys") + +const ( + demoPublicKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAdemo\n-----END PUBLIC KEY-----" + demoCert = "-----BEGIN CERTIFICATE-----\nMIIBszCCAVmgAwIBAgIUDEMO\nDEMODEMODEMO\n-----END CERTIFICATE-----" +) + +var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} + +// Azure returns a fake Azure provider with two subscriptions. +func Azure(delay time.Duration) *Provider { + p := newProvider(provider.KindAzure, delay) + secretRef := func(vault, name string) string { + return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name) + } + objectURL := func(vault, kind, name string) string { + return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name) + } + + prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com") + kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...) + dbPassword := p.add(kv, provider.TypeSecret, provider.Object{ + Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base, + Expires: base.AddDate(1, 0, 0), ContentType: "text/plain", + Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"), + }, "correct-horse-battery-staple") + p.versions[dbPassword.ID] = []provider.Object{ + {Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"}, + {Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)}, + {Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)}, + } + p.values[dbPassword.ID+"@91d3e0"] = "previous-password" + p.values[dbPassword.ID+"@0a77c4"] = "original-password" + p.add(kv, provider.TypeSecret, provider.Object{ + Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0), + Reference: secretRef("kv-app-prod", "api-key"), + }, "sk_live_51HxDEMO0000000000000000") + p.add(kv, provider.TypeSecret, provider.Object{ + Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0), + Reference: secretRef("kv-app-prod", "legacy-token"), + }, "deprecated") + + p.add(kv, provider.TypeKey, provider.Object{ + Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0), + Attrs: []provider.Attr{ + {Label: "key type", Value: "RSA 2048"}, + {Label: "ops", Value: "sign, verify"}, + }, + Reference: objectURL("kv-app-prod", "keys", "signing-key"), + }, demoPublicKey) + p.add(kv, provider.TypeKey, provider.Object{ + Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0), + Attrs: []provider.Attr{ + {Label: "key type", Value: "EC P-256"}, + {Label: "ops", Value: "wrapKey, unwrapKey"}, + }, + Reference: objectURL("kv-app-prod", "keys", "data-encryption"), + }, demoPublicKey) + + p.add(kv, provider.TypeCertificate, provider.Object{ + Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0), + Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file", + Attrs: []provider.Attr{ + {Label: "subject", Value: "CN=app.example.com"}, + {Label: "issuer", Value: "CN=Example Issuing CA"}, + {Label: "thumbprint", Value: "3B:1F:9A:0C:44:E2:7D:15"}, + {Label: "SANs", Value: "app.example.com, www.app.example.com"}, + {Label: "valid from", Value: "2026-08-01 09:30"}, + }, + Reference: objectURL("kv-app-prod", "certificates", "app-tls"), + }, demoCert) + p.add(kv, provider.TypeCertificate, provider.Object{ + Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0), + Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12", + Attrs: []provider.Attr{ + {Label: "subject", Value: "CN=old.example.com"}, + {Label: "issuer", Value: "CN=Example Issuing CA"}, + {Label: "thumbprint", Value: "A0:77:12:DE:9B:03:C6:58"}, + }, + Reference: objectURL("kv-app-prod", "certificates", "old-tls"), + }, demoCert) + + shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) + p.add(shared, provider.TypeSecret, provider.Object{ + Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), + }, "smtp-demo-password") + p.fail(shared, ErrForbiddenKeys, provider.TypeKey) + + locked := p.addContainer(prod, "kv-restricted", "https://kv-restricted.vault.azure.net", allTypes...) + p.fail(locked, ErrForbidden, allTypes...) + + dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com") + kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...) + p.add(kvDev, provider.TypeSecret, provider.Object{ + Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"), + }, "dev-password") + + return p +} + +// AzureOps returns a second fake Azure account. It shares platform-prod +// with Azure but sees a different vault there, and has a subscription of +// its own, like an admin account next to a personal one. +func AzureOps(delay time.Duration) *Provider { + p := newProvider(provider.KindAzure, delay) + secretRef := func(vault, name string) string { + return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name) + } + + prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com") + breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...) + p.add(breakGlass, provider.TypeSecret, provider.Object{ + Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0), + Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"), + }, "break-glass-demo-password") + shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) + p.add(shared, provider.TypeSecret, provider.Object{ + Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), + }, "smtp-demo-password") + // Unlike the first account, ops may read keys here. + p.add(shared, provider.TypeKey, provider.Object{ + Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0), + Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}}, + Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption", + }, demoPublicKey) + + sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com") + kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...) + p.add(kv, provider.TypeSecret, provider.Object{ + Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"), + }, "pd-demo-token") + + return p +} + +// Databricks returns a fake Databricks provider with two workspace profiles, +// including an Azure Key Vault-backed scope. Scopes hold secrets only. +func Databricks(delay time.Duration) *Provider { + p := newProvider(provider.KindDatabricks, delay) + secret := func(c provider.Container, key, value string) { + p.add(c, provider.TypeSecret, provider.Object{ + Name: key, Updated: base, Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key), + }, value) + } + + prod := p.addSource("dbx-prod", "prod", "adb-1111111111111111.11.azuredatabricks.net") + app := p.addContainer(prod, "app", "Databricks-backed") + secret(app, "storage-key", "storage-demo-key") + secret(app, "sp-client-secret", "sp-demo-secret") + akv := p.addContainer(prod, "kv-app-prod", "Azure Key Vault-backed: kv-app-prod") + secret(akv, "db-password", "correct-horse-battery-staple") + + dev := p.addSource("dbx-dev", "dev", "adb-2222222222222222.2.azuredatabricks.net") + devApp := p.addContainer(dev, "app", "Databricks-backed") + secret(devApp, "storage-key", "dev-storage-key") + + return p +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..4b93b07 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,171 @@ +// Package provider defines the read-only interface every secret backend +// implements, and the types the UI works with. +// +// All backends map onto the same hierarchy: +// +// Source Azure subscription | Databricks workspace profile +// Container Key Vault | secret scope +// Object secret, key or certificate | secret +package provider + +import ( + "context" + "errors" + "fmt" + "time" +) + +// Kind identifies a backend. +type Kind string + +const ( + KindAzure Kind = "Azure" + KindDatabricks Kind = "Databricks" + // KindRepo is a git repository's encrypted secret files (SOPS). + KindRepo Kind = "Repo" +) + +// AuthError reports that the user must (re)authenticate with a backend, for +// example because their az CLI login has expired. +type AuthError struct { + Kind Kind + // Account names the identity when a provider has several, e.g. + // "Azure · admin.alice"; empty otherwise. + Account string + // Login is the command that signs the user in, e.g. ["az", "login"]. + Login []string + Err error +} + +func (e *AuthError) Error() string { + return fmt.Sprintf("%s: not signed in or session expired: %v", e.Who(), e.Err) +} + +// Who is the account, or the kind if there is just one. +func (e *AuthError) Who() string { + if e.Account != "" { + return e.Account + } + return string(e.Kind) +} + +func (e *AuthError) Unwrap() error { return e.Err } + +// ErrUnsupported is returned by optional operations a backend cannot perform, +// such as listing versions of a Databricks secret. +var ErrUnsupported = errors.New("not supported by this provider") + +// Source is a top-level account: an Azure subscription or a Databricks +// workspace profile. +type Source struct { + Kind Kind + // Provider is the ID of the provider instance the source came from. + // The app sets it; it routes requests when several providers share a + // Kind, e.g. one per Azure account. + Provider string + // ProviderLabel is that provider's Label, also set by the app. + ProviderLabel string + ID string + Name string + Detail string // e.g. tenant or workspace host + URL string // web page for the source, if any + // Here marks the source lazysecrets was started in (the current git + // repo): listed first, starred or not, and labelled. + Here bool +} + +// ObjectType is the kind of object stored in a container. +type ObjectType string + +const ( + TypeSecret ObjectType = "Secret" + TypeKey ObjectType = "Key" + TypeCertificate ObjectType = "Certificate" +) + +// Container holds objects: a Key Vault or a Databricks secret scope. +type Container struct { + Source Source + ID string + Name string + Detail string // e.g. vault URL, or "Azure Key Vault-backed: kv-name" + // Types lists the object types the container holds; empty means + // secrets only. + Types []ObjectType + URL string // web page for the container, if any +} + +// ObjectTypes returns the container's object types, defaulting to secrets. +func (c Container) ObjectTypes() []ObjectType { + if len(c.Types) == 0 { + return []ObjectType{TypeSecret} + } + return c.Types +} + +// Attr is a type-specific metadata field, shown in provider order. +type Attr struct { + Label string + Value string +} + +// Object describes a secret, key or certificate without its value. +type Object struct { + Container Container + Type ObjectType + ID string + Name string + Version string + Enabled *bool + Updated time.Time + Expires time.Time + ContentType string + Tags map[string]string + // Attrs holds type-specific metadata, e.g. key type and size, or a + // certificate's subject, issuer and thumbprint. + Attrs []Attr + // Reference is how the object is referenced from config, e.g. + // "@Microsoft.KeyVault(SecretUri=...)" or "{{secrets/scope/key}}". + Reference string + URL string // web page for the object (or its list), if any +} + +// Provider is a read-only secret backend. Every method may block on the +// network and must honour ctx cancellation. +type Provider interface { + Kind() Kind + // ID identifies the instance, stable across runs, e.g. "Azure" or + // "Azure:user@example.com/tenant". + ID() string + // Label names the instance in the UI, e.g. "Azure" or "Azure · user". + Label() string + Sources(ctx context.Context) ([]Source, error) + Containers(ctx context.Context, src Source) ([]Container, error) + // Objects lists objects of type t, one of c.ObjectTypes(). It returns + // metadata only; values are fetched with Value. + Objects(ctx context.Context, c Container, t ObjectType) ([]Object, error) + // Describe returns o with details that listing doesn't include, such as + // a key's type or a certificate's subject. It never fetches secret + // values; it may return o unchanged. + Describe(ctx context.Context, o Object) (Object, error) + // Versions may return ErrUnsupported. + Versions(ctx context.Context, o Object) ([]Object, error) + // Value returns a secret's value, or the public material of a key (PEM + // public key) or certificate (PEM certificate). Private key material is + // never returned for keys. + Value(ctx context.Context, o Object) (SecretValue, error) +} + +// Current is implemented by providers that know the source lazysecrets +// was started in (the current git repo) and can find it faster than +// Sources lists everything, so it shows first. +type Current interface { + Current(ctx context.Context) (Source, bool) +} + +// Locator is implemented by providers that can find where a container lives, +// to open links. address is a vault host (kv.vault.azure.net) or a +// workspace host. It returns the ID of the source holding it. +type Locator interface { + Locate(ctx context.Context, address string) (sourceID string, ok bool, err error) +} diff --git a/internal/provider/repo/repo.go b/internal/provider/repo/repo.go new file mode 100644 index 0000000..6cdeed1 --- /dev/null +++ b/internal/provider/repo/repo.go @@ -0,0 +1,987 @@ +// Package repo browses the encrypted secret files in git repositories: +// a repo is a source, its secret files the containers (vault level), and +// their keys the secrets. SOPS files are the one type so far. +// +// Listing never decrypts: SOPS encrypts values only, so a file's keys and +// metadata are read directly. A value is decrypted when it's revealed or +// copied, one at a time, by the sops binary, with the keys sops itself +// uses (age, Azure Key Vault through az, AWS/GCP KMS, PGP). +// +// Which files: tracked by git, then, in order of preference, the include +// and exclude globs of the repo's .lazysecrets.yaml, those of the config, +// or where the repo's .sops.yaml creation_rules say SOPS files go; a repo +// with none of those is searched whole. +package repo + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" + "sync" + "time" + + "go.yaml.in/yaml/v3" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +type Settings struct { + // Dir is where lazysecrets started: the repo containing it, if any, + // is listed first and marked Here, with its files under Dir first. + Dir string + // Paths are repos to show. + Paths []string + // Roots are folders of repos (like ~/git): their repos, and repos one + // level further down (org/repo). + Roots []string + // Starred are repos the user starred something in, shown wherever + // they are. + Starred []string + // Include and Exclude are globs relative to a repo's root (** matches + // any depth), for repos without their own .lazysecrets.yaml. + Include, Exclude []string + // Sops is the sops binary; "sops" (on PATH) when empty. + Sops string +} + +type Provider struct { + s Settings + repos []repoInfo + err error + once sync.Once + + // here is the repo containing Dir, found on its own so it can show + // before the roots are scanned. + here *repoInfo + hereOnce sync.Once +} + +var ( + _ provider.Provider = (*Provider)(nil) + _ provider.Locator = (*Provider)(nil) + _ provider.Current = (*Provider)(nil) +) + +type repoInfo struct { + root string // absolute + name string // the folder, or its path under a root (org/repo) + id string // the remote without scheme or .git, else the root path + web string // the remote's web page, if it has one + prefix string // Dir relative to root ("" when Dir isn't in it) + here bool // the repo lazysecrets started in +} + +func New(s Settings) *Provider { + if s.Sops == "" { + s.Sops = "sops" + } + return &Provider{s: s} +} + +func (p *Provider) Kind() provider.Kind { return provider.KindRepo } +func (p *Provider) ID() string { return "Repos" } +func (p *Provider) Label() string { return "Repos" } + +func (p *Provider) load() { + p.once.Do(func() { + seen := map[string]bool{} + var roots []string + names := map[string]string{} // path under a root (org/repo) + add := func(root string) { + if !seen[root] { + seen[root] = true + roots = append(roots, root) + } + } + // The repo lazysecrets started in, if any: marked, so the UI can + // put it on top, with its files under Dir first. + here, inRepo := p.current() + if inRepo { + seen[here.root] = true + } + for _, dir := range p.s.Paths { + root, err := gitRoot(expandHome(dir)) + if err != nil { + p.err = errors.Join(p.err, fmt.Errorf("%s: not a git repository", dir)) + continue + } + add(root) + } + for _, dir := range p.s.Roots { + base, _ := filepath.EvalSymlinks(expandHome(dir)) + for _, root := range reposIn(expandHome(dir), 2) { + if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") { + names[root] = filepath.ToSlash(name) + } + add(root) + } + } + // Repos you starred something in, wherever they are. + for _, dir := range p.s.Starred { + if root, err := gitRoot(dir); err == nil { + add(root) + } + } + // A git call each for the remote: dozens under ~/git, so several + // at a time. + infos := make([]repoInfo, len(roots)) + parallel(len(roots), func(i int) { + infos[i] = info(roots[i], "") + if n := names[roots[i]]; n != "" { + infos[i].name = n + } + }) + if inRepo { + infos = append([]repoInfo{here}, infos...) + } + p.repos = infos + }) +} + +// current is the repo lazysecrets started in. +func (p *Provider) current() (repoInfo, bool) { + p.hereOnce.Do(func() { + root, err := gitRoot(p.s.Dir) + if err != nil { + return + } + rel, _ := filepath.Rel(root, absPath(p.s.Dir)) + if rel == "." { + rel = "" + } + r := info(root, filepath.ToSlash(rel)) + r.here = true + p.here = &r + }) + if p.here == nil { + return repoInfo{}, false + } + return *p.here, true +} + +// Current is the repo lazysecrets started in, if it has secret files: +// one repo to check, so it can show before the others are. +func (p *Provider) Current(ctx context.Context) (provider.Source, bool) { + r, ok := p.current() + if !ok { + return provider.Source{}, false + } + src := p.source(r) + if files, err := p.Containers(ctx, src); err == nil && len(files) == 0 { + return provider.Source{}, false + } + return src, true +} + +func (p *Provider) source(r repoInfo) provider.Source { + return provider.Source{ + Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(), + ID: r.root, Name: r.name, Detail: r.id, URL: r.web, Here: r.here, + } +} + +// parallel runs f for 0..n-1, several at a time. +func parallel(n int, f func(i int)) { + var wg sync.WaitGroup + sem := make(chan struct{}, max(8, runtime.NumCPU())) + for i := range n { + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + f(i) + }() + } + wg.Wait() +} + +// reposIn finds git repositories in dir, depth levels down at most, +// without looking inside a repo or hidden folders. +func reposIn(dir string, depth int) []string { + entries, err := os.ReadDir(dir) + if err != nil || depth == 0 { + return nil + } + var out []string + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + path := filepath.Join(dir, e.Name()) + if _, err := os.Stat(filepath.Join(path, ".git")); err == nil { + if abs, err := filepath.EvalSymlinks(path); err == nil { + path = abs + } + out = append(out, path) + continue + } + out = append(out, reposIn(path, depth-1)...) + } + return out +} + +func info(root, prefix string) repoInfo { + id, web := remoteID(root) + if id == "" { + id = root + } + return repoInfo{root: root, name: filepath.Base(root), id: id, web: web, prefix: prefix} +} + +// Sources lists the repos that have secret files: one without any isn't +// worth a row. +func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) { + p.load() + // Each repo is checked for secret files, several at a time: under + // ~/git that's dozens of repos. + keep := make([]bool, len(p.repos)) + srcs := make([]provider.Source, len(p.repos)) + for i, r := range p.repos { + srcs[i] = p.source(r) + } + parallel(len(srcs), func(i int) { + files, err := p.Containers(ctx, srcs[i]) + keep[i] = err != nil || len(files) > 0 + }) + var out []provider.Source + for i, src := range srcs { + if keep[i] { + out = append(out, src) + } + } + if len(out) == 0 && p.err != nil { + return nil, p.err + } + return out, nil +} + +func (p *Provider) repo(id string) (repoInfo, bool) { + if r, ok := p.current(); ok && r.root == id { + return r, true // without waiting for the others + } + p.load() + for _, r := range p.repos { + if r.root == id { + return r, true + } + } + return repoInfo{}, false +} + +// Containers lists a repo's secret files: those under where lazysecrets +// started first, then the rest, each by path. +func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) { + r, ok := p.repo(src.ID) + if !ok { + return nil, fmt.Errorf("unknown repo %s", src.ID) + } + files, err := tracked(ctx, r.root) + if err != nil { + return nil, err + } + user, sopsWhere, err := p.selector(r.root, files) + if err != nil { + return nil, err + } + // SOPS files are tracked, where .sops.yaml says they go. Env files + // usually aren't tracked (they're in .gitignore), so they're looked + // for by name among untracked and ignored files too. + envs := envFiles(r.root, files) + seen := map[string]bool{} + var out []provider.Container + add := func(f string) { + if seen[f] || !user(f) { + return + } + seen[f] = true + path := filepath.Join(r.root, f) + c := provider.Container{Source: src, ID: path, Name: f, Types: []provider.ObjectType{provider.TypeSecret}} + switch meta, isSops := readMeta(path); { + case isSops && sopsWhere(f): + c.Detail = "sops · " + strings.Join(meta.methods, ", ") + case isSops: + return // a SOPS file where .sops.yaml says SOPS files don't go + case isEnv(f): + c.Detail = "env · plain text" + default: + return + } + out = append(out, c) + } + for _, f := range envs { + add(f) + } + for _, f := range files { + if !inPackages(f) { + add(f) + } + } + under := func(c provider.Container) bool { + return r.prefix == "" || strings.HasPrefix(c.Name, r.prefix+"/") + } + slices.SortStableFunc(out, func(a, b provider.Container) int { + if ua, ub := under(a), under(b); ua != ub { + if ua { + return -1 + } + return 1 + } + return strings.Compare(a.Name, b.Name) + }) + return out, nil +} + +func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) { + if t != provider.TypeSecret { + return nil, nil + } + b, err := os.ReadFile(c.ID) + if err != nil { + return nil, err + } + meta, isSops := readMeta(c.ID) + keys, err := parseKeys(c.ID, b) + if err != nil { + return nil, fmt.Errorf("%s: %w", c.Name, err) + } + if !isSops { // a plain env file + out := make([]provider.Object, 0, len(keys)) + for _, k := range keys { + out = append(out, provider.Object{ + Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name, + Attrs: []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}, + }) + } + return out, nil + } + out := make([]provider.Object, 0, len(keys)) + for _, k := range keys { + o := provider.Object{ + Container: c, Type: provider.TypeSecret, + ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, + Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}}, + } + if !k.encrypted { + o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"}) + } + out = append(out, o) + } + return out, nil +} + +func (p *Provider) Describe(_ context.Context, o provider.Object) (provider.Object, error) { + return o, nil +} + +func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object, error) { + return nil, provider.ErrUnsupported +} + +// Value decrypts one key with sops. +func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { + file, extract, ok := strings.Cut(o.ID, "\x00") + if !ok { // made from a link: the repo's root, the file's path, the key + file, extract = o.Container.ID, extractFor(o.Name) + if file == "" { + file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name)) + } + } + if _, isSops := readMeta(file); !isSops { + return plainValue(file, o.Name) + } + cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file) + cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return provider.SecretValue{}, errors.New("sops isn't installed: decrypting needs it (brew install sops)") + } + return provider.SecretValue{}, errors.New(sopsError(stderr.String(), err)) + } + return provider.NewSecretValue(stdout.Bytes()), nil +} + +// sopsError is sops' own message, whole: which keys it tried and why +// each failed can all matter. +func sopsError(stderr string, err error) string { + msg := strings.TrimSpace(stderr) + if msg == "" { + msg = err.Error() + } + return "sops couldn't decrypt it:\n" + msg +} + +// Locate finds the repo a link names, by its remote (or root path). +func (p *Provider) Locate(_ context.Context, host string) (string, bool, error) { + p.load() + for _, r := range p.repos { + if strings.EqualFold(r.id, host) { + return r.root, true, nil + } + } + return "", false, nil +} + +// --- git --- + +func gitRoot(dir string) (string, error) { + if dir == "" { + return "", errors.New("no directory") + } + out, err := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func tracked(ctx context.Context, root string) ([]string, error) { + out, err := exec.CommandContext(ctx, "git", "-C", root, "ls-files", "-z").Output() + if err != nil { + return nil, fmt.Errorf("git ls-files in %s: %w", root, err) + } + var files []string + for _, f := range strings.Split(string(out), "\x00") { + if f != "" { + files = append(files, f) + } + } + return files, nil +} + +// remoteID turns the origin remote into an ID ("git.kebcloud.no/keb/repo") +// and a web URL, both empty without a remote. +func remoteID(root string) (string, string) { + // From .git/config directly: a git process per repo is most of what + // listing dozens of them costs. git answers for worktrees and + // submodules (.git is a file) and remotes it rewrites (insteadOf). + if url, ok := originURL(filepath.Join(root, ".git", "config")); ok { + if url == "" { + return "", "" + } + return normalizeRemote(url) + } + out, err := exec.Command("git", "-C", root, "remote", "get-url", "origin").Output() + if err != nil { + return "", "" + } + return normalizeRemote(strings.TrimSpace(string(out))) +} + +// originURL reads [remote "origin"] url from a git config file. It isn't +// ok when the file is missing or uses insteadOf, which only git resolves. +func originURL(config string) (string, bool) { + b, err := os.ReadFile(config) + if err != nil || bytes.Contains(b, []byte("insteadOf")) || bytes.Contains(b, []byte("insteadof")) { + return "", false + } + inOrigin := false + for line := range strings.SplitSeq(string(b), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[") { + inOrigin = line == `[remote "origin"]` + continue + } + if k, v, ok := strings.Cut(line, "="); ok && inOrigin && strings.TrimSpace(k) == "url" { + return strings.TrimSpace(v), true + } + } + return "", true // no origin +} + +// normalizeRemote handles https://host/path(.git), ssh://user@host[:port]/path +// and scp-style user@host:path. +func normalizeRemote(remote string) (string, string) { + u := strings.TrimSuffix(remote, ".git") + scheme := false + for _, s := range []string{"https://", "http://", "ssh://", "git://"} { + if rest, ok := strings.CutPrefix(u, s); ok { + u, scheme = rest, true + } + } + host, rest, _ := strings.Cut(u, "/") + if !scheme { + // scp-style: user@host:path + if h, r, ok := strings.Cut(u, ":"); ok { + host, rest = h, r + } + } + if at := strings.LastIndex(host, "@"); at >= 0 { + host = host[at+1:] + } + host, _, _ = strings.Cut(host, ":") // a port + if host == "" || rest == "" { + return "", "" + } + id := strings.ToLower(host + "/" + strings.Trim(rest, "/")) + if strings.Contains(host, ".") { + return id, "https://" + host + "/" + strings.Trim(rest, "/") + } + return id, "" +} + +// --- which files --- + +// lazysecretsFile is a repo's own .lazysecrets.yaml. +type lazysecretsFile struct { + Include []string `yaml:"include"` + Exclude []string `yaml:"exclude"` +} + +// selector says which files to look at: user (the include/exclude of the +// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere +// (.sops.yaml's creation_rules, when there's no include) for SOPS files. +func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) { + include, exclude := p.s.Include, p.s.Exclude + if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil { + var f lazysecretsFile + if err := yaml.Unmarshal(b, &f); err != nil { + return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err) + } + if len(f.Include) > 0 || len(f.Exclude) > 0 { + include, exclude = f.Include, f.Exclude + } + } + inc, err := globs(include) + if err != nil { + return nil, nil, err + } + exc, err := globs(exclude) + if err != nil { + return nil, nil, err + } + var rules []sopsRule + if len(inc) == 0 { + rules = sopsRules(root, files) + } + user = func(f string) bool { + return !anyMatch(exc, f) && (len(inc) == 0 || anyMatch(inc, f)) + } + sopsWhere = func(f string) bool { + return len(rules) == 0 || slices.ContainsFunc(rules, func(r sopsRule) bool { return r.matches(f) }) + } + return user, sopsWhere, nil +} + +// envFiles lists env files by name (.env, .env.*, *.env), tracked or +// not, ignored ones included, leaving out examples and dependencies. +// tracked is the repo's tracked files. Untracked ones are looked for in +// the folders that have tracked files, not by walking the whole tree: an +// ignored .env sits next to the code it's for, and the walk would go +// through node_modules and the like, which is most of the time a scan +// takes. +func envFiles(root string, tracked []string) []string { + var out []string + isTracked := make(map[string]bool, len(tracked)) + dirs := map[string]bool{".": true} + for _, f := range tracked { + isTracked[f] = true + if isEnv(f) { + out = append(out, f) + } + if d := path.Dir(f); !dirs[d] && !inPackages(f) { + dirs[d] = true + } + } + for d := range dirs { + entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(d))) + if err != nil { + continue + } + for _, e := range entries { + f := path.Join(d, e.Name()) + if e.Type().IsRegular() && !isTracked[f] && isEnv(f) { + out = append(out, f) + } + } + } + return out +} + +// packages are folders of dependencies, caches and build output: never +// looked in for secret files. +var packages = map[string]bool{ + "node_modules": true, "bower_components": true, "jspm_packages": true, + "vendor": true, "Pods": true, "Carthage": true, + ".venv": true, "venv": true, "__pycache__": true, ".tox": true, ".nox": true, + ".mypy_cache": true, ".pytest_cache": true, ".ruff_cache": true, + ".terraform": true, ".terragrunt-cache": true, + ".gradle": true, ".m2": true, ".yarn": true, ".pnpm-store": true, + ".npm": true, ".cache": true, ".next": true, ".nuxt": true, ".svelte-kit": true, + ".git": true, +} + +// inPackages reports whether f is inside one of the packages folders. +func inPackages(f string) bool { + for d := range strings.SplitSeq(path.Dir(f), "/") { + if packages[d] { + return true + } + } + return false +} + +// isEnv reports whether a file is named like an env file with real values: +// .env, .env.local, prod.env; not .env.example and the like, nor one in +// dependencies. +func isEnv(f string) bool { + if inPackages(f) { + return false + } + base := strings.ToLower(path.Base(f)) + for _, ext := range []string{".example", ".sample", ".template", ".dist", ".tmpl"} { + if strings.HasSuffix(base, ext) { + return false + } + } + return base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env") +} + +// plainValue reads a key from a plain env file. +func plainValue(file, name string) (provider.SecretValue, error) { + b, err := os.ReadFile(file) + if err != nil { + return provider.SecretValue{}, err + } + for _, line := range strings.Split(string(b), "\n") { + if k, v, ok := envLine(line); ok && k == name { + return provider.NewSecretValue([]byte(v)), nil + } + } + return provider.SecretValue{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file)) +} + +// envLine reads KEY=value (or export KEY=value), unquoting the value and +// dropping a comment after an unquoted one. +func envLine(line string) (string, string, bool) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + return "", "", false + } + line = strings.TrimPrefix(line, "export ") + k, v, ok := strings.Cut(line, "=") + if !ok { + return "", "", false + } + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') { + if end := strings.LastIndexByte(v, v[0]); end > 0 { + inner := v[1:end] + if v[0] == '"' { + inner = strings.NewReplacer(`\n`, "\n", `\"`, `"`, `\\`, `\`).Replace(inner) + } + return k, inner, true + } + } + if i := strings.Index(v, " #"); i >= 0 { + v = strings.TrimSpace(v[:i]) + } + return k, v, true +} + +// sopsRule is a creation rule of a .sops.yaml, which applies to files +// under the directory it's in. +type sopsRule struct { + dir string // relative to the repo root, "" for the root + re *regexp.Regexp +} + +func (r sopsRule) matches(f string) bool { + rel := f + if r.dir != "" { + var ok bool + if rel, ok = strings.CutPrefix(f, r.dir+"/"); !ok { + return false + } + } + return r.re == nil || r.re.MatchString(rel) +} + +func sopsRules(root string, files []string) []sopsRule { + var rules []sopsRule + for _, f := range files { + if path.Base(f) != ".sops.yaml" { + continue + } + b, err := os.ReadFile(filepath.Join(root, f)) + if err != nil { + continue + } + var cfg struct { + CreationRules []struct { + PathRegex string `yaml:"path_regex"` + } `yaml:"creation_rules"` + } + if yaml.Unmarshal(b, &cfg) != nil { + continue + } + dir := path.Dir(f) + if dir == "." { + dir = "" + } + for _, cr := range cfg.CreationRules { + r := sopsRule{dir: dir} + if cr.PathRegex != "" { + re, err := regexp.Compile(cr.PathRegex) + if err != nil { + continue + } + r.re = re + } + rules = append(rules, r) + } + } + return rules +} + +// globs compiles globs: * and ? within a path segment, ** across them. +func globs(patterns []string) ([]*regexp.Regexp, error) { + var out []*regexp.Regexp + for _, g := range patterns { + var b strings.Builder + b.WriteString("^") + for i := 0; i < len(g); i++ { + switch c := g[i]; { + case strings.HasPrefix(g[i:], "**/"): + b.WriteString("(.*/)?") + i += 2 + case strings.HasPrefix(g[i:], "**"): + b.WriteString(".*") + i++ + case c == '*': + b.WriteString("[^/]*") + case c == '?': + b.WriteString("[^/]") + default: + b.WriteString(regexp.QuoteMeta(string(c))) + } + } + b.WriteString("$") + re, err := regexp.Compile(b.String()) + if err != nil { + return nil, fmt.Errorf("glob %q: %w", g, err) + } + out = append(out, re) + } + return out, nil +} + +func anyMatch(res []*regexp.Regexp, f string) bool { + return slices.ContainsFunc(res, func(re *regexp.Regexp) bool { return re.MatchString(f) }) +} + +// --- sops files --- + +type sopsMeta struct { + methods []string // age, azure_kv, … + modified time.Time +} + +const maxFileSize = 4 << 20 + +// readMeta reads a file's sops metadata; ok is false if it isn't a sops +// file of a supported format. +func readMeta(file string) (sopsMeta, bool) { + fi, err := os.Stat(file) + if err != nil || fi.Size() > maxFileSize { + return sopsMeta{}, false + } + if formatOf(file) == "" { + return sopsMeta{}, false // not a format sops writes: skip reading + } + b, err := os.ReadFile(file) + if err != nil || !bytes.Contains(b, []byte("sops")) { + return sopsMeta{}, false // cheap: most files aren't sops, no parsing + } + var m sopsMeta + known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"} + switch formatOf(file) { + case "yaml", "json": + var doc struct { + Sops map[string]any `yaml:"sops"` + } + if yaml.Unmarshal(b, &doc) != nil || doc.Sops == nil || doc.Sops["mac"] == nil { + return m, false + } + for _, k := range known { + if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 { + m.methods = append(m.methods, k) + } + } + if s, ok := doc.Sops["lastmodified"].(string); ok { + m.modified, _ = time.Parse(time.RFC3339, s) + } + if t, ok := doc.Sops["lastmodified"].(time.Time); ok { + m.modified = t + } + case "dotenv", "ini": + text := string(b) + if !strings.Contains(text, "sops_mac") && !strings.Contains(text, "sops_version") { + return m, false + } + for _, k := range known { + if strings.Contains(text, "sops_"+k+"__") { + m.methods = append(m.methods, k) + } + } + if i := strings.Index(text, "sops_lastmodified"); i >= 0 { + line, _, _ := strings.Cut(text[i:], "\n") + if _, v, ok := strings.Cut(line, "="); ok { + m.modified, _ = time.Parse(time.RFC3339, strings.TrimSpace(v)) + } + } + default: + return m, false + } + if len(m.methods) == 0 { + m.methods = []string{"unknown keys"} + } + return m, true +} + +func formatOf(file string) string { + switch strings.ToLower(filepath.Ext(file)) { + case ".yaml", ".yml": + return "yaml" + case ".json": + return "json" + case ".env": + return "dotenv" + case ".ini": + return "ini" + } + if strings.HasPrefix(filepath.Base(file), ".env") { + return "dotenv" + } + return "" +} + +type key struct { + name string // db.password, list[0] + extract string // sops --extract: ["db"]["password"] + encrypted bool +} + +// parseKeys lists a sops file's keys without decrypting anything. +func parseKeys(file string, b []byte) ([]key, error) { + switch formatOf(file) { + case "yaml", "json": + var doc yaml.Node + if err := yaml.Unmarshal(b, &doc); err != nil { + return nil, err + } + if len(doc.Content) == 0 { + return nil, nil + } + var out []key + walk(doc.Content[0], "", "", true, &out) + return out, nil + case "dotenv": + var out []key + for _, line := range strings.Split(string(b), "\n") { + k, v, ok := envLine(line) + if !ok || strings.HasPrefix(k, "sops_") { + continue + } + out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC[")}) + } + return out, nil + case "ini": + var out []key + section := "" + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + switch { + case line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";"): + case strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]"): + section = strings.TrimSpace(line[1 : len(line)-1]) + case section != "sops": + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC[")}) + } + } + return out, nil + } + return nil, fmt.Errorf("unsupported format") +} + +// walk collects the leaves of a YAML/JSON tree, skipping sops' own block. +func walk(n *yaml.Node, name, extract string, top bool, out *[]key) { + switch n.Kind { + case yaml.MappingNode: + for i := 0; i+1 < len(n.Content); i += 2 { + k := n.Content[i].Value + if top && k == "sops" { + continue + } + child := k + if name != "" { + child = name + "." + k + } + walk(n.Content[i+1], child, extract+quote(k), false, out) + } + case yaml.SequenceNode: + for i, c := range n.Content { + walk(c, fmt.Sprintf("%s[%d]", name, i), fmt.Sprintf("%s[%d]", extract, i), false, out) + } + case yaml.AliasNode: + if n.Alias != nil { + walk(n.Alias, name, extract, false, out) + } + default: + *out = append(*out, key{name: name, extract: extract, encrypted: strings.HasPrefix(n.Value, "ENC[")}) + } +} + +// quote is a sops --extract path element. +func quote(k string) string { + return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]` +} + +// extractFor rebuilds the extract path from a key name, for objects made +// from a link (db.password, list[0]). Names with dots in keys can't be +// told apart from nesting: those come from the listing instead. +func extractFor(name string) string { + var b strings.Builder + for _, part := range strings.Split(name, ".") { + base, idx, _ := strings.Cut(part, "[") + if base != "" { + b.WriteString(quote(base)) + } + if idx != "" { + b.WriteString("[" + idx) + } + } + return b.String() +} + +func absPath(p string) string { + if a, err := filepath.Abs(p); err == nil { + return a + } + return p +} + +func expandHome(p string) string { + if rest, ok := strings.CutPrefix(p, "~/"); ok { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, rest) + } + } + return p +} diff --git a/internal/provider/repo/repo_test.go b/internal/provider/repo/repo_test.go new file mode 100644 index 0000000..890c977 --- /dev/null +++ b/internal/provider/repo/repo_test.go @@ -0,0 +1,437 @@ +package repo + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// A sops-shaped YAML file: listing reads its keys and metadata only. +const sopsYAML = `db: + password: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str] + port: ENC[AES256_GCM,data:x,iv:x,tag:x,type:int] +list: + - ENC[AES256_GCM,data:x,iv:x,tag:x,type:str] +public_unencrypted: hello +sops: + age: + - recipient: age1xxxx + enc: x + lastmodified: "2026-10-09T10:05:29Z" + mac: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str] + version: 3.13.3 +` + +const sopsEnv = "API_KEY=ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]\n" + + "sops_age__list_0__map_recipient=age1xxxx\nsops_mac=ENC[x]\nsops_lastmodified=2026-10-09T10:05:29Z\nsops_version=3.13.3\n" + +// gitRepo makes a git repository with files, all committed, and returns +// its root. +func gitRepo(t *testing.T, files map[string]string) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("no git") + } + root := t.TempDir() + root, _ = filepath.EvalSymlinks(root) // macOS: /var is /private/var + for name, body := range files { + p := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + for _, args := range [][]string{ + {"init", "-q"}, {"add", "."}, + {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "files"}, + {"remote", "add", "origin", "git@git.example.com:team/infra.git"}, + } { + if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v %s", args, err, out) + } + } + return root +} + +func names[T any](items []T, name func(T) string) []string { + var out []string + for _, it := range items { + out = append(out, name(it)) + } + return out +} + +func TestRepoFilesAndKeys(t *testing.T) { + root := gitRepo(t, map[string]string{ + "secrets/app.yaml": sopsYAML, + "svc/billing/prod.env": sopsEnv, + "svc/billing/plain.yaml": "not: sops\n", + "README.md": "# hi\n", + }) + p := New(Settings{Dir: filepath.Join(root, "svc", "billing")}) + ctx := context.Background() + + srcs, err := p.Sources(ctx) + if err != nil || len(srcs) != 1 { + t.Fatalf("sources %v, %v", srcs, err) + } + src := srcs[0] + if src.Name != filepath.Base(root) || src.Detail != "git.example.com/team/infra" || src.URL != "https://git.example.com/team/infra" { + t.Errorf("source %+v", src) + } + + files, err := p.Containers(ctx, src) + if err != nil { + t.Fatal(err) + } + // Only sops files; those under where it started first. + if got := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(got, []string{"svc/billing/prod.env", "secrets/app.yaml"}) { + t.Errorf("files %v", got) + } + if files[1].Detail != "sops · age" { + t.Errorf("detail %q", files[1].Detail) + } + + keys, err := p.Objects(ctx, files[1], provider.TypeSecret) + if err != nil { + t.Fatal(err) + } + if got := names(keys, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"db.password", "db.port", "list[0]", "public_unencrypted"}) { + t.Errorf("keys %v", got) + } + if keys[0].Updated.IsZero() { + t.Error("lastmodified not read") + } + if _, extract, _ := strings.Cut(keys[2].ID, "\x00"); extract != `["list"][0]` { + t.Errorf("extract %q", extract) + } + + env, _ := p.Objects(ctx, files[0], provider.TypeSecret) + if got := names(env, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"API_KEY"}) { + t.Errorf("env keys %v", got) + } + + if id, ok, _ := p.Locate(ctx, "git.example.com/team/infra"); !ok || id != root { + t.Errorf("locate: %q %v", id, ok) + } +} + +func TestWhichFiles(t *testing.T) { + files := map[string]string{ + "secrets/a.yaml": sopsYAML, + "other/b.yaml": sopsYAML, + "other/testdata/c.yaml": sopsYAML, + } + list := func(root string, s Settings) []string { + s.Dir = root + p := New(s) + srcs, _ := p.Sources(context.Background()) + cs, err := p.Containers(context.Background(), srcs[0]) + if err != nil { + t.Fatal(err) + } + got := names(cs, func(c provider.Container) string { return c.Name }) + slices.Sort(got) + return got + } + + // No rules: every tracked sops file. + root := gitRepo(t, files) + if got := list(root, Settings{}); len(got) != 3 { + t.Errorf("whole repo: %v", got) + } + // The config's globs. + if got := list(root, Settings{Include: []string{"**/*.yaml"}, Exclude: []string{"**/testdata/**"}}); !slices.Equal(got, []string{"other/b.yaml", "secrets/a.yaml"}) { + t.Errorf("config globs: %v", got) + } + + // .sops.yaml says where sops files go. + withRules := map[string]string{".sops.yaml": "creation_rules:\n - path_regex: secrets/.*\\.yaml$\n age: age1x\n"} + for k, v := range files { + withRules[k] = v + } + root = gitRepo(t, withRules) + if got := list(root, Settings{}); !slices.Equal(got, []string{"secrets/a.yaml"}) { + t.Errorf(".sops.yaml rules: %v", got) + } + + // The repo's own .lazysecrets.yaml wins over both. + withRules[".lazysecrets.yaml"] = "include: [\"other/**\"]\nexclude: [\"**/testdata/**\"]\n" + root = gitRepo(t, withRules) + if got := list(root, Settings{Include: []string{"secrets/**"}}); !slices.Equal(got, []string{"other/b.yaml"}) { + t.Errorf(".lazysecrets.yaml: %v", got) + } +} + +func TestNormalizeRemote(t *testing.T) { + for in, want := range map[string]string{ + "https://git.kebcloud.no/keb/nix-config.git": "git.kebcloud.no/keb/nix-config", + "git@github.com:Team/Repo.git": "github.com/team/repo", + "ssh://git@git.example.com:2222/team/infra": "git.example.com/team/infra", + "https://user@dev.azure.com/org/proj/_git/x": "dev.azure.com/org/proj/_git/x", + } { + if got, _ := normalizeRemote(in); got != want { + t.Errorf("%s: %q, want %q", in, got, want) + } + } +} + +// With sops and age installed: real encryption and decryption, with a +// throwaway key. +func TestDecrypt(t *testing.T) { + for _, bin := range []string{"sops", "age-keygen"} { + if _, err := exec.LookPath(bin); err != nil { + t.Skip("needs " + bin) + } + } + dir := t.TempDir() + keyFile := filepath.Join(dir, "key.txt") + if out, err := exec.Command("age-keygen", "-o", keyFile).CombinedOutput(); err != nil { + t.Fatalf("age-keygen: %v %s", err, out) + } + b, _ := os.ReadFile(keyFile) + var recipient string + for _, f := range strings.Fields(string(b)) { + if strings.HasPrefix(f, "age1") { + recipient = f + } + } + plain := filepath.Join(dir, "plain.yaml") + os.WriteFile(plain, []byte("db:\n password: hunter2\nlist:\n - first\n"), 0o600) + enc, err := exec.Command("sops", "encrypt", "--age", recipient, plain).Output() + if err != nil { + t.Fatal(err) + } + root := gitRepo(t, map[string]string{"secrets.yaml": string(enc)}) + t.Setenv("SOPS_AGE_KEY_FILE", keyFile) + + p := New(Settings{Dir: root}) + ctx := context.Background() + srcs, _ := p.Sources(ctx) + cs, _ := p.Containers(ctx, srcs[0]) + keys, _ := p.Objects(ctx, cs[0], provider.TypeSecret) + for _, k := range keys { + v, err := p.Value(ctx, k) + if err != nil { + t.Fatalf("%s: %v", k.Name, err) + } + want := map[string]string{"db.password": "hunter2", "list[0]": "first"}[k.Name] + if v.Reveal() != want { + t.Errorf("%s = %q, want %q", k.Name, v.Reveal(), want) + } + } + + // From a link, without the listing: the extract path from the name. + v, err := p.Value(ctx, provider.Object{Container: cs[0], Name: "list[0]"}) + if err != nil || v.Reveal() != "first" { + t.Errorf("from a link: %q %v", v.Reveal(), err) + } + + // Without the key: sops' own error. + t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt")) + if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") { + t.Errorf("no key: %v", err) + } +} + +func TestRepoWithoutSecretsIsntListed(t *testing.T) { + root := gitRepo(t, map[string]string{"README.md": "# no secrets\n", "config.yaml": "plain: yaml\n"}) + srcs, err := New(Settings{Dir: root}).Sources(context.Background()) + if err != nil || len(srcs) != 0 { + t.Errorf("sources %v, %v", srcs, err) + } +} + +func TestPlainEnvFiles(t *testing.T) { + root := gitRepo(t, map[string]string{ + ".gitignore": ".env\n.env.local\n", + ".env.example": "TOKEN=changeme\n", + "secrets/app.yaml": sopsYAML, + }) + // Ignored, as they usually are: written after the commit. + os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nexport TOKEN=\"s3cr3t value\"\nURL=https://x.example # the API\nQUOTED='a#b'\n"), 0o600) + os.MkdirAll(filepath.Join(root, "node_modules", "pkg"), 0o755) + os.WriteFile(filepath.Join(root, "node_modules", "pkg", ".env"), []byte("X=1\n"), 0o600) + + p := New(Settings{Dir: root}) + ctx := context.Background() + srcs, _ := p.Sources(ctx) + files, err := p.Containers(ctx, srcs[0]) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, c := range files { + got[c.Name] = c.Detail + } + if len(got) != 2 || got[".env"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" { + t.Fatalf("files %v (want .env and the sops file; not the example or node_modules)", got) + } + + var env provider.Container + for _, c := range files { + if c.Name == ".env" { + env = c + } + } + keys, _ := p.Objects(ctx, env, provider.TypeSecret) + want := map[string]string{"TOKEN": "s3cr3t value", "URL": "https://x.example", "QUOTED": "a#b"} + if len(keys) != len(want) { + t.Fatalf("keys %v", names(keys, func(o provider.Object) string { return o.Name })) + } + for _, k := range keys { + v, err := p.Value(ctx, k) // no sops involved + if err != nil || v.Reveal() != want[k.Name] { + t.Errorf("%s = %q, %v; want %q", k.Name, v.Reveal(), err, want[k.Name]) + } + } + + // .sops.yaml rules limit SOPS files, not plain env files. + os.WriteFile(filepath.Join(root, ".sops.yaml"), []byte("creation_rules:\n - path_regex: nowhere/.*\n"), 0o644) + exec.Command("git", "-C", root, "add", ".sops.yaml").Run() + files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0]) + if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env"}) { + t.Errorf("with rules elsewhere: %v", names) + } +} + +func TestRoots(t *testing.T) { + withSecrets := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML}) + without := gitRepo(t, map[string]string{"README.md": "# none\n"}) + nested := gitRepo(t, map[string]string{".env.example": "X=y\n", "prod.env": sopsEnv}) + + // A folder of repos: two at the top, one under an org folder. + root, _ := filepath.EvalSymlinks(t.TempDir()) + os.Rename(withSecrets, filepath.Join(root, "infra")) + os.Rename(without, filepath.Join(root, "docs")) + os.MkdirAll(filepath.Join(root, "acme"), 0o755) + os.Rename(nested, filepath.Join(root, "acme", "billing")) + + outside := t.TempDir() // not in a repo + srcs, err := New(Settings{Dir: outside, Roots: []string{root}}).Sources(context.Background()) + if err != nil { + t.Fatal(err) + } + got := names(srcs, func(s provider.Source) string { return s.Name }) + slices.Sort(got) + if !slices.Equal(got, []string{"acme/billing", "infra"}) { + t.Errorf("repos under the root: %v (want the two with secret files)", got) + } + + // Inside a repo: the same repos, that one first and marked Here. + srcs, _ = New(Settings{Dir: filepath.Join(root, "acme", "billing"), Roots: []string{root}}).Sources(context.Background()) + got = names(srcs, func(s provider.Source) string { return s.Name }) + if !slices.Equal(got, []string{"billing", "infra"}) || !srcs[0].Here || srcs[1].Here { + t.Errorf("inside a repo: %v, want billing (here) then infra", got) + } +} + +func TestStarredReposAlongside(t *testing.T) { + here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML}) + starred := gitRepo(t, map[string]string{"prod.env": sopsEnv}) + other := gitRepo(t, map[string]string{"secrets/b.yaml": sopsYAML}) + _ = other + + srcs, err := New(Settings{Dir: here, Starred: []string{starred, here}}).Sources(context.Background()) + if err != nil { + t.Fatal(err) + } + got := names(srcs, func(s provider.Source) string { return s.ID }) + if !slices.Equal(got, []string{here, starred}) { + t.Errorf("sources %v, want this repo then the starred one (not under a root)", got) + } +} + +// sops' output when no key opens the file, as sops 3.13 prints it. +const noKeyStderr = `Failed to get the data key required to decrypt the SOPS file. + +Group 0: FAILED + age1el8zh94re2488t5rrpqxk5xcxhs2cg6xfcccntz6l4ggv0rsf9yq44c0cs: FAILED + - | failed to create reader for decrypting sops data key with + | age: no identity matched any of the recipients. Did not find + | keys in locations 'SOPS_AGE_SSH_PRIVATE_KEY_FILE'. + https://kv-app.vault.azure.net/keys/sops/abc: FAILED + - | failed to decrypt sops data key with Azure Key Vault key + | 'https://kv-app.vault.azure.net/keys/sops/abc': DefaultAzureCredential: failed to acquire a token. + +Recovery failed because no master key was able to decrypt the file. In +order for SOPS to recover the file, at least one key has to be successful, +but none were. +` + +func TestSopsError(t *testing.T) { + got := sopsError(noKeyStderr, errors.New("exit status 128")) + if got != "sops couldn't decrypt it:\n"+strings.TrimSpace(noKeyStderr) { + t.Errorf("not sops' whole message:\n%s", got) + } + if got := sopsError("", errors.New("exit status 1")); got != "sops couldn't decrypt it:\nexit status 1" { + t.Errorf("without output: %q", got) + } +} + +func TestOriginURL(t *testing.T) { + dir := t.TempDir() + write := func(s string) string { + p := filepath.Join(dir, "config") + os.WriteFile(p, []byte(s), 0o644) + return p + } + for _, c := range []struct { + config, url string + ok bool + }{ + {"[core]\n\tbare = false\n[remote \"origin\"]\n\turl = git@git.example.com:team/infra.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n", "git@git.example.com:team/infra.git", true}, + {"[remote \"upstream\"]\n\turl = https://a/b\n[remote \"origin\"]\n\turl=https://git.example.com/x.git\n", "https://git.example.com/x.git", true}, + {"[core]\n\tbare = false\n", "", true}, // no origin + {"[url \"git@host:\"]\n\tinsteadOf = https://host/\n[remote \"origin\"]\n\turl = https://host/x\n", "", false}, + } { + url, ok := originURL(write(c.config)) + if url != c.url || ok != c.ok { + t.Errorf("%q: %q %v, want %q %v", c.config, url, ok, c.url, c.ok) + } + } + if _, ok := originURL(filepath.Join(dir, "missing")); ok { + t.Error("a missing config should leave it to git") + } +} + +func TestEnvFilesSkipPackages(t *testing.T) { + root := gitRepo(t, map[string]string{ + "app/main.go": "package main\n", + "vendor/lib/x.go": "package lib\n", + "web/node_modules/pkg/index.js": "x\n", + "secrets/sops.yaml": sopsYAML, + "web/node_modules/pkg/sops.yaml": sopsYAML, + }) + // Untracked, as .env files usually are: next to tracked code, in a + // package folder, and in a folder with nothing tracked. + for f, body := range map[string]string{ + "app/.env": "A=1\n", + "web/node_modules/pkg/.env": "B=2\n", + "vendor/lib/.env": "C=3\n", + "scratch/.env": "D=4\n", + } { + os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755) + os.WriteFile(filepath.Join(root, f), []byte(body), 0o644) + } + p := New(Settings{Dir: root}) + srcs, _ := p.Sources(context.Background()) + files, err := p.Containers(context.Background(), srcs[0]) + if err != nil { + t.Fatal(err) + } + got := names(files, func(c provider.Container) string { return c.Name }) + slices.Sort(got) + if want := []string{"app/.env", "secrets/sops.yaml"}; !slices.Equal(got, want) { + t.Errorf("files %v, want %v (nothing from node_modules or vendor)", got, want) + } +} diff --git a/internal/provider/value.go b/internal/provider/value.go new file mode 100644 index 0000000..a794468 --- /dev/null +++ b/internal/provider/value.go @@ -0,0 +1,34 @@ +package provider + +import ( + "fmt" + "log/slog" +) + +const masked = "••••••" + +// SecretValue holds a secret's plaintext. Every formatting, logging and +// encoding path renders it masked; the plaintext is only available through +// Reveal, so it cannot leak through a stray Printf, slog call or error. +type SecretValue struct { + b []byte +} + +func NewSecretValue(b []byte) SecretValue { + return SecretValue{b: append([]byte(nil), b...)} +} + +// Reveal returns the plaintext. Call it only to display or copy the value. +func (v SecretValue) Reveal() string { return string(v.b) } + +func (v SecretValue) Len() int { return len(v.b) } + +func (v SecretValue) String() string { return masked } +func (v SecretValue) GoString() string { return masked } + +// Format covers every fmt verb, including %#v and %x. +func (v SecretValue) Format(f fmt.State, _ rune) { _, _ = f.Write([]byte(masked)) } + +func (v SecretValue) LogValue() slog.Value { return slog.StringValue(masked) } + +func (v SecretValue) MarshalText() ([]byte, error) { return []byte(masked), nil } diff --git a/internal/provider/value_test.go b/internal/provider/value_test.go new file mode 100644 index 0000000..299a08a --- /dev/null +++ b/internal/provider/value_test.go @@ -0,0 +1,51 @@ +package provider + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "strings" + "testing" +) + +func TestSecretValueNeverFormatsPlaintext(t *testing.T) { + const plain = "hunter2" + v := NewSecretValue([]byte(plain)) + wrapped := struct{ V SecretValue }{v} + + var outputs []string + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%X", "%d"} { + outputs = append(outputs, fmt.Sprintf(verb, v), fmt.Sprintf(verb, wrapped)) + } + outputs = append(outputs, fmt.Sprint(v), fmt.Errorf("failed: %v", v).Error()) + + js, err := json.Marshal(wrapped) + if err != nil { + t.Fatal(err) + } + outputs = append(outputs, string(js)) + + var buf bytes.Buffer + slog.New(slog.NewJSONHandler(&buf, nil)).Info("x", "value", v) + slog.New(slog.NewTextHandler(&buf, nil)).Info("x", "value", v) + outputs = append(outputs, buf.String()) + + for _, out := range outputs { + if strings.Contains(out, plain) || strings.Contains(out, fmt.Sprintf("%x", plain)) { + t.Errorf("plaintext leaked: %q", out) + } + } + if v.Reveal() != plain { + t.Errorf("Reveal() = %q, want %q", v.Reveal(), plain) + } +} + +func TestNewSecretValueCopiesInput(t *testing.T) { + b := []byte("abc") + v := NewSecretValue(b) + b[0] = 'x' + if v.Reveal() != "abc" { + t.Errorf("value aliased caller's slice: %q", v.Reveal()) + } +} diff --git a/internal/ref/ref.go b/internal/ref/ref.go new file mode 100644 index 0000000..1074bd7 --- /dev/null +++ b/internal/ref/ref.go @@ -0,0 +1,375 @@ +// Package ref parses and builds references to vaults, scopes and secrets: +// lazysecrets:// links, and the formats people already share (Key Vault +// identifier URLs, App Service @Microsoft.KeyVault references, portal links +// and Databricks {{secrets/scope/key}}). +// +// A link names a vault, scope or object; what to do with it is up to +// whoever opens it: +// +// lazysecrets://azure/subscriptions/ +// lazysecrets://azure/[/[/[/]]] +// lazysecrets://databricks/[/[/]] +// lazysecrets://repo/[/[/]] +// +// A repo is its git remote without scheme or .git +// (git.kebcloud.no/keb/infra), the file its path in the repo and the key +// its path in the file (db.password); slashes inside them are escaped. +// +// Vault hosts always contain dots, so "subscriptions" can't be one. +// +// Every path segment is percent-encoded, so names may hold any character. +// Query parameters are ignored (early links carried ?action=). +package ref + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "strings" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +const Scheme = "lazysecrets" + +// Ref points at a container, or an object in one. +type Ref struct { + Kind provider.Kind + // Host is the vault host (Azure), workspace host (Databricks) or repo + // (Repo). It is empty for a bare {{secrets/scope/key}}, whose workspace + // is unknown. + Host string + // Scope is the Databricks scope or the repo's file; empty for Azure. + Scope string + // Subscription is set for a link to an Azure subscription. + Subscription string + // Type is the object type, or for an Azure vault link the tab to show + // (empty: the default tab). + Type provider.ObjectType + Name string // object name; empty for a container link + Version string +} + +// IsSource reports whether r points at a subscription or workspace. +func (r Ref) IsSource() bool { + if r.Kind != provider.KindAzure { + return r.Scope == "" + } + return r.Subscription != "" +} + +// IsContainer reports whether r points at a vault or scope. +func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() } + +// ContainerName is the vault name (from its host) or the scope. +func (r Ref) ContainerName() string { + if r.Kind != provider.KindAzure { + return r.Scope + } + name, _, _ := strings.Cut(r.Host, ".") + return name +} + +// VaultURL is the Azure vault's URL, as providers store it in +// Container.Detail. +func (r Ref) VaultURL() string { return "https://" + r.Host + "/" } + +var collections = map[provider.ObjectType]string{ + provider.TypeSecret: "secrets", + provider.TypeKey: "keys", + provider.TypeCertificate: "certificates", +} + +func typeOf(collection string) (provider.ObjectType, bool) { + for t, c := range collections { + if strings.EqualFold(c, collection) { + return t, true + } + } + return "", false +} + +// IdentifierURL is the plain Key Vault URL of an Azure reference, e.g. +// https://kv.vault.azure.net/secrets/name[/version], usable without +// lazysecrets (az keyvault secret show --id). Empty for Databricks. +func (r Ref) IdentifierURL() string { + if r.Kind != provider.KindAzure { + return "" + } + u := "https://" + r.Host + if r.Type == "" { + return u + "/" + } + for _, seg := range []string{collections[r.Type], r.Name, r.Version} { + if seg != "" { + u += "/" + url.PathEscape(seg) + } + } + return u +} + +// String is the lazysecrets:// link. +func (r Ref) String() string { + if r.Kind == provider.KindAzure && r.Subscription != "" { + return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription) + } + segs := []string{strings.ToLower(string(r.Kind)), r.Host} + switch r.Kind { + case provider.KindDatabricks, provider.KindRepo: + segs = append(segs, r.Scope, r.Name) + default: + if r.Type != "" { + segs = append(segs, collections[r.Type]) + } + segs = append(segs, r.Name, r.Version) + } + for len(segs) > 2 && segs[len(segs)-1] == "" { + segs = segs[:len(segs)-1] + } + for i := range segs { + segs[i] = url.PathEscape(segs[i]) + if r.Kind == provider.KindRepo && i > 0 { + // Repos and files have slashes: keep them in their part. + segs[i] = strings.ReplaceAll(segs[i], "/", "%2F") + } + } + return Scheme + "://" + strings.Join(segs, "/") +} + +// ForSource is a link to a subscription or workspace. +func ForSource(src provider.Source) (Ref, error) { + switch src.Kind { + case provider.KindAzure: + return Ref{Kind: provider.KindAzure, Subscription: src.ID}, nil + case provider.KindDatabricks: + if src.Detail == "" { + return Ref{}, errors.New("workspace host unknown") + } + return Ref{Kind: provider.KindDatabricks, Host: strings.ToLower(src.Detail)}, nil + case provider.KindRepo: + return Ref{Kind: provider.KindRepo, Host: strings.ToLower(src.Detail)}, nil + } + return Ref{}, fmt.Errorf("can't link to %s", src.Kind) +} + +// ForContainer is a link to a vault or scope. +func ForContainer(c provider.Container, tab provider.ObjectType) (Ref, error) { + switch c.Source.Kind { + case provider.KindAzure: + host, err := vaultHost(c.Detail) + if err != nil { + return Ref{}, err + } + if tab == provider.TypeSecret { + tab = "" // the default tab + } + return Ref{Kind: provider.KindAzure, Host: host, Type: tab}, nil + case provider.KindDatabricks: + if c.Source.Detail == "" { + return Ref{}, errors.New("workspace host unknown") + } + return Ref{Kind: provider.KindDatabricks, Host: strings.ToLower(c.Source.Detail), Scope: c.Name}, nil + case provider.KindRepo: + return Ref{Kind: provider.KindRepo, Host: strings.ToLower(c.Source.Detail), Scope: c.Name}, nil + } + return Ref{}, fmt.Errorf("can't link to %s", c.Source.Kind) +} + +// ForObject is a link to an object, pinned to its version if given. +func ForObject(o provider.Object, version string) (Ref, error) { + r, err := ForContainer(o.Container, o.Type) + if err != nil { + return Ref{}, err + } + r.Name, r.Type = o.Name, o.Type + if r.Kind == provider.KindAzure { + r.Version = version // Databricks secrets have no versions + } + return r, nil +} + +func vaultHost(vaultURL string) (string, error) { + u, err := url.Parse(vaultURL) + if err != nil || u.Host == "" { + return "", fmt.Errorf("not a vault URL: %q", vaultURL) + } + return strings.ToLower(u.Host), nil +} + +var ( + errUnknown = errors.New("not a lazysecrets link or a Key Vault/Databricks reference") + + // @Microsoft.KeyVault(SecretUri=…) or (VaultName=…;SecretName=…[;SecretVersion=…]) + appServiceRef = regexp.MustCompile(`(?i)^@Microsoft\.KeyVault\((.*)\)$`) + // {{secrets/scope/key}} + databricksRef = regexp.MustCompile(`^\{\{\s*secrets/([^/{}\s]+)/([^/{}\s]+)\s*\}\}$`) + // …/providers/Microsoft.KeyVault/vaults/ in a portal link + portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`) + // …/asset/Microsoft_Azure_KeyVault// + portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`) +) + +// Parse reads any supported reference. +func Parse(s string) (Ref, error) { + s = strings.TrimSpace(s) + switch { + case strings.HasPrefix(strings.ToLower(s), Scheme+"://"): + return parseLink(s) + case appServiceRef.MatchString(s): + return parseAppService(appServiceRef.FindStringSubmatch(s)[1]) + case databricksRef.MatchString(s): + m := databricksRef.FindStringSubmatch(s) + return Ref{Kind: provider.KindDatabricks, Scope: m[1], Name: m[2], Type: provider.TypeSecret}, nil + case strings.Contains(s, "portal.azure.com"): + return parsePortal(s) + case strings.HasPrefix(strings.ToLower(s), "https://"): + return parseIdentifier(s) + } + return Ref{}, errUnknown +} + +func parseLink(s string) (Ref, error) { + u, err := url.Parse(s) + if err != nil { + return Ref{}, fmt.Errorf("bad link: %w", err) + } + segs, err := pathSegments(u.EscapedPath()) + if err != nil { + return Ref{}, err + } + var r Ref + if len(segs) == 0 || segs[0] == "" { + return Ref{}, errors.New("link has no vault or workspace") + } + r.Host = strings.ToLower(segs[0]) + rest := segs[1:] + + switch strings.ToLower(u.Host) { + case "azure": + r.Kind = provider.KindAzure + if r.Host == "subscriptions" { + if len(rest) != 1 || rest[0] == "" { + return Ref{}, errors.New("subscription link needs exactly one subscription ID") + } + return Ref{Kind: provider.KindAzure, Subscription: rest[0]}, nil + } + if len(rest) > 3 { + return Ref{}, errors.New("azure link has too many parts") + } + if len(rest) > 0 { + t, ok := typeOf(rest[0]) + if !ok { + return Ref{}, fmt.Errorf("unknown object type %q (want secrets, keys or certificates)", rest[0]) + } + r.Type = t + } + if len(rest) > 1 { + r.Name = rest[1] + } + if len(rest) > 2 { + r.Version = rest[2] + } + case "databricks", "repo": + r.Kind = provider.KindDatabricks + if strings.EqualFold(u.Host, "repo") { + r.Kind = provider.KindRepo + } + if len(rest) > 2 { + return Ref{}, fmt.Errorf("%s link has too many parts", strings.ToLower(u.Host)) + } + if len(rest) > 0 { + r.Scope = rest[0] + } + if len(rest) > 1 { + r.Name, r.Type = rest[1], provider.TypeSecret + } + + default: + return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host) + } + return r, nil +} + +// pathSegments splits an escaped path on / and unescapes each segment, so +// an escaped slash (%2F) stays inside its segment. +func pathSegments(escaped string) ([]string, error) { + escaped = strings.Trim(escaped, "/") + if escaped == "" { + return nil, nil + } + parts := strings.Split(escaped, "/") + for i, p := range parts { + s, err := url.PathUnescape(p) + if err != nil { + return nil, fmt.Errorf("bad link: %w", err) + } + parts[i] = s + } + return parts, nil +} + +// parseIdentifier reads a Key Vault URL: a vault, or an object identifier +// like https://kv.vault.azure.net/secrets/name[/version]. +func parseIdentifier(s string) (Ref, error) { + u, err := url.Parse(s) + if err != nil || u.Host == "" { + return Ref{}, errUnknown + } + host := strings.ToLower(u.Host) + if !strings.Contains(host, ".vault.") { + return Ref{}, errUnknown + } + segs, err := pathSegments(u.EscapedPath()) + if err != nil { + return Ref{}, err + } + r := Ref{Kind: provider.KindAzure, Host: host} + if len(segs) > 0 { + t, ok := typeOf(segs[0]) + if !ok { + return Ref{}, fmt.Errorf("unknown Key Vault path %q", u.Path) + } + r.Type = t + } + if len(segs) > 1 { + r.Name = segs[1] + } + if len(segs) > 2 { + r.Version = segs[2] + } + return r, nil +} + +func parseAppService(args string) (Ref, error) { + fields := map[string]string{} + for _, kv := range strings.Split(args, ";") { + k, v, ok := strings.Cut(kv, "=") + if ok { + fields[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v) + } + } + if uri := fields["secreturi"]; uri != "" { + return parseIdentifier(uri) + } + vault, name := fields["vaultname"], fields["secretname"] + if vault == "" || name == "" { + return Ref{}, errors.New("@Microsoft.KeyVault reference needs SecretUri, or VaultName and SecretName") + } + return Ref{ + Kind: provider.KindAzure, Host: strings.ToLower(vault) + ".vault.azure.net", + Type: provider.TypeSecret, Name: name, Version: fields["secretversion"], + }, nil +} + +// parsePortal reads Azure portal links to a vault or a Key Vault object. +func parsePortal(s string) (Ref, error) { + if m := portalAsset.FindStringSubmatch(s); m != nil { + return parseIdentifier(m[1]) + } + if m := portalVault.FindStringSubmatch(s); m != nil { + // Portal vault links don't carry the cloud; assume public Azure. + return Ref{Kind: provider.KindAzure, Host: strings.ToLower(m[1]) + ".vault.azure.net"}, nil + } + return Ref{}, errUnknown +} diff --git a/internal/ref/ref_test.go b/internal/ref/ref_test.go new file mode 100644 index 0000000..f72e2aa --- /dev/null +++ b/internal/ref/ref_test.go @@ -0,0 +1,192 @@ +package ref + +import ( + "reflect" + "strings" + "testing" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +const ( + kvHost = "kv-app-prod.vault.azure.net" + dbHost = "adb-1111111111111111.11.azuredatabricks.net" +) + +func TestParse(t *testing.T) { + secret := Ref{Kind: provider.KindAzure, Host: kvHost, Type: provider.TypeSecret, Name: "db-password"} + versioned := secret + versioned.Version = "91d3e0" + vault := Ref{Kind: provider.KindAzure, Host: kvHost} + keysTab := vault + keysTab.Type = provider.TypeKey + cert := Ref{Kind: provider.KindAzure, Host: kvHost, Type: provider.TypeCertificate, Name: "app-tls"} + scope := Ref{Kind: provider.KindDatabricks, Host: dbHost, Scope: "app"} + dbxSecret := Ref{Kind: provider.KindDatabricks, Host: dbHost, Scope: "app", Name: "storage-key", Type: provider.TypeSecret} + + for in, want := range map[string]Ref{ + // lazysecrets links + "lazysecrets://azure/" + kvHost: vault, + "lazysecrets://azure/" + kvHost + "/": vault, + "lazysecrets://azure/" + kvHost + "/keys": keysTab, + "lazysecrets://azure/" + kvHost + "/secrets/db-password": secret, + "lazysecrets://azure/" + kvHost + "/secrets/db-password/91d3e0": versioned, + "lazysecrets://azure/" + kvHost + "/secrets/db-password?action=copy": secret, // early links: ignored + "LazySecrets://Azure/" + strings.ToUpper(kvHost) + "/secrets/db-password": secret, + "lazysecrets://databricks/" + dbHost + "/app": scope, + "lazysecrets://databricks/" + dbHost + "/app/storage-key": dbxSecret, + // formats people already share + "https://" + kvHost + "/secrets/db-password": secret, + "https://" + kvHost + "/secrets/db-password/91d3e0": versioned, + "https://" + kvHost + "/": vault, + "https://" + kvHost + "/certificates/app-tls": cert, + "@Microsoft.KeyVault(SecretUri=https://" + kvHost + "/secrets/db-password/)": secret, + "@Microsoft.KeyVault(VaultName=kv-app-prod;SecretName=db-password)": secret, + "@Microsoft.KeyVault(VaultName=kv-app-prod;SecretName=db-password;SecretVersion=91d3e0)": versioned, + " {{secrets/app/storage-key}} ": {Kind: provider.KindDatabricks, Scope: "app", Name: "storage-key", Type: provider.TypeSecret}, + "https://portal.azure.com/#@t/resource/subscriptions/s/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv-app-prod/overview": vault, + "https://portal.azure.com/#@t/asset/Microsoft_Azure_KeyVault/Certificate/https://" + kvHost + "/certificates/app-tls": cert, + } { + got, err := Parse(in) + if err != nil { + t.Errorf("Parse(%q): %v", in, err) + continue + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Parse(%q)\n got %+v\nwant %+v", in, got, want) + } + } +} + +func TestParseRejects(t *testing.T) { + for _, in := range []string{ + "", + "hunter2", + "https://example.com/secrets/x", + "lazysecrets://aws/secret", + "lazysecrets://azure/", + "lazysecrets://azure/" + kvHost + "/passwords/x", + "lazysecrets://azure/" + kvHost + "/secrets/a/b/c", + "lazysecrets://azure/subscriptions/", + "lazysecrets://azure/subscriptions/a/b", + "@Microsoft.KeyVault(VaultName=kv)", + } { + if r, err := Parse(in); err == nil { + t.Errorf("Parse(%q) = %+v, want an error", in, r) + } + } +} + +func TestLinksRoundTrip(t *testing.T) { + kv := provider.Container{ + Source: provider.Source{Kind: provider.KindAzure, ID: "sub"}, + Name: "kv-app-prod", Detail: "https://" + kvHost + "/", + } + scope := provider.Container{ + Source: provider.Source{Kind: provider.KindDatabricks, ID: "prod", Detail: dbHost}, + Name: "team@data", + } + key := provider.Object{Container: kv, Type: provider.TypeKey, Name: "signing-key"} + dbx := provider.Object{Container: scope, Type: provider.TypeSecret, Name: "storage.key"} + + links := map[string]func() (Ref, error){ + "lazysecrets://azure/" + kvHost: func() (Ref, error) { return ForContainer(kv, provider.TypeSecret) }, + "lazysecrets://azure/" + kvHost + "/keys": func() (Ref, error) { return ForContainer(kv, provider.TypeKey) }, + "lazysecrets://azure/" + kvHost + "/keys/signing-key/v9": func() (Ref, error) { return ForObject(key, "v9") }, + "lazysecrets://databricks/" + dbHost + "/team@data": func() (Ref, error) { return ForContainer(scope, provider.TypeSecret) }, + "lazysecrets://databricks/" + dbHost + "/team@data/storage.key": func() (Ref, error) { return ForObject(dbx, "") }, + } + for want, build := range links { + r, err := build() + if err != nil { + t.Fatal(err) + } + if got := r.String(); got != want { + t.Errorf("link = %q, want %q", got, want) + } + back, err := Parse(r.String()) + if err != nil { + t.Fatalf("Parse(%q): %v", r.String(), err) + } + if !reflect.DeepEqual(back, r) { + t.Errorf("round trip of %q\n got %+v\nwant %+v", want, back, r) + } + } + +} + +func TestSlashInNamesIsEscaped(t *testing.T) { + r := Ref{Kind: provider.KindDatabricks, Host: dbHost, Scope: "a/b", Name: "c", Type: provider.TypeSecret} + back, err := Parse(r.String()) + if err != nil || back.Scope != "a/b" || back.Name != "c" { + t.Errorf("%q → %+v, %v", r.String(), back, err) + } +} + +func TestIdentifierURL(t *testing.T) { + for in, want := range map[string]string{ + "lazysecrets://azure/" + kvHost + "/secrets/db-password/91d3e0": "https://" + kvHost + "/secrets/db-password/91d3e0", + "lazysecrets://azure/" + kvHost + "/keys/signing-key": "https://" + kvHost + "/keys/signing-key", + "lazysecrets://azure/" + kvHost: "https://" + kvHost + "/", + "lazysecrets://databricks/" + dbHost + "/app/k": "", + } { + r, err := Parse(in) + if err != nil { + t.Fatal(err) + } + if got := r.IdentifierURL(); got != want { + t.Errorf("%s: %q, want %q", in, got, want) + } + } +} + +func TestSourceLinks(t *testing.T) { + az := provider.Source{Kind: provider.KindAzure, ID: "4d3073a0-1111"} + dbx := provider.Source{Kind: provider.KindDatabricks, ID: "prod", Detail: dbHost} + for src, want := range map[provider.Source]string{ + az: "lazysecrets://azure/subscriptions/4d3073a0-1111", + dbx: "lazysecrets://databricks/" + dbHost, + } { + r, err := ForSource(src) + if err != nil { + t.Fatal(err) + } + if r.String() != want || !r.IsSource() || r.IsContainer() { + t.Errorf("%s: %q source=%v container=%v", src.Kind, r.String(), r.IsSource(), r.IsContainer()) + } + back, err := Parse(want) + if err != nil || !reflect.DeepEqual(back, r) { + t.Errorf("round trip %q: %+v, %v", want, back, err) + } + } + // Vault and scope links aren't source links. + for _, link := range []string{"lazysecrets://azure/" + kvHost, "lazysecrets://databricks/" + dbHost + "/app"} { + if r, _ := Parse(link); r.IsSource() || !r.IsContainer() { + t.Errorf("%s: source=%v", link, r.IsSource()) + } + } +} + +func TestRepoLinks(t *testing.T) { + src := provider.Source{Kind: provider.KindRepo, Detail: "git.kebcloud.no/keb/nix-config"} + c := provider.Container{Source: src, Name: "secrets/server.yaml"} + r, err := ForObject(provider.Object{Container: c, Name: "db.password", Type: provider.TypeSecret}, "") + if err != nil { + t.Fatal(err) + } + link := r.String() + if link != "lazysecrets://repo/git.kebcloud.no%2Fkeb%2Fnix-config/secrets%2Fserver.yaml/db.password" { + t.Errorf("link %s", link) + } + back, err := Parse(link) + if err != nil || back.Kind != provider.KindRepo || back.Host != "git.kebcloud.no/keb/nix-config" || + back.Scope != "secrets/server.yaml" || back.Name != "db.password" { + t.Errorf("parsed %+v, %v", back, err) + } + if fr, _ := ForSource(src); !fr.IsSource() || fr.String() != "lazysecrets://repo/git.kebcloud.no%2Fkeb%2Fnix-config" { + t.Errorf("source link %s", fr.String()) + } + if cr, _ := ForContainer(c, provider.TypeSecret); !cr.IsContainer() || cr.ContainerName() != "secrets/server.yaml" { + t.Errorf("file link %+v", cr) + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..b7596c7 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,301 @@ +// Package store keeps what lazysecrets remembers between runs in one JSON +// file in the XDG state directory: a record per subscription, vault or +// secret the user has starred, annotated or used, keyed by its +// lazysecrets:// link, and the layout. It never holds secret values. +// +// JSON rather than YAML (the config's format) because the app writes it, +// and so an editor plugin can read it with a built-in parser +// (vim.json.decode). +package store + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "sync" + "time" +) + +// Node is what's remembered about one subscription, vault or secret. Name, +// Path, Source and Account are context, so lists of nodes (favourites, +// recent) can show and jump to them before their subscription is loaded. +type Node struct { + Name string `json:"name,omitempty"` + Path []string `json:"path,omitempty"` // parents' names: [source] or [source, vault] + Source string `json:"source,omitempty"` // ID of the subscription or workspace profile + Account string `json:"account,omitempty"` // provider ID that last read it + + // StarredBy lists the accounts (provider IDs) the item is starred + // for: a star is the item through that account. A note describes the + // item itself, whichever account shows it. + StarredBy []string `json:"starredBy,omitempty"` + Note string `json:"note,omitempty"` + + LastUsed time.Time `json:"lastUsed,omitzero"` + Uses int `json:"uses,omitempty"` +} + +// keep reports whether the record holds anything worth keeping; context +// alone isn't. +func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 } + +// StarredFor reports whether the item is starred for account. +func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) } + +// ToggleStar stars or unstars the item for account, reporting whether +// it's starred now. +func (n *Node) ToggleStar(account string) bool { + if i := slices.Index(n.StarredBy, account); i >= 0 { + n.StarredBy = slices.Delete(n.StarredBy, i, i+1) + return false + } + n.StarredBy = append(n.StarredBy, account) + return true +} + +// UpdateCheck is the last check for a newer release. +func (s *Store) UpdateCheck() UpdateCheck { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.Update +} + +// SetUpdateCheck records a check for a newer release. +func (s *Store) SetUpdateCheck(c UpdateCheck) error { + return s.save(func(d *file) { d.Update = c }) +} + +// Layout is the user's pane sizing, as offsets from the default layout so +// it adapts to the terminal size. +type Layout struct { + Split int `json:"split,omitempty"` // columns the divider moved + Heights [3]int `json:"heights,omitzero"` // rows added to each left pane +} + +type file struct { + Version int `json:"version"` + Nodes map[string]Node `json:"nodes"` + Layout Layout `json:"layout,omitzero"` + // Flags are one-off choices, like "don't ask again". + Flags map[string]bool `json:"flags,omitempty"` + // Update is the last check for a newer release. + Update UpdateCheck `json:"update,omitzero"` +} + +// UpdateCheck is the last look for a newer release: when, and what the +// latest was (its tag, changelog and page), so the next start within a +// day shows it without asking again. +type UpdateCheck struct { + At time.Time `json:"at,omitzero"` + Tag string `json:"tag,omitempty"` + Notes string `json:"notes,omitempty"` + URL string `json:"url,omitempty"` +} + +const version = 1 + +type Store struct { + path string + mu sync.Mutex + data file +} + +// Load reads path; a missing file is an empty store. path "" gives a store +// that never saves, for tests and --demo. +func Load(path string) (*Store, error) { + s := &Store{path: path} + d, err := read(path) + if err != nil { + return nil, err + } + s.data = d + return s, nil +} + +func read(path string) (file, error) { + d := file{Version: version, Nodes: map[string]Node{}} + if path == "" { + return d, nil + } + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return d, nil + } + if err != nil { + return d, err + } + if err := json.Unmarshal(b, &d); err != nil { + return d, fmt.Errorf("%s: %w", path, err) + } + if d.Version > version { + return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version) + } + if d.Nodes == nil { + d.Nodes = map[string]Node{} + } + return d, nil +} + +// Node returns the record for key, empty if there is none. +func (s *Store) Node(key string) Node { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.Nodes[key] +} + +// Has reports whether there's a record for key. +func (s *Store) Has(key string) bool { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.data.Nodes[key] + return ok +} + +// Nodes returns a copy of all records. +func (s *Store) Nodes() map[string]Node { + s.mu.Lock() + defer s.mu.Unlock() + return maps.Clone(s.data.Nodes) +} + +// Update changes the record for key and saves. Another lazysecrets may +// have saved in the meantime, so the file is read again first and only +// this record is changed in it. A record left with nothing worth keeping +// is removed. +func (s *Store) Update(key string, change func(*Node)) error { + return s.save(func(d *file) { + n := d.Nodes[key] + change(&n) + if n.keep() { + d.Nodes[key] = n + } else { + delete(d.Nodes, key) + } + }) +} + +// Usage records — ones kept only for their use — are pruned when they +// go unused this long, and beyond this many, oldest first. +const ( + usageMaxAge = 90 * 24 * time.Hour + usageMax = 100 +) + +// Use counts a use of key at the given time, with change updating its +// context, and prunes old usage records. +func (s *Store) Use(key string, at time.Time, change func(*Node)) error { + return s.save(func(d *file) { + n := d.Nodes[key] + change(&n) + n.Uses++ + n.LastUsed = at + d.Nodes[key] = n + prune(d.Nodes, at) + }) +} + +// prune drops usage records unused since usageMaxAge before now, and the +// oldest beyond usageMax. Starred and noted records stay. +func prune(nodes map[string]Node, now time.Time) { + var usage []string + for k, n := range nodes { + if len(n.StarredBy) > 0 || n.Note != "" { + continue + } + if now.Sub(n.LastUsed) > usageMaxAge { + delete(nodes, k) + continue + } + usage = append(usage, k) + } + if len(usage) <= usageMax { + return + } + slices.SortFunc(usage, func(a, b string) int { return nodes[b].LastUsed.Compare(nodes[a].LastUsed) }) + for _, k := range usage[usageMax:] { + delete(nodes, k) + } +} + +// Flag reports a one-off choice. +func (s *Store) Flag(name string) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.Flags[name] +} + +// SetFlag records a one-off choice. +func (s *Store) SetFlag(name string, on bool) error { + return s.save(func(d *file) { + if d.Flags == nil { + d.Flags = map[string]bool{} + } + if on { + d.Flags[name] = true + } else { + delete(d.Flags, name) + } + }) +} + +func (s *Store) Layout() Layout { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.Layout +} + +func (s *Store) SetLayout(l Layout) error { + return s.save(func(d *file) { d.Layout = l }) +} + +// save applies change to the file as it is now on disk (or to memory, for +// an unsaved store) and writes it atomically, readable by the user only. +func (s *Store) save(change func(*file)) error { + s.mu.Lock() + defer s.mu.Unlock() + d := s.data + if s.path != "" { + var err error + if d, err = read(s.path); err != nil { + return err + } + } else { + d.Nodes = maps.Clone(d.Nodes) + } + change(&d) + d.Version = version + if s.path != "" { + b, err := json.MarshalIndent(d, "", " ") + if err != nil { + return err + } + if err := writeAtomic(s.path, append(b, '\n')); err != nil { + return err + } + } + s.data = d + return nil +} + +func writeAtomic(path string, b []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) // no-op after a successful rename + if _, err := tmp.Write(b); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..37d8caf --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,114 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +const key = "lazysecrets://azure/kv.vault.azure.net/secrets/db-password" + +func TestUpdateAndReload(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "state.json") + s, err := Load(path) + if err != nil { + t.Fatal(err) + } + if err := s.Update(key, func(n *Node) { + n.Name, n.Path, n.StarredBy, n.Note = "db-password", []string{"prod", "kv"}, []string{"Azure"}, "prod SQL admin" + }); err != nil { + t.Fatal(err) + } + if err := s.SetLayout(Layout{Split: 8}); err != nil { + t.Fatal(err) + } + again, err := Load(path) + if err != nil { + t.Fatal(err) + } + if n := again.Node(key); !n.StarredFor("Azure") || n.Note != "prod SQL admin" || n.Path[1] != "kv" { + t.Errorf("reloaded %+v", n) + } + if again.Layout().Split != 8 { + t.Error("layout lost") + } + if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 { + t.Errorf("mode %o", info.Mode().Perm()) + } +} + +func TestContextAloneIsntKept(t *testing.T) { + s, _ := Load("") + _ = s.Update(key, func(n *Node) { n.StarredBy, n.Name = []string{"Azure"}, "db-password" }) + _ = s.Update(key, func(n *Node) { n.StarredBy = nil }) + if _, ok := s.Nodes()[key]; ok { + t.Error("a record with only context left was kept") + } +} + +func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + a, _ := Load(path) + b, _ := Load(path) + _ = a.Update("one", func(n *Node) { n.StarredBy = []string{"Azure"} }) + _ = b.Update("two", func(n *Node) { n.Note = "hi" }) // b hasn't seen "one" + _ = a.Update("one", func(n *Node) { n.Uses++; n.LastUsed = time.Now() }) + + final, _ := Load(path) + if n := final.Node("one"); !n.StarredFor("Azure") || n.Uses != 1 { + t.Errorf("one = %+v", n) + } + if final.Node("two").Note != "hi" { + t.Error("b's change lost") + } +} + +func TestNewerVersionRefused(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + _ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600) + if _, err := Load(path); err == nil { + t.Error("a newer file was accepted") + } +} + +func TestUseCountsAndPrunes(t *testing.T) { + s, _ := Load("") + now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + ctx := func(n *Node) { n.Name = "x" } + _ = s.Use("old", now.Add(-100*24*time.Hour), ctx) + _ = s.Update("old-starred", func(n *Node) { n.StarredBy = []string{"Azure"}; n.Uses = 1 }) + _ = s.Use("a", now.Add(-time.Hour), ctx) + _ = s.Use("a", now, ctx) + if n := s.Node("a"); n.Uses != 2 || !n.LastUsed.Equal(now) || n.Name != "x" { + t.Errorf("a = %+v", n) + } + if s.Has("old") { + t.Error("record unused for 100 days kept") + } + if !s.Has("old-starred") { + t.Error("starred record pruned") + } + + for i := range usageMax + 5 { + _ = s.Use(fmt.Sprint("n", i), now.Add(time.Duration(i)*time.Minute), ctx) + } + if got := len(s.Nodes()); got != usageMax+1 { // + old-starred + t.Errorf("%d records, want %d", got, usageMax+1) + } + if s.Has("n0") || !s.Has(fmt.Sprint("n", usageMax+4)) { + t.Error("pruned the wrong end") + } +} + +func TestStarsPerAccount(t *testing.T) { + var n Node + if !n.ToggleStar("Azure:alice") || !n.StarredFor("Azure:alice") || n.StarredFor("Azure:bob") { + t.Errorf("star for alice: %v", n.StarredBy) + } + n.ToggleStar("Azure:bob") + if n.ToggleStar("Azure:alice") || n.StarredFor("Azure:alice") || !n.StarredFor("Azure:bob") { + t.Errorf("unstar alice: %v", n.StarredBy) + } +} diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go new file mode 100644 index 0000000..e1c0e07 --- /dev/null +++ b/internal/ui/app/app.go @@ -0,0 +1,1576 @@ +// Package app is the root Bubble Tea model: layout, focus, key routing and +// asynchronous loading from providers. +package app + +import ( + "context" + "errors" + "fmt" + "image/color" + "math" + "os/exec" + "strings" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/browser" + "git.kebcloud.no/keb/lazysecrets/internal/clipboard" + "git.kebcloud.no/keb/lazysecrets/internal/commands" + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/store" + "git.kebcloud.no/keb/lazysecrets/internal/ui/icons" + "git.kebcloud.no/keb/lazysecrets/internal/ui/keys" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" + "git.kebcloud.no/keb/lazysecrets/internal/update" +) + +type pane int + +const ( + paneSources pane = iota + paneContainers + paneSecrets + paneDetail + paneCount +) + +// slot identifies a kind of request; starting one cancels the previous +// request in the same slot. +type slot int + +const ( + slotContainers slot = iota + slotObjects + slotValue // reveal in the detail pane + slotDescribe // extra metadata for the selected object + slotCopy // fetch a value straight to the clipboard + slotVersions // count the selected object's versions + slotCount +) + +// Options configures the app. +type Options struct { + // Timeout bounds every provider call. Defaults to 30s. + Timeout time.Duration + // Icons shows Nerd Font glyphs next to backends. + Icons bool + // Clipboard defaults to the system clipboard, or OSC52 over SSH. + Clipboard Clipboard + // ClipboardClear clears copied values after this long; 0 disables. + ClipboardClear time.Duration + // OpenURL opens a web page; defaults to the system browser. + OpenURL func(url string) error + // FlatSources lists sources without provider groups. + FlatSources bool + // CollapseGroups starts with the Sources pane's groups folded, when + // there are several. + CollapseGroups bool + // Store keeps stars, notes and the layout; defaults to an unsaved one. + Store *store.Store + // LoadDelay is how long the cursor must rest on a row before its + // contents load (settled); 0 loads at once. + LoadDelay time.Duration + // ExpiryWarning marks items expiring within this long; 0 marks only + // expired ones. + ExpiryWarning time.Duration + // RevealTimeout hides a revealed value after this long; 0 never. + RevealTimeout time.Duration + // HideOnBlur hides a revealed value when the terminal loses focus. + HideOnBlur bool + // OnActive is called on key presses and when the terminal gains + // focus, so the link handler can pick the instance last used. + OnActive func() + // Version is shown at the bottom right ("0.1.0"); empty shows none. + Version string + // Commands are the custom commands from the config (commands.go). + Commands []commands.Command + // Keys, if set, replaces the default keymap (keys.Map.Apply). + Keys *keys.Map + // Updater, if set, finds and installs newer releases (U); with + // UpdateCheck, lazysecrets also looks once a day on its own. + Updater *Updater + UpdateCheck bool + // HandlerSetup, if set, offers to set up the link handler at startup. + HandlerSetup *HandlerSetup + // NoHistory stops counting uses of items for the Recent list. + NoHistory bool + // Open is a reference to open at startup (lazysecrets open ). + Open *ref.Ref + // Styles, if set, replaces the default theme (styles.FromTheme), and + // DefaultFg the terminal's default text colour. + Styles *styles.Styles + DefaultFg color.Color +} + +// Clipboard is implemented by clipboard.Clipboard. +type Clipboard interface { + Write(s string) tea.Cmd + Clear(fingerprint [32]byte) tea.Cmd + // Read returns the clipboard's text, or an error where it can't be + // read (over SSH). + Read() (string, error) +} + +type Model struct { + providers []provider.Provider + byID map[string]provider.Provider + timeout time.Duration + + keys keys.Map + st styles.Styles + help help.Model + spin spinner.Model + + sources panes.List[sourceRow] + containers panes.List[provider.Container] + secrets panes.List[provider.Object] + detail panes.Detail + + focus pane + termBlurred bool // the terminal (or tmux pane) doesn't have focus + w, h int + split int // columns the user moved the divider from its default + heights [3]int // rows added to each left pane's default height + zoom zoom + spinning bool + status string // sticky errors from loading sources + flash flash // transient message, cleared on the next key press + + // auth is set when a backend needs the user to sign in again. + auth *provider.AuthError + + defaultFg color.Color // from the theme; nil keeps the terminal's + store *store.Store + icons icons.Set + + cache cache + + clip Clipboard + clipClear time.Duration + openURL func(string) error + now func() time.Time + + // A pending clear of a copied value, counted down in the footer. + // clipToken identifies the latest copy so older ticks are ignored. + clipToken int + clipFP [32]byte // fingerprint of the copied value + clipLabel string + clipDeadline time.Time // zero when nothing is pending + + collapsed map[string]bool // folded groups in the Sources pane, by provider ID + // current is the source lazysecrets started in, as soon as its + // provider finds it (provider.Current), before Sources lists them all. + current *provider.Source + flatSources bool // Sources pane without provider groups + sourcesFavs bool // Sources pane on its Favourites tab + history bool // count uses of items (usage.go) + onActive func() + + // A revealed value hides at revealDeadline; revealToken drops ticks + // of an earlier reveal. + revealTimeout time.Duration + expiryWarning time.Duration + hideOnBlur bool + revealDeadline time.Time + revealToken int + + commands []commands.Command // custom commands (commands.go) + version string // shown at the bottom right + crawl crawl // loading every vault for search (search.go) + search *searchView // the search popup, when open (searchview.go) + + handlerSetup *HandlerSetup + + updater *Updater + updateCheck bool + fetchOnStart bool // look for a newer release in Init + newer *update.Release // a newer release to offer + updated string // the release installed, waiting for a restart + askHandler bool // the link handler popup is still to show + + // Loads the selection starts after a key press wait settle first, and + // are dropped if the cursor moves on meanwhile, so holding j doesn't + // send a request per row. waiting is settle while handling a key. + settle, waiting time.Duration + sourcesFiltered bool // the Sources rows were built for a filter + sourcesTouched bool // the user has moved in the Sources pane + + // Sources stream in per provider; gen discards results from a + // superseded load. + sourcesGen int + sourcesPending int + sourceResults map[string]sourcesMsg // by provider ID + + // At most one request in flight per slot. Responses whose id doesn't + // match are stale and dropped. + seq int + reqID [slotCount]int + cancel [slotCount]context.CancelFunc + + // Last selection seen, to detect changes after each update. + selSource, selContainer, selSecret string + + // objType is the active tab of the Secrets pane. wantType is the tab the + // user last picked, restored when moving to a container that has it. + objType, wantType provider.ObjectType + + // versionsOf is set while the Secrets pane lists an object's versions. + versionsOf *provider.Object + + nav *nav // opening a reference, in progress + pendingCmd tea.Cmd // a command started by advanceNav + prompt *modal // popup that has the keyboard + linked []provider.Container // vaults opened from links, this session + open *ref.Ref // reference to open at startup + + // A lazysecrets:// link found on the clipboard, offered in the footer. + dismissedLink string // the last link offered: not offered again + clipChecked time.Time +} + +// currentMsg is the source lazysecrets started in (provider.Current). +type currentMsg struct { + gen int + source provider.Source +} + +type sourcesMsg struct { + gen int + provider string // provider ID + sources []provider.Source + err error +} + +type containersMsg struct { + req int + key string // cache key + items []provider.Container + err error +} + +type secretsMsg struct { + req int + key string // cache key + // bound is set when the container's account was found by trying each + // candidate; it is the container as served by the one that worked. + bound *provider.Container + items []provider.Object + err error +} + +type valueMsg struct { + req int + obj provider.Object + value provider.SecretValue + err error +} + +type describeMsg struct { + req int + key string // cache key + obj provider.Object + err error +} + +type copyMsg struct { + req int + obj provider.Object + name string + value provider.SecretValue + err error +} + +type clipTickMsg struct{ token int } + +// revealTickMsg counts down a revealed value's display (reveal.go). +type revealTickMsg struct{ token int } + +type versionCountMsg struct { + req int + key string // cache key, shared with the versions view + id string // object ID + items []provider.Object + err error +} + +type loginDoneMsg struct{ err error } + +type flash struct { + text string + isErr bool // a failure: "✗ copy failed: …" + ok bool // a confirmation: "✓ copied api-key" + warn bool // can't be done here: "⚠ select a vault to share" +} + +// done is a confirmation flash, "✓ copied api-key". +func done(text string) flash { return flash{text: text, ok: true} } + +// warning says what can't be done here, and why. +func warning(text string) flash { return flash{text: text, warn: true} } + +func New(providers []provider.Provider, opts Options) Model { + if opts.Timeout <= 0 { + opts.Timeout = 30 * time.Second + } + iconSet := icons.None + if opts.Icons { + iconSet = icons.NerdFont + } + m := Model{ + providers: providers, + byID: map[string]provider.Provider{}, + timeout: opts.Timeout, + keys: keys.Default(), + st: styles.Default(), + spin: spinner.New(spinner.WithSpinner(spinner.Dot)), + detail: panes.NewDetail(iconSet), + icons: iconSet, + sourceResults: map[string]sourcesMsg{}, + clip: opts.Clipboard, + clipClear: opts.ClipboardClear, + now: time.Now, + cache: newCache(), + } + if m.clip == nil { + m.clip = clipboard.New() + } + m.openURL = opts.OpenURL + if m.openURL == nil { + m.openURL = browser.Open + } + for _, p := range providers { + m.byID[p.ID()] = p + } + + // Before the lists: their render functions capture the store. + m.store = opts.Store + if m.store == nil { + m.store, _ = store.Load("") + } + m.sources = newSourcesList(iconSet, m.store) + m.sources.Empty = "no sources found" + m.collapsed = map[string]bool{} + m.flatSources = opts.FlatSources + if opts.CollapseGroups && len(providers) > 1 { + for _, p := range providers { + m.collapsed[p.ID()] = true + } + } + m.history = !opts.NoHistory + m.onActive = opts.OnActive + m.commands = opts.Commands + m.version = opts.Version + if opts.Keys != nil { + m.keys = *opts.Keys + } + m.detail.RevealKey, m.detail.VersionsKey = keys.Name(m.keys.Reveal), keys.Name(m.keys.Versions) + m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur + m.expiryWarning = opts.ExpiryWarning + m.handlerSetup = opts.HandlerSetup + m.updater, m.updateCheck = opts.Updater, opts.UpdateCheck + m.startUpdateCheck() + m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt) + m.settle = opts.LoadDelay + if opts.Styles != nil { + m.st = *opts.Styles + } + m.defaultFg = opts.DefaultFg + m.help = newHelp(m.st) + m.open = opts.Open + saved := m.store.Layout() + m.split, m.heights = saved.Split, saved.Heights + + m.containers = panes.NewList("[2] Vaults", panes.Item[provider.Container]{ + ID: containerKey, + Filter: func(c provider.Container) string { return c.Name }, + FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note }, + Render: m.containerRender(false), + }) + + m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{ + // Versions of one object share its ID. + ID: func(s provider.Object) string { return s.ID + "@" + s.Version }, + Filter: func(s provider.Object) string { return s.Name }, + // Typing "expir" finds what has expired or soon will. + FilterExtra: func(o provider.Object) string { return m.store.Node(objectKey(o)).Note + " " + m.expiryWord(o) }, + Render: m.objectRender(), + }) + m.secrets.Empty = "no secrets" + m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret + + m.sources.SetLoading() + m.sourcesGen = 1 + m.sourcesPending = len(providers) + m.spinning = true + return m +} + +// renderVersion draws a version row: short ID, date and state. current is +// the newest version's ID. +func renderVersion(now time.Time, current string) func(styles.Styles, provider.Object, int) string { + return func(st styles.Styles, s provider.Object, _ int) string { + return panes.VersionLine(st, s, now, current) + } +} + +// setVersions shows a version list, marking the newest as current. +func (m *Model) setVersions(items []provider.Object) { + current := "" + if len(items) > 0 { + current = items[0].Version + } + m.secrets.SetRender(renderVersion(m.now(), current)) + m.secrets.SetItems(items) +} + +func (m Model) Init() tea.Cmd { + cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()} + if m.fetchOnStart { + cmds = append(cmds, m.fetchLatest(false)) + } + if m.open != nil { + r := *m.open + cmds = append(cmds, func() tea.Msg { return openRefMsg{r} }) + } + return tea.Batch(cmds...) +} + +// openRefMsg starts opening a reference from outside the key handler. +type openRefMsg struct{ ref ref.Ref } + +// OpenLink is a message offering r in the Open link popup, for links +// handed over by the link handler (Program.Send). +func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{r} } + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.w, m.h = msg.Width, msg.Height + + case spinner.TickMsg: + if !m.anyLoading() { + m.spinning = false + return m, nil + } + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd + + case tea.KeyPressMsg: + m.touched() + m.flash = flash{} + m.nav = nil // the user took over + cmds = append(cmds, m.maybeCheckClipboard()) + cmd, quit := m.handleKey(msg) + if quit { + return m, tea.Quit + } + cmds = append(cmds, cmd) + if m.nav == nil { // jumps and links go straight there + m.waiting = m.settle + } + + case sourcesMsg: + cmds = append(cmds, m.handleSources(msg)) + + case updateMsg: + cmds = append(cmds, m.handleUpdate(msg)) + + case installedMsg: + m.handleInstalled(msg) + + case currentMsg: + if msg.gen == m.sourcesGen { + m.current = &msg.source + m.rebuildSources() + m.queueSource(msg.source, false) + cmds = append(cmds, m.crawlStart()) + } + + case handlerInstalledMsg: + m.handlerInstalled(msg) + + case openRefMsg: + m.deferHandlerSetup() // the link first + cmds = append(cmds, m.offerRef("Open link", msg.ref, false)) + + case resolvedMsg: + m.handleResolved(msg) + + case tea.PasteMsg: + cmds = append(cmds, m.handlePaste(msg)) + + case tea.BlurMsg: + m.termBlurred = true + if m.hideOnBlur && m.detail.State() == panes.ValueShown { + m.hideValue() + } + + case commandValueMsg: + cmds = append(cmds, m.handleCommandValue(msg)) + + case commandDoneMsg: + m.handleCommandDone(msg) + + case crawlMsg: + cmds = append(cmds, m.handleCrawl(msg)) + + case revealTickMsg: + cmds = append(cmds, m.revealTick(msg)) + + case tea.FocusMsg: + m.touched() + m.termBlurred = false + // Back from another app, maybe with a link copied there. + m.clipChecked = m.now() + cmds = append(cmds, m.checkClipboard()) + + case openPromptFillMsg: + m.fillOpenPrompt(string(msg)) + + case clipboardTextMsg: + m.offerClipboardLink(string(msg)) + + case containersMsg: + if msg.req != m.reqID[slotContainers] { + break + } + m.done(slotContainers) + if msg.err != nil { + m.noteErr(msg.err) + delete(m.cache.containers, msg.key) + m.containers.SetError(msg.err) + } else { + m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()} + m.containers.SetItems(m.starredContainersFirst(msg.items)) + } + + case secretsMsg: + if msg.req != m.reqID[slotObjects] { + break + } + m.done(slotObjects) + if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil { + m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind)) + m.closeVersions() + } else if msg.err != nil { + m.noteErr(msg.err) + delete(m.cache.objects, msg.key) + m.secrets.SetError(msg.err) + } else { + if msg.bound != nil { + msg.key = m.rebind(*msg.bound) + } + m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} + if m.versionsOf != nil { + m.setVersions(msg.items) + } else { + m.secrets.SetItems(m.starredFirst(msg.items)) + } + } + + case valueMsg: + if msg.req != m.reqID[slotValue] { + break + } + m.done(slotValue) + if msg.err != nil { + m.noteErr(msg.err) + m.detail.SetValueError(msg.err) + } else { + m.detail.SetValue(msg.value) + m.usedObject(msg.obj) + cmds = append(cmds, m.startRevealTimer()) + } + + case versionCountMsg: + if msg.req != m.reqID[slotVersions] { + break + } + m.done(slotVersions) + // Errors and unsupported providers just leave the count out. + if msg.err == nil { + m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} + m.detail.SetVersions(msg.id, msg.items) + } + + case describeMsg: + if msg.req != m.reqID[slotDescribe] { + break + } + m.done(slotDescribe) + if msg.err != nil { + m.noteErr(msg.err) + m.detail.SetDescribeError(msg.err) + } else { + m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()} + m.detail.SetDescribed(msg.obj) + } + + case copyMsg: + if msg.req != m.reqID[slotCopy] { + break + } + m.done(slotCopy) + if msg.err != nil { + m.noteErr(msg.err) + m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true} + } else { + cmds = append(cmds, m.copyText(msg.name, msg.value.Reveal(), true)) + if msg.obj.Name != "" { + m.usedObject(msg.obj) + } + } + + case clipTickMsg: + if msg.token != m.clipToken || m.clipDeadline.IsZero() { + break + } + if m.now().Before(m.clipDeadline) { + cmds = append(cmds, m.clipTick()) + break + } + cmds = append(cmds, m.clip.Clear(m.clipFP)) + m.cancelClipClear() + m.flash = done("clipboard cleared") + + case clipboard.ErrorMsg: + m.flash = flash{text: "clipboard: " + msg.Err.Error(), isErr: true} + + case loginDoneMsg: + if msg.err != nil { + m.flash = flash{text: "sign-in failed: " + msg.err.Error(), isErr: true} + break + } + m.auth = nil + m.cache = newCache() + m.flash = done("signed in; reloading") + m.sources.Refresh() + cmds = append(cmds, m.loadSources()) + m.selSource = "\x00" // force the selection cascade to reload everything + } + + cmds = append(cmds, m.syncSelection()) + // Opening a link steps through panes as lists load; lists served from + // the cache load at once, so keep going while it makes progress. + for range 8 { + if !m.advanceNav() { + break + } + cmds = append(cmds, m.pendingCmd, m.syncSelection()) + m.pendingCmd = nil + } + if m.sources.HasFilter() != m.sourcesFiltered { + m.rebuildSources() // switch between grouped and flat-while-filtering rows + cmds = append(cmds, m.syncSelection()) + } + m.waiting = 0 + m.askHandlerSetup() + m.keys.Login.SetEnabled(m.auth != nil && len(m.auth.Login) > 0) + if m.anyLoading() && !m.spinning { + m.spinning = true + cmds = append(cmds, m.spin.Tick) + } + m.layout() + return m, tea.Batch(cmds...) +} + +// handleKey returns quit=true when the program should exit. +func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) { + if msg.String() == "ctrl+c" { + return nil, true + } + if m.prompt != nil { + return m.handleModal(msg), false + } + if m.search != nil { + return m.handleSearch(msg), false + } + if l := m.focusedFilter(); l != nil && l.Filtering() { + switch { + case key.Matches(msg, m.keys.AcceptFilter): + l.AcceptFilter() + case key.Matches(msg, m.keys.ClearFilter): + l.ClearFilter() + case key.Matches(msg, m.keys.FilterUp): + m.move(-1) + case key.Matches(msg, m.keys.FilterDown): + m.move(1) + case key.Matches(msg, m.keys.FilterCopy): + return m.copyValue(), false + case key.Matches(msg, m.keys.FilterReveal): + return m.toggleReveal(), false + case key.Matches(msg, m.keys.FilterStar): + return m.toggleFavourite(), false + default: + return l.UpdateFilter(msg), false + } + return nil, false + } + + if c, ok := m.commandForKey(msg); ok { + return m.startCommand(c), false + } + k := m.keys + switch { + case key.Matches(msg, k.Commands): + return m.showCommands(), false + case key.Matches(msg, k.Quit): + return nil, true + case key.Matches(msg, k.Help): + return m.showKeybindings(), false + case key.Matches(msg, k.Up): + m.move(-1) + case key.Matches(msg, k.Down): + m.move(1) + case key.Matches(msg, k.FilterCopy): + return m.copyValue(), false + case key.Matches(msg, k.FilterReveal): + return m.toggleReveal(), false + case key.Matches(msg, k.Top): + m.jump(true) + case key.Matches(msg, k.Bottom): + m.jump(false) + case key.Matches(msg, k.NextPane): + m.focus = (m.focus + 1) % paneCount + case key.Matches(msg, k.PrevPane): + m.focus = (m.focus + paneCount - 1) % paneCount + case key.Matches(msg, k.Pane1): + m.focus = paneSources + case key.Matches(msg, k.Pane2): + m.focus = paneContainers + case key.Matches(msg, k.Pane3): + m.focus = paneSecrets + case key.Matches(msg, k.Pane4): + m.focus = paneDetail + case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup(): + // folded or unfolded a provider group + case key.Matches(msg, k.Enter): + if m.focus < paneDetail { + m.focus++ + } + case key.Matches(msg, k.Share): + return m.share(false), false + case key.Matches(msg, k.ShareMenu): + return m.share(true), false + case key.Matches(msg, k.OpenRef): + return m.startOpenPrompt(), false + case key.Matches(msg, k.Note): + return m.editNote(), false + case key.Matches(msg, k.Favourites): + return m.showFavourites(), false + case key.Matches(msg, k.Search): + return m.showSearch(), false + case key.Matches(msg, k.Recent): + return m.showRecent(), false + case key.Matches(msg, k.Favourite): + return m.toggleFavourite(), false + case key.Matches(msg, k.SplitRight): + m.moveSplit(splitStep) + m.saveLayout() + case key.Matches(msg, k.SplitLeft): + m.moveSplit(-splitStep) + m.saveLayout() + case key.Matches(msg, k.SplitReset): + m.split, m.heights, m.zoom = 0, [3]int{}, zoomNormal + m.saveLayout() + case key.Matches(msg, k.Taller), key.Matches(msg, k.Shorter): + delta := heightStep + if key.Matches(msg, k.Shorter) { + delta = -heightStep + } + if m.resizeFocused(delta) { + m.saveLayout() + } else { + m.flash = warning("focus a left pane to change its height") + } + case key.Matches(msg, k.ZoomIn): + m.cycleZoom(1) + case key.Matches(msg, k.ZoomOut): + m.cycleZoom(-1) + case key.Matches(msg, k.SourcesView): + m.toggleSourcesView() + case key.Matches(msg, k.Versions): + if m.versionsOf != nil { + m.closeVersions() + } else if o, ok := m.detail.Secret(); ok { + m.versionsOf = &o + m.focus = paneSecrets + } + case key.Matches(msg, k.Back): + if f := m.focusedFilter(); f != nil && f.HasFilter() { + f.ClearFilter() + } else if m.versionsOf != nil && m.focus >= paneSecrets { + m.closeVersions() + } else if m.focus > paneSources { + m.focus-- + } + case key.Matches(msg, k.Filter): + if l := m.focusedFilter(); l != nil { + return l.StartFilter(), false + } + case key.Matches(msg, k.NextTab): + m.switchTab(1) + case key.Matches(msg, k.PrevTab): + m.switchTab(-1) + case key.Matches(msg, k.Reveal): + return m.toggleReveal(), false + case key.Matches(msg, k.Copy): + return m.copyValue(), false + case key.Matches(msg, k.CopyRef): + if o, ok := m.detail.Secret(); ok && o.Reference != "" { + return m.copyText(o.Name+" reference", o.Reference, false), false + } + case key.Matches(msg, k.Open): + return m.openSelected(), false + case key.Matches(msg, k.Login): + if m.auth != nil && len(m.auth.Login) > 0 { + c := exec.Command(m.auth.Login[0], m.auth.Login[1:]...) + return tea.ExecProcess(c, func(err error) tea.Msg { return loginDoneMsg{err: err} }), false + } + case key.Matches(msg, k.Refresh): + return m.refresh(), false + case key.Matches(msg, k.Update): + return m.update(), false + } + return nil, false +} + +// filterable is the part of panes.List the app needs regardless of item type. +type filterable interface { + Filtering() bool + HasFilter() bool + StartFilter() tea.Cmd + AcceptFilter() + ClearFilter() + UpdateFilter(tea.Msg) tea.Cmd +} + +func (m *Model) focusedFilter() filterable { + switch m.focus { + case paneSources: + return &m.sources + case paneContainers: + return &m.containers + case paneSecrets: + return &m.secrets + } + return nil +} + +func (m *Model) move(delta int) { + switch m.focus { + case paneSources: + m.sourcesTouched = true + m.sources.Move(delta) + case paneContainers: + m.containers.Move(delta) + case paneSecrets: + m.secrets.Move(delta) + case paneDetail: + m.detail.Scroll(delta) + } +} + +func (m *Model) jump(top bool) { + switch { + case m.focus == paneSources && top: + m.sourcesTouched = true + m.sources.Top() + case m.focus == paneSources: + m.sourcesTouched = true + m.sources.Bottom() + case m.focus == paneContainers && top: + m.containers.Top() + case m.focus == paneContainers: + m.containers.Bottom() + case m.focus == paneSecrets && top: + m.secrets.Top() + case m.focus == paneSecrets: + m.secrets.Bottom() + case m.focus == paneDetail && top: + m.detail.ScrollTop() + case m.focus == paneDetail: + m.detail.ScrollBottom() + } +} + +func (m *Model) toggleReveal() tea.Cmd { + s, ok := m.detail.Secret() + if !ok { + return nil + } + switch m.detail.State() { + case panes.ValueShown: + m.hideValue() + return nil + case panes.ValueLoading: + m.cancelReq(slotValue) + m.detail.Hide() + return nil + } + return m.fetchValue(s) +} + +func (m *Model) refresh() tea.Cmd { + switch m.focus { + case paneSources: + m.sources.Refresh() + return m.loadSources() + case paneContainers: + if src, ok := m.selectedSource(); ok { + m.containers.Refresh() + return m.loadContainers(src, m.selSource) + } + case paneSecrets: + if c, ok := m.containers.Selected(); ok { + m.secrets.Refresh() + return m.loadObjects(c, m.objType, m.selContainer) + } + case paneDetail: + if s, ok := m.detail.Secret(); ok && m.detail.State() != panes.ValueHidden { + return m.fetchValue(s) + } + } + return nil +} + +// syncSelection cascades selection changes down the hierarchy: a new source +// loads its containers, a new container loads its secrets, a new secret +// resets the detail pane. It runs after every update. +func (m *Model) syncSelection() tea.Cmd { + var cmds []tea.Cmd + + if id := m.sources.SelectedID(); id != m.selSource { + m.cache.rememberCursor(m.selSource, m.containers.SelectedID()) + m.selSource = id + m.cancelReq(slotContainers) + m.containers.Reset() + // Named after the level for the selected row's provider, also on + // a group header. + m.containers.Title, m.containers.Empty = "[2] Vaults", "select a subscription" + if r, ok := m.sources.Selected(); ok { + switch r.group { + case provider.KindDatabricks: + m.containers.Title, m.containers.Empty = "[2] Scopes", "select a workspace" + case provider.KindRepo: + m.containers.Title, m.containers.Empty = "[2] Files", "select a repo" + } + } + m.containers.SetRender(m.containerRender(false)) + if m.onLinks() { + m.showLinkedContainers() + } else if src, ok := m.selectedSource(); ok { + m.containers.Title = "[2] Vaults" + m.containers.Empty = "no vaults found" + switch src.Kind { + case provider.KindDatabricks: + m.containers.Title = "[2] Scopes" + m.containers.Empty = "no secret scopes" + case provider.KindRepo: + m.containers.Title = "[2] Files" + m.containers.Empty = "no SOPS or env files here (see docs/repos.md)" + } + cmds = append(cmds, m.showContainers(src, id)) + } + } + + // The active tab is part of the key, so switching tabs reloads too. + c, hasContainer := m.containers.Selected() + listKey := m.containers.SelectedID() + if hasContainer { + m.syncTabs(c) + listKey += "|" + string(m.objType) + // Moving to another vault or tab leaves the versions view. + if v := m.versionsOf; v != nil && (containerKey(v.Container) != containerKey(c) || v.Type != m.objType) { + m.closeVersions() + } + if v := m.versionsOf; v != nil { + listKey = versionsKey(*v) + m.secrets.Title = "[3] Versions of " + v.Name + m.secrets.Tabs = nil + m.secrets.Empty = "no versions" + } + } else { + m.secrets.Tabs = nil + m.secrets.Title = "[3] Secrets" + m.secrets.Empty = "select a vault or scope" + } + if listKey != m.selContainer { + m.cache.rememberCursor(m.selContainer, m.secrets.SelectedID()) + m.selContainer = listKey + m.cancelReq(slotObjects) + m.secrets.Reset() + switch { + case hasContainer && m.versionsOf != nil: + cmds = append(cmds, m.showVersions(*m.versionsOf, listKey)) + case hasContainer: + m.secrets.SetRender(m.objectRender()) + cmds = append(cmds, m.showObjects(c, listKey)) + } + } + + if id := m.secrets.SelectedID(); id != m.selSecret || (id == "" && m.detailStale()) { + m.selSecret = id + m.cancelReq(slotValue) + m.cancelReq(slotDescribe) + m.cancelReq(slotVersions) + if s, ok := m.secrets.Selected(); ok { + m.detail.ShowSecret(s) + cmds = append(cmds, m.describe(s), m.countVersions(s)) + } else if c, ok := m.containers.Selected(); ok { + m.detail.ShowContainer(c) + } else { + m.detail.Clear() + } + m.refreshDetailNote() + } + return tea.Batch(cmds...) +} + +// showContainers shows src's containers from the cache, loading them if +// they are missing or stale. +func (m *Model) showContainers(src provider.Source, key string) tea.Cmd { + e, ok := m.cache.containers[key] + if !ok { + m.containers.SetLoading() + return m.loadContainers(src, key) + } + m.containers.SetItems(m.starredContainersFirst(e.value)) + m.containers.Select(m.cache.cursor[key]) + if e.fresh(m.now()) { + return nil + } + m.containers.Refresh() + return m.loadContainers(src, key) +} + +// showObjects is showContainers for the Secrets pane. +func (m *Model) showObjects(c provider.Container, key string) tea.Cmd { + e, ok := m.cache.objects[key] + if !ok { + m.secrets.SetLoading() + return m.loadObjects(c, m.objType, key) + } + m.secrets.SetItems(m.starredFirst(e.value)) + m.secrets.Select(m.cache.cursor[key]) + if e.fresh(m.now()) { + return nil + } + m.secrets.Refresh() + return m.loadObjects(c, m.objType, key) +} + +// showVersions lists o's versions, newest first. They are cached like +// object lists. +func (m *Model) showVersions(o provider.Object, key string) tea.Cmd { + if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) { + m.setVersions(e.value) + m.secrets.Select(m.cache.cursor[key]) + return nil + } + m.secrets.SetLoading() + p := m.providerFor(o.Container.Source) + ctx, req := m.begin(slotObjects) + return func() tea.Msg { + items, err := p.Versions(ctx, o) + for i := range items { + items[i].Container = o.Container + } + return secretsMsg{req: req, key: key, items: items, err: err} + } +} + +// versionsKey caches an object's versions; the versions view and the +// version count share it. +func versionsKey(o provider.Object) string { + return containerKey(o.Container) + "|" + string(o.Type) + "|versions|" + o.ID +} + +// countVersions fetches the selected object's versions in the background +// so the detail pane can list them. +func (m *Model) countVersions(o provider.Object) tea.Cmd { + if m.versionsOf != nil { + return nil // the list already shows them + } + key := versionsKey(o) + if e, ok := m.cache.objects[key]; ok { + m.detail.SetVersions(o.ID, e.value) + if e.fresh(m.now()) { + return nil + } + } + p := m.providerFor(o.Container.Source) + ctx, req := m.begin(slotVersions) + return m.settled(ctx, func() tea.Msg { + items, err := p.Versions(ctx, o) + for i := range items { + items[i].Container = o.Container + } + return versionCountMsg{req: req, key: key, id: o.ID, items: items, err: err} + }) +} + +// closeVersions returns the Secrets pane to the object list, with the +// cursor on the object whose versions were shown. +func (m *Model) closeVersions() { + m.versionsOf = nil +} + +// syncTabs sets the Secrets pane tabs from the container's object types, +// keeping the user's preferred type when the container has it. +func (m *Model) syncTabs(c provider.Container) { + types := c.ObjectTypes() + m.objType = types[0] + m.secrets.Tabs = nil + for i, t := range types { + if t == m.wantType { + m.objType = t + m.secrets.Tab = i + } + m.secrets.Tabs = append(m.secrets.Tabs, tabLabels[t]) + } + if m.objType != m.wantType { + m.secrets.Tab = 0 + } + m.secrets.Title = "[3] " + tabLabels[m.objType] + if len(types) > 1 { + m.secrets.Title = "[3]" + } + m.secrets.Empty = "no " + strings.ToLower(pluralLabels[m.objType]) +} + +var ( + tabLabels = map[provider.ObjectType]string{ + provider.TypeSecret: "Secrets", provider.TypeKey: "Keys", provider.TypeCertificate: "Certs", + } + pluralLabels = map[provider.ObjectType]string{ + provider.TypeSecret: "Secrets", provider.TypeKey: "Keys", provider.TypeCertificate: "Certificates", + } +) + +// switchTab moves the Secrets pane to the next (delta=1) or previous +// (delta=-1) object type of the selected container. +func (m *Model) switchTab(delta int) { + if m.focus == paneSources { + m.sourcesFavs = !m.sourcesFavs // two tabs: either way flips + m.sourcesTouched = false // onto the first source of the tab + m.rebuildSources() + return + } + c, ok := m.containers.Selected() + if !ok { + return + } + types := c.ObjectTypes() + if len(types) < 2 { + return + } + i := 0 + for j, t := range types { + if t == m.objType { + i = j + } + } + m.wantType = types[(i+delta+len(types))%len(types)] +} + +// detailStale reports whether the detail pane shows a container other than +// the selected one, which happens when the container changes while no +// secret is selected. +func (m *Model) detailStale() bool { + _, hasSecret := m.detail.Secret() + shown := "" + if c, ok := m.detail.Container(); ok { + shown = containerKey(c) + } + return hasSecret || shown != m.containers.SelectedID() +} + +func (m *Model) handleSources(msg sourcesMsg) tea.Cmd { + if msg.gen != m.sourcesGen { + return nil + } + m.sourcesPending-- + m.sourceResults[msg.provider] = msg + + var errs []string + for _, p := range m.providers { + if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil { + m.noteErr(r.err) + errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err)) + } + } + m.status = strings.Join(errs, " · ") + m.rebuildSources() + m.sources.Loading = m.sourcesPending > 0 + return m.autoLoad(msg) +} + +// autoLoad loads local sources (repos) whole as they're found: their +// files are on disk, so listing every file's keys is cheap, and Go to +// can search them without asking. +func (m *Model) autoLoad(msg sourcesMsg) tea.Cmd { + p, ok := m.byID[msg.provider] + if !ok || msg.err != nil || p.Kind() != provider.KindRepo { + return nil + } + for _, src := range msg.sources { + m.queueSource(src, false) + } + return m.crawlStart() +} + +// begin starts a cancellable request for pane p, cancelling any in flight. +func (m *Model) begin(p slot) (context.Context, int) { + m.cancelReq(p) + m.seq++ + ctx, cancel := context.WithTimeout(context.Background(), m.timeout) + m.reqID[p], m.cancel[p] = m.seq, cancel + return ctx, m.seq +} + +func (m *Model) cancelReq(p slot) { + if m.cancel[p] != nil { + m.cancel[p]() + } + m.reqID[p], m.cancel[p] = 0, nil +} + +func (m *Model) touched() { + if m.onActive != nil { + m.onActive() + } +} + +// settled runs load after the wait for the cursor to settle, if a key +// press started it; a request cancelled meanwhile (the cursor moved on) +// is never sent. +func (m *Model) settled(ctx context.Context, load tea.Cmd) tea.Cmd { + wait := m.waiting + if wait <= 0 { + return load + } + return func() tea.Msg { + t := time.NewTimer(wait) + defer t.Stop() + select { + case <-ctx.Done(): + return nil + case <-t.C: + return load() + } + } +} + +// done releases the context of a completed request. +func (m *Model) done(p slot) { m.cancelReq(p) } + +// loadSources starts a new generation of source loads, superseding any in +// flight. +func (m *Model) loadSources() tea.Cmd { + m.sourcesGen++ + m.sourcesPending = len(m.providers) + m.sourceResults = map[string]sourcesMsg{} + return m.sourcesCmd() +} + +func (m Model) sourcesCmd() tea.Cmd { + gen := m.sourcesGen + var cmds []tea.Cmd + for _, p := range m.providers { + ctx, cancel := context.WithTimeout(context.Background(), m.timeout) + cmds = append(cmds, func() tea.Msg { + defer cancel() + s, err := p.Sources(ctx) + for i := range s { + s[i].Provider, s[i].ProviderLabel = p.ID(), p.Label() + } + return sourcesMsg{gen: gen, provider: p.ID(), sources: s, err: err} + }) + // The source it started in, quickly, on its own. + if c, ok := p.(provider.Current); ok { + ctx, cancel := context.WithTimeout(context.Background(), m.timeout) + cmds = append(cmds, func() tea.Msg { + defer cancel() + src, ok := c.Current(ctx) + if !ok { + return nil + } + src.Provider, src.ProviderLabel = p.ID(), p.Label() + return currentMsg{gen: gen, source: src} + }) + } + } + return tea.Batch(cmds...) +} + +func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd { + p := m.providerFor(src) + ctx, req := m.begin(slotContainers) + return m.settled(ctx, func() tea.Msg { + items, err := p.Containers(ctx, src) + // Carry the source as given, with its provider ID, so later + // requests reach the same account. + for i := range items { + items[i].Source = src + } + return containersMsg{req: req, key: key, items: items, err: err} + }) +} + +// loadObjects lists a container's objects. If the container's account +// isn't known (an old favourite), each account that can see its source is +// tried in turn, and the first that succeeds is reported in bound so the +// favourite can remember it. +func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd { + cands := m.candidates(c.Source) + known := len(cands) == 1 && cands[0].ID() == c.Source.Provider + ctx, req := m.begin(slotObjects) + return m.settled(ctx, func() tea.Msg { + var errs []error + for _, p := range cands { + cc := c + cc.Source = boundTo(c.Source, p) + items, err := p.Objects(ctx, cc, t) + if err == nil { + for i := range items { + items[i].Container = cc + } + msg := secretsMsg{req: req, key: key, items: items} + if !known { + msg.bound = &cc + } + return msg + } + if ctx.Err() != nil || len(cands) == 1 { + return secretsMsg{req: req, key: key, err: err} + } + errs = append(errs, fmt.Errorf("%s: %w", p.Label(), err)) + } + return secretsMsg{req: req, key: key, err: errors.Join(errs...)} + }) +} + +func (m *Model) fetchValue(s provider.Object) tea.Cmd { + p := m.providerFor(s.Container.Source) + ctx, req := m.begin(slotValue) + m.detail.SetLoading() + return func() tea.Msg { + v, err := p.Value(ctx, s) + return valueMsg{req: req, obj: s, value: v, err: err} + } +} + +func (m *Model) describe(o provider.Object) tea.Cmd { + key := detailKey(o) + if e, ok := m.cache.details[key]; ok { + m.detail.SetDescribed(e.value) + if e.fresh(m.now()) { + return nil + } + } else { + m.detail.SetDescribing() + } + p := m.providerFor(o.Container.Source) + ctx, req := m.begin(slotDescribe) + return m.settled(ctx, func() tea.Msg { + obj, err := p.Describe(ctx, o) + return describeMsg{req: req, key: key, obj: obj, err: err} + }) +} + +// copyValue copies the selected object's value, fetching it without +// revealing it if it isn't shown already. +func (m *Model) copyValue() tea.Cmd { + o, ok := m.detail.Secret() + if !ok { + return nil + } + if v, ok := m.detail.Value(); ok { + m.usedObject(o) + return m.copyText(o.Name, v.Reveal(), true) + } + p := m.providerFor(o.Container.Source) + ctx, req := m.begin(slotCopy) + m.flash = flash{text: "copying " + o.Name + "…"} + return func() tea.Msg { + v, err := p.Value(ctx, o) + return copyMsg{req: req, obj: o, name: o.Name, value: v, err: err} + } +} + +// copyText puts text on the clipboard. Sensitive text is cleared again +// after the configured delay. +func (m *Model) copyText(label, text string, sensitive bool) tea.Cmd { + cmds := []tea.Cmd{m.clip.Write(text)} + // This copy replaces whatever a pending clear was waiting for. + m.cancelClipClear() + if !sensitive || m.clipClear <= 0 { + m.flash = done("copied " + label) + return tea.Batch(cmds...) + } + m.flash = flash{} // the countdown line takes over from "copying…" + m.clipFP = clipboard.Fingerprint(text) + m.clipLabel = label + m.clipDeadline = m.now().Add(m.clipClear) + return tea.Batch(append(cmds, m.clipTick())...) +} + +// clipTick fires when the countdown's displayed second next changes. +func (m *Model) clipTick() tea.Cmd { + wait := m.clipDeadline.Sub(m.now()) % time.Second + if wait <= 0 { + wait = time.Second + } + token := m.clipToken + return tea.Tick(wait, func(time.Time) tea.Msg { return clipTickMsg{token: token} }) +} + +func (m *Model) cancelClipClear() { + m.clipToken++ + m.clipFP, m.clipLabel, m.clipDeadline = [32]byte{}, "", time.Time{} +} + +// openSelected opens the focused pane's selected item in the browser. Over +// SSH it copies the URL instead. +func (m *Model) openSelected() tea.Cmd { + var name, url string + var used func() + switch m.focus { + case paneSources: + s, _ := m.selectedSource() + name, url, used = s.Name, s.URL, func() { m.usedSource(s) } + case paneContainers: + c, _ := m.containers.Selected() + name, url, used = c.Name, c.URL, func() { m.usedContainer(c) } + default: + o, _ := m.detail.Secret() + name, url, used = o.Name, o.URL, func() { m.usedObject(o) } + } + if name == "" { + return nil + } + if url == "" { + m.flash = warning("no web page for " + name) + return nil + } + err := m.openURL(url) + if err == nil || errors.Is(err, browser.ErrRemote) { + used() + } + switch { + case errors.Is(err, browser.ErrRemote): + cmd := m.copyText("link to "+name, url, false) + m.flash.text += " (can't open a browser over SSH)" + return cmd + case err != nil: + m.flash = flash{text: "open: " + err.Error(), isErr: true} + default: + m.flash = done("opened " + name + " in the browser") + } + return nil +} + +// noteErr remembers authentication failures so the UI can offer sign-in. +func (m *Model) noteErr(err error) { + var auth *provider.AuthError + if errors.As(err, &auth) { + m.auth = auth + } +} + +func (m Model) anyLoading() bool { + return m.sources.Loading || m.containers.Loading || m.secrets.Loading || + m.detail.State() == panes.ValueLoading +} + +const ( + minWidth = 50 + minHeight = 12 +) + +func (m Model) footerView() string { + var help string + if l := m.focusedFilter(); m.search != nil || m.prompt != nil { + help = m.helpBar(m.popupKeys(), 1) // the popup's keys, ? help last + } else if l != nil && l.Filtering() { + help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) + } else { + help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}.ShortHelp(), 2) + } + var lines []string + failed := func(text string) { lines = append(lines, m.flashLine(flash{text: text, isErr: true})) } + switch { + case m.auth != nil && len(m.auth.Login) > 0: + failed(fmt.Sprintf("%s: sign-in required (not signed in or session expired)%s", + m.auth.Who(), withKey(m.keys.Login, " · press %s to run "+strings.Join(m.auth.Login, " ")))) + case m.auth != nil: + failed(fmt.Sprintf("%s: sign-in required: %v", m.auth.Who(), m.auth.Err)) + case m.status != "": + failed(m.status) + } + if !m.clipDeadline.IsZero() { + left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds()))) + lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left)))) + } + if m.flash.text != "" { + lines = append(lines, m.flashLine(m.flash)) + } + // The version at the right end, one column in, like lazygit. + if v := m.versionLabel(); v != "" { + up := m.updateLabel() // a newer release, in colour + gap := max(1, m.w-1-ansi.StringWidth(help)-ansi.StringWidth(v+up)-1) + help += strings.Repeat(" ", gap) + m.st.Muted.Render(v) + m.st.Notice.Render(up) + " " + } + return strings.Join(append(lines, " "+help), "\n") +} + +// flashLine draws a message: errors in red, confirmations after a ✓. +func (m Model) flashLine(f flash) string { + // One line here: a longer message (sops' errors) keeps its first, and + // says where the rest is. + if first, _, multi := strings.Cut(f.text, "\n"); multi { + f.text = first + " … (the whole message: " + withKey(m.keys.Reveal, "%s on the secret") + ")" + } + var s string + switch { + case f.isErr: + s = m.st.Error.Render("✗ " + f.text) + case f.warn: + s = m.st.Warning.Render("⚠ " + f.text) + case f.ok: + s = m.st.Success.Render("✓") + " " + m.st.Notice.Render(f.text) + default: + s = m.st.Notice.Render(f.text) + } + return lipgloss.NewStyle().MaxWidth(m.w).Render(" " + s) // off the edge +} + +func (m Model) View() tea.View { + var content string + if m.w < minWidth || m.h < minHeight { + content = fmt.Sprintf("Terminal too small (%dx%d); need at least %dx%d.", m.w, m.h, minWidth, minHeight) + } else { + content = m.panesView() + "\n" + m.footerView() + if m.prompt != nil || m.search != nil { + content = m.overlay(content) + } + } + v := tea.NewView(content) + v.AltScreen = true + v.ForegroundColor = m.defaultFg + v.ReportFocus = true // to look for links on the clipboard + v.WindowTitle = "lazysecrets" + return v +} diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go new file mode 100644 index 0000000..6fab678 --- /dev/null +++ b/internal/ui/app/app_test.go @@ -0,0 +1,1024 @@ +package app + +import ( + "cmp" + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + + "git.kebcloud.no/keb/lazysecrets/internal/browser" + "git.kebcloud.no/keb/lazysecrets/internal/clipboard" + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" + "git.kebcloud.no/keb/lazysecrets/internal/ui/keys" +) + +func TestMain(m *testing.M) { + time.Local = time.UTC // golden output formats times in local time + os.Exit(m.Run()) +} + +// driver runs a Model synchronously: every command is executed and its +// message fed back until the program is idle. Commands that don't return +// promptly (spinner and cursor-blink ticks) are abandoned. +type driver struct { + t *testing.T + m Model + wait time.Duration // how long a command may take before it's dropped (default 50ms) +} + +func newDriver(t *testing.T, providers ...provider.Provider) *driver { + t.Helper() + if len(providers) == 0 { + providers = []provider.Provider{fake.Azure(0), fake.Databricks(0)} + } + return newDriverOpts(t, Options{}, providers...) +} + +func newDriverOpts(t *testing.T, opts Options, providers ...provider.Provider) *driver { + t.Helper() + if len(providers) == 0 { + providers = []provider.Provider{fake.Azure(0), fake.Databricks(0)} + } + if opts.Clipboard == nil { + opts.Clipboard = &fakeClipboard{} + } + if opts.OpenURL == nil { // never launch a real browser from tests + opts.OpenURL = func(string) error { return nil } + } + m := New(providers, opts) + m.detail.Now = func() time.Time { return time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) } + d := &driver{t: t, m: m} + d.send(tea.WindowSizeMsg{Width: 100, Height: 30}) + d.run(m.Init()) + return d +} + +// fakeClipboard records writes instead of touching the real clipboard. +type fakeClipboard struct { + content string + cleared [][32]byte +} + +func (c *fakeClipboard) Write(s string) tea.Cmd { + c.content = s + return nil +} + +func (c *fakeClipboard) Read() (string, error) { return c.content, nil } + +func (c *fakeClipboard) Clear(fp [32]byte) tea.Cmd { + c.cleared = append(c.cleared, fp) + if clipboard.Fingerprint(c.content) == fp { + c.content = "" + } + return nil +} + +func (d *driver) clip() *fakeClipboard { return d.m.clip.(*fakeClipboard) } + +func (d *driver) send(msg tea.Msg) { + d.t.Helper() + next, cmd := d.m.Update(msg) + d.m = next.(Model) + d.run(cmd) +} + +func (d *driver) run(cmd tea.Cmd) { + d.t.Helper() + if cmd == nil { + return + } + ch := make(chan tea.Msg, 1) + go func() { ch <- cmd() }() + var msg tea.Msg + select { + case msg = <-ch: + case <-time.After(cmp.Or(d.wait, 50*time.Millisecond)): + return + } + switch msg := msg.(type) { + case nil, spinner.TickMsg: + case tea.BatchMsg: + for _, c := range msg { + d.run(c) + } + default: + d.send(msg) + } +} + +func (d *driver) keys(keys ...string) { + d.t.Helper() + for _, k := range keys { + d.send(keyPress(k)) + } +} + +func (d *driver) typeText(s string) { + d.t.Helper() + for _, r := range s { + d.send(tea.KeyPressMsg{Code: r, Text: string(r)}) + } +} + +func (d *driver) screen() string { + return ansi.Strip(d.m.View().Content) +} + +// sourceCount counts source rows, leaving out group headers. +func (d *driver) sourceCount() int { + n := 0 + for _, p := range d.m.providers { + if !d.m.collapsed[p.ID()] { + n += len(d.m.sourceResults[p.ID()].sources) + } + } + return n +} + +func (d *driver) selected() (src, container, secret string) { + s, _ := d.m.selectedSource() + c, _ := d.m.containers.Selected() + x, _ := d.m.secrets.Selected() + return s.Name, c.Name, x.Name +} + +func TestStartupCascadesToFirstSecret(t *testing.T) { + t.Parallel() + d := newDriver(t) + src, c, s := d.selected() + if src != "platform-prod" || c != "kv-app-prod" || s != "db-password" { + t.Fatalf("selected = %q/%q/%q", src, c, s) + } + if got := d.sourceCount(); got != 4 { + t.Errorf("sources = %d, want 4 (2 Azure + 2 Databricks)", got) + } + if d.m.anyLoading() { + t.Error("still loading after idle") + } +} + +func TestGolden(t *testing.T) { + t.Parallel() + d := newDriver(t) + golden.RequireEqual(t, d.screen()) +} + +func TestIcons(t *testing.T) { + t.Parallel() + const azure, dbx = "\U000F0805", "\U000F0F58" + plain := newDriver(t).screen() + if strings.Contains(plain, azure) || strings.Contains(plain, dbx) { + t.Error("icons shown without the option") + } + d := newDriverOpts(t, Options{Icons: true}) + screen := d.screen() + if !strings.Contains(screen, azure+" Azure") || !strings.Contains(screen, dbx+" Databricks") { + t.Errorf("icons missing:\n%s", screen) + } + if !strings.Contains(screen, "source "+azure+" Azure / platform-prod") { + t.Errorf("detail source line missing icon:\n%s", screen) + } +} + +func TestObjectTypeTabs(t *testing.T) { + t.Parallel() + d := newDriver(t) + if got := d.m.secrets.Tabs; strings.Join(got, ",") != "Secrets,Keys,Certs" { + t.Fatalf("tabs = %v", got) + } + d.keys("3", "]") + if d.m.objType != provider.TypeKey { + t.Fatalf("objType = %s after ]", d.m.objType) + } + if _, _, s := d.selected(); s != "signing-key" { + t.Fatalf("selected = %q", s) + } + screen := d.screen() + for _, want := range []string{"key type RSA 2048", "ops sign, verify", "space to show public key"} { + if !strings.Contains(screen, want) { + t.Errorf("missing %q:\n%s", want, screen) + } + } + d.keys("space") + if !strings.Contains(d.screen(), "BEGIN PUBLIC KEY") { + t.Errorf("public key not shown:\n%s", d.screen()) + } + + d.keys("]") // certificates + d.keys("3", "j") + if _, _, s := d.selected(); s != "old-tls" { + t.Fatalf("selected = %q", s) + } + if !strings.Contains(d.screen(), "(expired)") { + t.Errorf("expired certificate not flagged:\n%s", d.screen()) + } + d.keys("]") // wraps to secrets + if d.m.objType != provider.TypeSecret { + t.Fatalf("objType = %s, want wrap to Secret", d.m.objType) + } + d.keys("[") + if d.m.objType != provider.TypeCertificate { + t.Fatalf("objType = %s, want wrap back to Certificate", d.m.objType) + } +} + +func TestTabErrorsArePerType(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("2", "j") // kv-shared-prod: secrets readable, keys forbidden + if d.m.secrets.Err != nil { + t.Fatalf("secrets tab errored: %v", d.m.secrets.Err) + } + d.keys("]") + if d.m.secrets.Err == nil || !strings.Contains(d.screen(), "list keys") { + t.Fatalf("keys tab should show permission error:\n%s", d.screen()) + } + d.keys("]") + if d.m.secrets.Err != nil || !strings.Contains(d.screen(), "no certificates") { + t.Fatalf("certs tab should be empty without error:\n%s", d.screen()) + } +} + +func TestTabPreferenceSurvivesDatabricks(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "]") // Keys + d.keys("1", "j", "j", "j") // Databricks prod: secrets only + if d.m.objType != provider.TypeSecret || len(d.m.secrets.Tabs) != 1 { + t.Fatalf("Databricks: objType = %s, tabs = %v", d.m.objType, d.m.secrets.Tabs) + } + if !strings.Contains(d.screen(), "[3] Secrets") { + t.Errorf("single-type title missing:\n%s", d.screen()) + } + d.keys("3", "]", "1") // no-op without tabs + d.keys("k", "k", "k") // back to Azure prod + if d.m.objType != provider.TypeKey { + t.Fatalf("objType = %s, want Keys restored", d.m.objType) + } +} + +func TestCopyWithoutRevealing(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{ClipboardClear: 30 * time.Second}) + clock := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + d.m.now = func() time.Time { return clock } + + d.keys("y") + if got := d.clip().content; got != "correct-horse-battery-staple" { + t.Fatalf("clipboard = %q", got) + } + if strings.Contains(d.screen(), "correct-horse-battery-staple") { + t.Error("copying revealed the value") + } + if !strings.Contains(d.screen(), "copied db-password · clipboard clears in 30s") { + t.Errorf("no countdown:\n%s", d.screen()) + } + if strings.Contains(d.screen(), "copying") { + t.Errorf("stale progress message:\n%s", d.screen()) + } + + // The countdown survives key presses and ticks down. + d.keys("j") + clock = clock.Add(2500 * time.Millisecond) + d.send(clipTickMsg{token: d.m.clipToken}) + if !strings.Contains(d.screen(), "clipboard clears in 28s") { + t.Errorf("countdown not updated:\n%s", d.screen()) + } + + // A newer copy restarts the countdown; ticks for the old one are ignored. + old := d.m.clipToken + d.keys("y") // api-key + clock = clock.Add(time.Minute) + d.send(clipTickMsg{token: old}) + if d.clip().content == "" { + t.Fatal("stale tick cleared the clipboard") + } + d.send(clipTickMsg{token: d.m.clipToken}) + if d.clip().content != "" { + t.Errorf("clipboard not cleared: %q", d.clip().content) + } + if strings.Contains(d.screen(), "clears in") || !strings.Contains(d.screen(), "clipboard cleared") { + t.Errorf("countdown not replaced by cleared notice:\n%s", d.screen()) + } +} + +func TestCopyReferenceCancelsCountdown(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{ClipboardClear: 30 * time.Second}) + d.keys("y", "Y") + if strings.Contains(d.screen(), "clears in") { + t.Errorf("countdown kept after the value was replaced on the clipboard:\n%s", d.screen()) + } +} + +func TestCopyRevealedValueAndReference(t *testing.T) { + t.Parallel() + d := newDriver(t) // ClipboardClear 0: no timer + d.keys("space", "y") + if d.clip().content != "correct-horse-battery-staple" { + t.Fatalf("clipboard = %q", d.clip().content) + } + if strings.Contains(d.screen(), "clears in") { + t.Error("clear notice shown with clearing disabled") + } + d.keys("Y") + if want := "@Microsoft.KeyVault(SecretUri=https://kv-app-prod.vault.azure.net/secrets/db-password)"; d.clip().content != want { + t.Errorf("reference = %q", d.clip().content) + } +} + +// expiring fails object listing with an AuthError until signed in. +type expiring struct { + provider.Provider + signedIn *bool +} + +func (e expiring) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) { + if !*e.signedIn { + return nil, &provider.AuthError{Kind: provider.KindAzure, Login: []string{"az", "login"}, Err: errors.New("AADSTS700082")} + } + return e.Provider.Objects(ctx, c, t) +} + +func TestSignInFlow(t *testing.T) { + t.Parallel() + signedIn := false + d := newDriver(t, expiring{fake.Azure(0), &signedIn}) + if d.m.auth == nil { + t.Fatal("auth error not noticed") + } + if !strings.Contains(d.screen(), "press L to run az login") { + t.Errorf("no sign-in hint:\n%s", d.screen()) + } + if !d.m.keys.Login.Enabled() { + t.Error("L not enabled") + } + next, cmd := d.m.Update(keyPress("L")) + if cmd == nil { + t.Fatal("L did not start az login") + } + d.m = next.(Model) + + signedIn = true + d.send(loginDoneMsg{}) + if d.m.auth != nil || d.m.keys.Login.Enabled() { + t.Error("auth state not cleared after sign-in") + } + if _, _, s := d.selected(); s != "db-password" { + t.Errorf("objects not reloaded after sign-in, selected %q", s) + } +} + +func TestSignInFailureKeepsHint(t *testing.T) { + t.Parallel() + signedIn := false + d := newDriver(t, expiring{fake.Azure(0), &signedIn}) + d.send(loginDoneMsg{err: errors.New("exit status 1")}) + if d.m.auth == nil || !strings.Contains(d.screen(), "sign-in failed: exit status 1") { + t.Errorf("failure not reported:\n%s", d.screen()) + } +} + +// describing adds an attribute in Describe. +type describing struct{ provider.Provider } + +func (describing) Describe(_ context.Context, o provider.Object) (provider.Object, error) { + o.Attrs = append(o.Attrs, provider.Attr{Label: "described", Value: o.Name}) + return o, nil +} + +func TestDescribeUpdatesDetail(t *testing.T) { + t.Parallel() + d := newDriver(t, describing{fake.Azure(0)}) + if !strings.Contains(d.screen(), "described db-password") { + t.Fatalf("describe result not shown:\n%s", d.screen()) + } + d.send(describeMsg{req: -1, obj: provider.Object{ID: "x"}}) + d.keys("3", "j") + if !strings.Contains(d.screen(), "described api-key") { + t.Errorf("describe not refreshed on selection change:\n%s", d.screen()) + } +} + +func TestOpenInBrowser(t *testing.T) { + t.Parallel() + var opened []string + open := func(url string) error { opened = append(opened, url); return nil } + d := newDriverOpts(t, Options{OpenURL: open}) + + d.keys("o") // subscription + d.keys("2", "o") // vault + d.keys("3", "]", "o") // key: its vault's key list + d.keys("1", "j", "j", "j") // Databricks prod + d.keys("o") // workspace + d.keys("2", "o") // Databricks-backed scope: nothing to open + if !strings.Contains(d.screen(), "no web page for app") { + t.Errorf("missing no-page notice:\n%s", d.screen()) + } + d.keys("j", "o") // AKV-backed scope: its vault + + want := []string{ + "https://portal.azure.com/#resource/subscriptions/sub-prod/overview", + "https://portal.azure.com/#resource/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv-app-prod/overview", + "https://portal.azure.com/#resource/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv-app-prod/keys", + "https://adb-1111111111111111.11.azuredatabricks.net", + "https://portal.azure.com/#resource/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv-app-prod/overview", + } + if strings.Join(opened, "\n") != strings.Join(want, "\n") { + t.Errorf("opened:\n%s\nwant:\n%s", strings.Join(opened, "\n"), strings.Join(want, "\n")) + } +} + +func TestOpenOverSSHCopiesLink(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{OpenURL: func(string) error { return browser.ErrRemote }}) + d.keys("o") + if d.clip().content != "https://portal.azure.com/#resource/subscriptions/sub-prod/overview" { + t.Errorf("clipboard = %q", d.clip().content) + } + if !strings.Contains(d.screen(), "can't open a browser over SSH") { + t.Errorf("no explanation:\n%s", d.screen()) + } +} + +func TestVersions(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("v") + if d.m.focus != paneSecrets || !strings.Contains(d.screen(), "[3] Versions of db-password") { + t.Fatalf("versions view not shown:\n%s", d.screen()) + } + screen := d.screen() + for _, want := range []string{"3f9c2a 2026-09-01 current", "91d3e0 2026-03-01 expired", "0a77c4 2025-09-01 disabled"} { + if !strings.Contains(screen, want) { + t.Errorf("missing row %q:\n%s", want, screen) + } + } + + // space reads the selected version, not the latest. + d.keys("j", "space") + if !strings.Contains(d.screen(), "previous-password") || strings.Contains(d.screen(), "correct-horse") { + t.Errorf("wrong version revealed:\n%s", d.screen()) + } + + // esc returns to the list with the cursor on the object. + d.keys("esc") + if d.m.versionsOf != nil || !strings.Contains(d.screen(), "[3] Secrets - Keys - Certs") { + t.Fatalf("versions view not closed:\n%s", d.screen()) + } + if _, _, s := d.selected(); s != "db-password" { + t.Errorf("cursor = %q after closing versions", s) + } + if strings.Contains(d.screen(), "previous-password") { + t.Error("old version's value still shown after closing versions") + } +} + +func TestVersionHistoryInDetail(t *testing.T) { + t.Parallel() + d := newDriver(t) + screen := d.screen() + for _, want := range []string{ + "versions 3 · v to browse", + "▸ 3f9c2a 2026-09-01 current", // the version shown above + " 91d3e0 2026-03-01 expired", + " 0a77c4 2025-09-01 disabled", + } { + if !strings.Contains(screen, want) { + t.Errorf("missing %q:\n%s", want, screen) + } + } + d.keys("3", "j") // api-key: only its current version + if !strings.Contains(d.screen(), "versions 1") || strings.Contains(d.screen(), "v to browse") { + t.Errorf("wrong history for api-key:\n%s", d.screen()) + } + d.keys("1", "j", "j", "j") // Databricks: unsupported, so no section at all + if strings.Contains(d.screen(), "versions ") { + t.Errorf("versions shown for Databricks:\n%s", d.screen()) + } +} + +func TestVersionCountPrimesVersionsView(t *testing.T) { + t.Parallel() + p := &counting{Provider: fake.Azure(0)} + d := newDriver(t, p) + before := p.versions + d.keys("v") + if p.versions != before { + t.Errorf("v refetched versions already counted: %d → %d", before, p.versions) + } +} + +func TestBackspaceGoesBack(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("v") + d.send(tea.KeyPressMsg{Code: tea.KeyBackspace}) + if d.m.versionsOf != nil { + t.Fatal("backspace did not close the versions view") + } + // While typing a filter, backspace deletes a character instead. + d.keys("/") + d.typeText("apx") + d.send(tea.KeyPressMsg{Code: tea.KeyBackspace}) + if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 { + t.Errorf("backspace in filter: filtering=%v visible=%d", d.m.secrets.Filtering(), d.m.secrets.Len()) + } +} + +func TestVersionsClosedByNavigation(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("v", "]") // switching tab leaves the versions view + if d.m.versionsOf != nil || d.m.objType != provider.TypeKey { + t.Errorf("tab switch: versionsOf=%v objType=%s", d.m.versionsOf, d.m.objType) + } + d.keys("[", "v", "2", "j") // another vault + if d.m.versionsOf != nil { + t.Error("vault switch did not close the versions view") + } +} + +func TestVersionsUnsupported(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("1", "j", "j", "j", "v") // Databricks + if d.m.versionsOf != nil { + t.Error("versions view stayed open") + } + if !strings.Contains(d.screen(), "versions aren't supported for Databricks") { + t.Errorf("no notice:\n%s", d.screen()) + } + if _, _, s := d.selected(); s != "storage-key" { + t.Errorf("selection lost: %q", s) + } +} + +func TestNavigationReloadsChildren(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("j") // platform-dev + if _, c, s := d.selected(); c != "kv-app-dev" || s != "db-password" { + t.Fatalf("after j on sources: %q/%q", c, s) + } + d.keys("j") // Databricks group header: nothing below it + if _, c, _ := d.selected(); c != "" || d.m.containers.Title != "[2] Scopes" { + t.Errorf("header selected but containers shown: %q / %q", c, d.m.containers.Title) + } + d.keys("j") // Databricks prod + if d.m.containers.Title != "[2] Scopes" { + t.Errorf("containers title = %q", d.m.containers.Title) + } + if _, c, s := d.selected(); c != "app" || s != "storage-key" { + t.Fatalf("after j to Databricks: %q/%q", c, s) + } +} + +func TestPermissionErrorShownInPane(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("2", "G") // kv-restricted + if d.m.secrets.Err == nil { + t.Fatal("expected secrets error") + } + if !strings.Contains(d.screen(), "403 Forbidden") { + t.Error("error not rendered") + } + if !strings.Contains(d.screen(), "Select a secret") { + t.Error("detail should show the container summary") + } +} + +func TestRevealAndHideOnSelectionChange(t *testing.T) { + t.Parallel() + d := newDriver(t) + const plain = "correct-horse-battery-staple" + if strings.Contains(d.screen(), plain) { + t.Fatal("value visible before reveal") + } + d.keys("3", "space") + if !strings.Contains(d.screen(), plain) { + t.Fatalf("value not revealed:\n%s", d.screen()) + } + d.keys("space") + if strings.Contains(d.screen(), plain) { + t.Fatal("value still visible after hide") + } + d.keys("space", "j") + if strings.Contains(d.screen(), plain) { + t.Fatal("value still visible after moving to another secret") + } + if d.m.detail.State() != 0 { + t.Error("value state not reset after selection change") + } +} + +func TestRevealFromEveryPane(t *testing.T) { + t.Parallel() + const plain = "correct-horse-battery-staple" + for _, p := range []string{"1", "2", "3", "4"} { + d := newDriver(t) + d.keys(p, "space") + if !strings.Contains(d.screen(), plain) { + t.Errorf("pane %s: value not revealed", p) + } + d.keys("space") + if strings.Contains(d.screen(), plain) { + t.Errorf("pane %s: value not hidden", p) + } + } +} + +func TestRevealOnAKVBackedDatabricksScope(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("j", "j", "j", "2", "j") // Databricks prod → kv-app-prod scope + if _, c, _ := d.selected(); c != "kv-app-prod" { + t.Fatalf("container = %q", c) + } + if !strings.Contains(d.screen(), "Azure Key Vault-backed: kv-app-prod") { + t.Errorf("scope backing not shown with a secret selected:\n%s", d.screen()) + } + d.keys("3", "space") + if !strings.Contains(d.screen(), "correct-horse-battery-staple") { + t.Fatalf("value not revealed:\n%s", d.screen()) + } +} + +func TestFilter(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "/") + d.typeText("api") + if d.m.secrets.Len() != 1 { + t.Fatalf("visible = %d, want 1", d.m.secrets.Len()) + } + d.keys("enter") + if _, _, s := d.selected(); s != "api-key" { + t.Fatalf("selected = %q", s) + } + // While filtering, q is text rather than quit. + d.keys("/") + _, cmd := d.m.Update(keyPress("q")) + if cmd != nil { + if _, quit := cmd().(tea.QuitMsg); quit { + t.Fatal("q quit while filtering") + } + } + d.keys("esc") // leave filter mode, clearing the query + d.keys("/") + d.typeText("api") + d.keys("enter", "esc") // apply, then esc clears the applied filter + if d.m.secrets.Len() != 3 { + t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len()) + } + if _, _, s := d.selected(); s != "api-key" { + t.Errorf("clearing the filter should keep the selection, got %q", s) + } +} + +func TestMoveWhileFiltering(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "/") + d.typeText("a") // all three secrets contain an a + if d.m.secrets.Len() != 3 { + t.Fatalf("visible = %d", d.m.secrets.Len()) + } + _, _, initial := d.selected() // kept from before filtering: the middle row + var seen []string + for _, k := range []rune{'j', 'j', 'k'} { + d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl}) + _, _, s := d.selected() + seen = append(seen, s) + } + if !d.m.secrets.Filtering() { + t.Fatal("ctrl+j/k left filter mode") + } + // down to the bottom, stay there, back up to where it started + if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial { + t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen) + } + if !strings.Contains(d.screen(), "ctrl+j/n down") { + t.Errorf("filter help missing ctrl+j:\n%s", d.screen()) + } +} + +func TestStaleResponsesDropped(t *testing.T) { + t.Parallel() + d := newDriver(t) + before := d.m.containers.Len() + d.send(containersMsg{req: -1, items: nil}) + if d.m.containers.Len() != before { + t.Error("stale containersMsg was applied") + } + d.send(valueMsg{req: -1, value: provider.NewSecretValue([]byte("stale"))}) + if strings.Contains(d.screen(), "stale") { + t.Error("stale valueMsg was applied") + } +} + +type failingSources struct{ provider.Provider } + +func (failingSources) Sources(context.Context) ([]provider.Source, error) { + return nil, errors.New("run az login") +} + +func TestProviderErrorDoesNotHideOthers(t *testing.T) { + t.Parallel() + d := newDriver(t, failingSources{fake.Azure(0)}, fake.Databricks(0)) + if d.sourceCount() != 2 { + t.Fatalf("sources = %d, want the 2 Databricks ones", d.sourceCount()) + } + if !strings.Contains(d.screen(), "Azure: run az login") { + t.Errorf("provider error not in status line:\n%s", d.screen()) + } +} + +func TestFocusKeys(t *testing.T) { + t.Parallel() + d := newDriver(t) + for _, tc := range []struct { + key string + want pane + }{ + {"tab", paneContainers}, {"enter", paneSecrets}, {"enter", paneDetail}, + {"enter", paneDetail}, {"esc", paneSecrets}, {"shift+tab", paneContainers}, + {"4", paneDetail}, {"l", paneSources}, {"h", paneDetail}, + } { + d.keys(tc.key) + if d.m.focus != tc.want { + t.Fatalf("after %q focus = %d, want %d", tc.key, d.m.focus, tc.want) + } + } +} + +func TestQuit(t *testing.T) { + t.Parallel() + for _, k := range []string{"q", "ctrl+c"} { + d := newDriver(t) + _, cmd := d.m.Update(keyPress(k)) + if cmd == nil { + t.Fatalf("%s: no command", k) + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Errorf("%s did not quit", k) + } + } +} + +func TestTooSmall(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.send(tea.WindowSizeMsg{Width: 30, Height: 8}) + if !strings.Contains(d.screen(), "Terminal too small") { + t.Error("expected too-small message") + } +} + +// leftEdge is the width of the left column: the column of the first +// top-right corner on the first screen line. +func (d *driver) leftEdge() int { + first := strings.Split(d.screen(), "\n")[0] + return len([]rune(first[:strings.Index(first, "╮")])) + 1 +} + +func TestMoveDivider(t *testing.T) { + t.Parallel() + d := newDriver(t) // 100 wide: default left column 33 + start := d.leftEdge() + if start != 33 { + t.Fatalf("default left width = %d", start) + } + d.send(tea.KeyPressMsg{Code: tea.KeyRight, Mod: tea.ModCtrl}) + d.keys(">") + if got := d.leftEdge(); got != start+2*splitStep { + t.Errorf("after ctrl+→ and >: %d, want %d", got, start+2*splitStep) + } + d.keys("=") + if got := d.leftEdge(); got != start { + t.Errorf("= didn't reset: %d", got) + } + + // Clamped, and the other direction responds at once after a limit. + for range 20 { + d.keys("<") + } + if got := d.leftEdge(); got != minLeftW { + t.Errorf("min width = %d, want %d", got, minLeftW) + } + d.keys(">") + if got := d.leftEdge(); got != minLeftW+splitStep { + t.Errorf("after limit, > gave %d", got) + } + for range 30 { + d.keys(">") + } + if got := d.leftEdge(); got != 100-minDetailW { + t.Errorf("max width = %d, want %d", got, 100-minDetailW) + } + + // Focus doesn't matter: it's the divider that moves. + d.keys("=", "4", ">") + if got := d.leftEdge(); got != start+splitStep { + t.Errorf("from the detail pane: %d", got) + } +} + +func TestCopyAndRevealWhileFiltering(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "/") + d.typeText("api") + d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) + if d.clip().content != "sk_live_51HxDEMO0000000000000000" { + t.Errorf("ctrl+y copied %q", d.clip().content) + } + d.send(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl}) + if !strings.Contains(d.screen(), "sk_live_51HxDEMO") { + t.Error("ctrl+r didn't reveal") + } + if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 { + t.Error("left filter mode or changed the query") + } + d.send(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl}) + if strings.Contains(d.screen(), "sk_live_51HxDEMO") { + t.Error("second ctrl+r didn't hide") + } +} + +func TestCtrlNP(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3") + d.send(tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}) + if _, _, s := d.selected(); s != "api-key" { + t.Errorf("ctrl+n: %q", s) + } + d.keys("/") + d.typeText("e") + d.send(tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}) // moves, doesn't type + if !d.m.secrets.Filtering() || d.m.secrets.HasFilter() && strings.Contains(d.screen(), "/ep") { + t.Error("ctrl+p typed into the filter") + } + d.keys("esc", "?") + before := d.m.prompt.cursor + d.send(tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}) + if d.m.prompt.cursor == before { + t.Error("ctrl+n didn't move in the menu") + } +} + +func TestRevealedValueHides(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{RevealTimeout: 30 * time.Second, HideOnBlur: true}) + clock := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + d.m.now = func() time.Time { return clock } + + d.keys("space") + if !strings.Contains(d.screen(), "correct-horse-battery-staple") || !strings.Contains(d.screen(), "hides in 30s") { + t.Fatalf("not revealed with a countdown:\n%s", d.screen()) + } + clock = clock.Add(12 * time.Second) + d.send(revealTickMsg{d.m.revealToken}) + if !strings.Contains(d.screen(), "hides in 18s") { + t.Errorf("countdown:\n%s", d.screen()) + } + clock = clock.Add(18 * time.Second) + d.send(revealTickMsg{d.m.revealToken}) + if strings.Contains(d.screen(), "correct-horse") { + t.Errorf("still shown after the timeout:\n%s", d.screen()) + } + + // Losing focus hides it at once. + d.keys("space") + d.send(tea.BlurMsg{}) + if strings.Contains(d.screen(), "correct-horse") { + t.Errorf("still shown after losing focus:\n%s", d.screen()) + } + // A tick from the earlier reveal does nothing. + d.send(revealTickMsg{d.m.revealToken - 1}) +} + +func TestRevealTimeoutOff(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{}) + d.keys("space") + d.send(tea.BlurMsg{}) + if !strings.Contains(d.screen(), "correct-horse-battery-staple") || strings.Contains(d.screen(), "hides in") { + t.Errorf("hidden though both are off:\n%s", d.screen()) + } +} + +func TestExpiryMarkers(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}) + now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + d.m.now = func() time.Time { return now } + o := func(exp time.Time) provider.Object { return provider.Object{Name: "x", Expires: exp} } + for _, c := range []struct { + exp time.Time + want string + }{ + {now.Add(-time.Hour), "✗ expired"}, + {now.Add(12*24*time.Hour + time.Hour), "⚠ 12d"}, + {now.Add(5 * time.Hour), "⚠ 5h"}, + {now.Add(60 * 24 * time.Hour), ""}, + {time.Time{}, ""}, + } { + got := ansi.Strip(d.m.objectRender()(d.m.st, o(c.exp), 40)) + if c.want == "" && strings.ContainsAny(got, "✗⚠") || c.want != "" && !strings.Contains(got, c.want) { + t.Errorf("expires %v: %q, want %q", c.exp, got, c.want) + } + } + if w := d.m.expiryWord(o(now.Add(-time.Hour))); w != "expired" { + t.Errorf("filter word %q", w) + } +} + +func TestSourcesFavouritesTab(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("j", "f") // star platform-dev + d.keys("]") + if !d.m.sourcesFavs || !strings.Contains(d.screen(), "Favourites") { + t.Fatalf("not on the Favourites tab:\n%s", d.screen()) + } + var names []string + for _, r := range d.m.sources.Items() { + names = append(names, r.source.Name) + } + if strings.Join(names, ",") != "platform-dev" { + t.Errorf("favourites tab rows = %v", names) + } + if src, _, _ := d.selected(); src != "platform-dev" { + t.Errorf("cursor on %q, want the starred source", src) + } + d.keys("[") + if d.m.sourcesFavs || len(d.m.sources.Items()) < 4 { + t.Errorf("back on Sources: favs=%v, %d rows", d.m.sourcesFavs, len(d.m.sources.Items())) + } +} + +func TestRebindingShowsEverywhere(t *testing.T) { + t.Parallel() + km := keys.Default() + if err := km.Apply(map[string]map[string][]string{"lists": {"reveal": {"e"}, "versions": {}}}); err != nil { + t.Fatal(err) + } + d := newDriverOpts(t, Options{Keys: &km}) + d.keys("3") + scr := d.screen() + if !strings.Contains(scr, "(e to reveal)") || strings.Contains(scr, "space to reveal") { + t.Errorf("detail hint doesn't follow the binding:\n%s", scr) + } + if !strings.Contains(lastLine(scr), "e reveal/hide") { + t.Errorf("help bar: %q", lastLine(scr)) + } + if strings.Contains(scr, "to browse") { + t.Errorf("hint for unbound versions:\n%s", scr) + } + d.keys("space") + if strings.Contains(d.screen(), "correct-horse") { + t.Error("space still reveals") + } + d.keys("e") + if !strings.Contains(d.screen(), "correct-horse-battery-staple") { + t.Errorf("e doesn't reveal:\n%s", d.screen()) + } +} + +// failingValue fails to read values with a multi-line error, like sops. +type failingValue struct{ provider.Provider } + +const multiLineErr = "sops couldn't decrypt it:\nFailed to get the data key.\n\nGroup 0: FAILED\n age1abc: FAILED\n - | no identity matched any of the recipients" + +func (failingValue) Value(context.Context, provider.Object) (provider.SecretValue, error) { + return provider.SecretValue{}, errors.New(multiLineErr) +} + +func TestMultiLineErrorsShownWhole(t *testing.T) { + t.Parallel() + d := newDriver(t, failingValue{fake.Azure(0)}) + d.keys("3", "space") + scr := d.screen() + for _, line := range strings.Split(multiLineErr, "\n") { + if line != "" && !strings.Contains(scr, line) { + t.Errorf("detail pane lacks %q:\n%s", line, scr) + } + } + d.keys("y") // copying fails the same way: one line in the footer + if !strings.Contains(scr, "Group 0") || !strings.Contains(d.screen(), "✗ copy failed: sops couldn't decrypt it: … (the whole message: space on the secret)") { + t.Errorf("footer:\n%s", d.screen()) + } +} diff --git a/internal/ui/app/cache.go b/internal/ui/app/cache.go new file mode 100644 index 0000000..b722a0c --- /dev/null +++ b/internal/ui/app/cache.go @@ -0,0 +1,50 @@ +package app + +import ( + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// cacheTTL is how long cached metadata is shown without reloading. Older +// entries are still shown immediately, then reloaded in the background. +const cacheTTL = 2 * time.Minute + +// cache holds metadata for this session only. It never holds secret values +// or errors, and is never written to disk. +type cache struct { + containers map[string]entry[[]provider.Container] // by source key + objects map[string]entry[[]provider.Object] // by container key (ID|type) + details map[string]entry[provider.Object] // Describe results by ID@version + // cursor remembers the selected child per parent key, so returning to + // a subscription or vault puts the cursor back where it was. + cursor map[string]string +} + +type entry[T any] struct { + value T + at time.Time +} + +func newCache() cache { + return cache{ + containers: map[string]entry[[]provider.Container]{}, + objects: map[string]entry[[]provider.Object]{}, + details: map[string]entry[provider.Object]{}, + cursor: map[string]string{}, + } +} + +func (e entry[T]) fresh(now time.Time) bool { return now.Sub(e.at) < cacheTTL } + +// detailKey includes the account: the result carries its container, and +// later requests for the object are routed by it. +func detailKey(o provider.Object) string { + return containerKey(o.Container) + "|" + o.ID + "@" + o.Version +} + +func (c cache) rememberCursor(parent, child string) { + if parent != "" && child != "" { + c.cursor[parent] = child + } +} diff --git a/internal/ui/app/cache_test.go b/internal/ui/app/cache_test.go new file mode 100644 index 0000000..5047d06 --- /dev/null +++ b/internal/ui/app/cache_test.go @@ -0,0 +1,161 @@ +package app + +import ( + "context" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" +) + +// counting records how often lists and details are fetched. +type counting struct { + provider.Provider + containers, objects, describes, values, versions int +} + +func (c *counting) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) { + c.versions++ + return c.Provider.Versions(ctx, o) +} + +func (c *counting) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) { + c.containers++ + return c.Provider.Containers(ctx, src) +} + +func (c *counting) Objects(ctx context.Context, ct provider.Container, t provider.ObjectType) ([]provider.Object, error) { + c.objects++ + return c.Provider.Objects(ctx, ct, t) +} + +func (c *counting) Describe(ctx context.Context, o provider.Object) (provider.Object, error) { + c.describes++ + return c.Provider.Describe(ctx, o) +} + +func (c *counting) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { + c.values++ + return c.Provider.Value(ctx, o) +} + +func newCachingDriver(t *testing.T) (*driver, *counting, *time.Time) { + t.Helper() + p := &counting{Provider: fake.Azure(0)} + d := newDriver(t, p) + // Entries from the initial load were stamped with the real clock. + clock := time.Now() + d.m.now = func() time.Time { return clock } + return d, p, &clock +} + +func TestCacheAvoidsRefetchAndRestoresCursor(t *testing.T) { + t.Parallel() + d, p, _ := newCachingDriver(t) + d.keys("2", "j", "3", "j") // kv-shared-prod... then back + d.keys("2", "k", "3", "j") // kv-app-prod, cursor to api-key + d.keys("1", "j", "k") // away to platform-dev and back + before := *p + + d.keys("j", "k") + if p.containers != before.containers || p.objects != before.objects { + t.Errorf("fresh cache refetched: containers %d→%d, objects %d→%d", + before.containers, p.containers, before.objects, p.objects) + } + if _, c, s := d.selected(); c != "kv-app-prod" || s != "api-key" { + t.Errorf("cursor not restored: %s/%s", c, s) + } +} + +func TestStaleCacheShownThenReloaded(t *testing.T) { + t.Parallel() + d, p, clock := newCachingDriver(t) + d.keys("j") // platform-dev + *clock = clock.Add(cacheTTL + time.Second) + before := p.containers + d.keys("k") // back to platform-prod: stale + if p.containers != before+1 { + t.Errorf("stale entry not reloaded: %d calls, want %d", p.containers, before+1) + } + if _, c, _ := d.selected(); c != "kv-app-prod" { + t.Errorf("selection lost across background reload: %q", c) + } +} + +func TestRefreshBypassesCache(t *testing.T) { + t.Parallel() + d, p, _ := newCachingDriver(t) + before := p.objects + d.keys("3", "r") + if p.objects != before+1 { + t.Errorf("r did not reload: %d calls, want %d", p.objects, before+1) + } +} + +func TestValuesAndErrorsNotCached(t *testing.T) { + t.Parallel() + d, p, _ := newCachingDriver(t) + d.keys("space", "space", "space") + if p.values != 2 { + t.Errorf("value fetched %d times for two reveals, want 2", p.values) + } + + d.keys("2", "G") // kv-restricted: 403 + if _, ok := d.m.cache.objects[d.m.selContainer]; ok { + t.Error("error result cached") + } + before := p.objects + d.keys("k", "j") + if p.objects != before+2 { + t.Errorf("failed list not retried: %d calls, want %d", p.objects, before+2) + } +} + +func TestSignInDropsCache(t *testing.T) { + t.Parallel() + d, _, _ := newCachingDriver(t) + d.send(loginDoneMsg{}) + // Everything is reloaded fresh, so only the current selection is cached. + if n := len(d.m.cache.containers); n != 1 { + t.Errorf("containers cached after sign-in = %d, want 1", n) + } +} + +// runAll runs cmd and the commands it batches, ignoring their messages. +func runAll(cmd tea.Cmd) { + if cmd == nil { + return + } + if b, ok := cmd().(tea.BatchMsg); ok { + for _, c := range b { + runAll(c) + } + } +} + +func TestMovingOnDropsUnsettledLoads(t *testing.T) { + t.Parallel() + c := &counting{Provider: fake.Azure(0)} + d := newDriverOpts(t, Options{}, c) + d.m.settle = 20 * time.Millisecond + before := c.containers + + // j to platform-dev and straight back: its load never goes out. + m1, passed := d.m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) + _, back := m1.Update(tea.KeyPressMsg{Code: 'k', Text: "k"}) + runAll(back) + runAll(passed) + if c.containers != before { + t.Errorf("passing over a subscription loaded it: %d calls", c.containers-before) + } + + // Stopping on it loads it once the cursor settles. + _, stop := d.m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) + runAll(stop) + if c.containers != before+1 { + t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before) + } +} diff --git a/internal/ui/app/commands.go b/internal/ui/app/commands.go new file mode 100644 index 0000000..4caeca2 --- /dev/null +++ b/internal/ui/app/commands.go @@ -0,0 +1,318 @@ +package app + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/commands" + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" +) + +// Custom commands (customCommands in the config) run a shell command on +// the selected item: by their key in the pane they're for, or from the +// commands menu (x). Prompts ask for text first; commands that get the +// secret value ask before running. The value reaches the command on its +// input or in an environment variable, never on its command line. + +// commandTimeout bounds commands whose output is captured; terminal ones +// run as long as the user likes. +const commandTimeout = 5 * time.Minute + +type commandValueMsg struct { + cmd commands.Command + rendered string + value provider.SecretValue + err error +} + +type commandDoneMsg struct { + cmd commands.Command + out string + err error +} + +// paneContext is the custom command context of a pane. +func paneContext(p pane) string { + switch p { + case paneSources: + return commands.Sources + case paneContainers: + return commands.Vaults + } + return commands.Secrets +} + +// commandsHere are the custom commands for the focused pane. +func (m *Model) commandsHere() []commands.Command { + var out []commands.Command + for _, c := range m.commands { + if c.Context == commands.Global || c.Context == paneContext(m.focus) { + out = append(out, c) + } + } + return out +} + +// commandForKey is the custom command a key runs here, if any. +func (m *Model) commandForKey(msg tea.KeyPressMsg) (commands.Command, bool) { + for _, c := range m.commandsHere() { + if c.Key != "" && c.Key == msg.String() { + return c, true + } + } + return commands.Command{}, false +} + +// showCommands lists the custom commands for the focused pane. +func (m *Model) showCommands() tea.Cmd { + cmds := m.commandsHere() + if len(cmds) == 0 { + m.flash = warning("no custom commands for the " + strings.ToLower(paneNames[m.focus]) + " pane: see customCommands in the config") + return nil + } + var opts []option + for _, c := range cmds { + label := c.Description + if label == "" { + label = c.Command + } + if c.Key != "" { + label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label + } + opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }}) + } + return m.showFilterMenu("Commands", opts) +} + +// startCommand runs c on the selection: prompts, then confirmation, then +// the command. +func (m *Model) startCommand(c commands.Command) tea.Cmd { + it, obj, ok := m.commandItem(c.Context) + if !ok { + m.flash = warning(map[string]string{ + commands.Sources: "select a subscription or workspace to run " + c.Description + " on", + commands.Vaults: "select a vault or scope to run " + c.Description + " on", + commands.Secrets: "select a secret to run " + c.Description + " on", + }[c.Context]) + return nil + } + it.Prompt = map[string]commands.Q{} + return m.askPrompt(c, it, obj, 0) +} + +// askPrompt asks c's prompts in turn, then confirms or runs it. +func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Object, i int) tea.Cmd { + if i == len(c.Prompts) { + return m.confirmCommand(c, it, obj) + } + p := c.Prompts[i] + in := newTextInput(p.Title+": ", 48) + in.SetValue(p.Default) + in.CursorEnd() + m.prompt = &modal{ + title: cmpOr(c.Description, "Command"), input: &in, + submit: func(m *Model, text string) tea.Cmd { + it.Prompt[p.Name] = commands.Q(text) + return m.askPrompt(c, it, obj, i+1) + }, + footer: footer(hint(m.keys.PopupSelect, "next"), hint(m.keys.PopupClose, "cancel")), + } + return m.prompt.input.Focus() +} + +func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provider.Object) tea.Cmd { + rendered, err := c.Render(it) + if err != nil { + m.flash = flash{text: c.Description + ": " + err.Error(), isErr: true} + return nil + } + if !c.Confirms() { + return m.runCommand(c, rendered, obj) + } + var lines []string + for _, l := range strings.Split(ansi.Wrap(rendered, 70, " "), "\n") { + lines = append(lines, m.st.Muted.Render(l)) + } + switch { + case c.Value == "stdin": + lines = append(lines, "", "It gets the value of "+string(it.Name)+" on its input.") + case strings.HasPrefix(c.Value, "env:"): + lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".") + } + m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{ + {key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }}, + }) + return nil +} + +// runCommand fetches the value if the command takes it, then runs it. +func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Object) tea.Cmd { + if c.Value == "" || obj == nil { + return m.execCommand(c, rendered, provider.SecretValue{}) + } + o := *obj + p := m.providerFor(o.Container.Source) + m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"} + timeout := m.timeout + m.usedObject(o) + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + v, err := p.Value(ctx, o) + return commandValueMsg{cmd: c, rendered: rendered, value: v, err: err} + } +} + +// execCommand runs the command as its output mode says. +func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd { + shell := cmpOr(os.Getenv("SHELL"), "/bin/sh") + setValue := func(cmd *exec.Cmd) { + switch { + case c.Value == "stdin": + cmd.Stdin = strings.NewReader(value.Reveal()) + case strings.HasPrefix(c.Value, "env:"): + cmd.Env = append(os.Environ(), strings.TrimPrefix(c.Value, "env:")+"="+value.Reveal()) + } + } + if c.Out() == commands.Terminal { + // Pause before coming back, reading the terminal itself: the + // command's input may be taken. + script := rendered + "\nprintf '\\n%s' 'Press enter to return to lazysecrets'; read -r _ = 0 { + out = out[i+1:] // the last line, usually the error + } + m.flash = flash{text: strings.TrimSpace(name + " failed: " + msg.err.Error() + " " + out), isErr: true} + default: + m.flash = done(name) + } + return + } + lines := strings.Split(strings.TrimRight(ansi.Strip(msg.out), "\n"), "\n") + if len(lines) > maxOutputLines { + lines = append(lines[:maxOutputLines], m.st.Muted.Render(fmt.Sprintf("… %d more lines", len(lines)-maxOutputLines))) + } + title := "✓ " + name + if msg.err != nil { + title = "✗ " + name + ": " + msg.err.Error() + } + m.showMenu(title, lines, nil) +} + +// commandItem fills a command's fields from the selection: the source, +// and the vault and secret as deep as the context goes (global: as deep +// as the focus). ok is false if the context's item isn't selected. +func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool) { + depth := map[string]int{commands.Sources: 0, commands.Vaults: 1, commands.Secrets: 2}[ctx] + if ctx == commands.Global { + depth = min(int(m.focus), 2) + } + var it commands.Item + src, ok := m.selectedSource() + if !ok && depth == 0 { + return it, nil, false + } + if ok { + it.Kind, it.Account = commands.Q(strings.ToLower(string(src.Kind))), commands.Q(accountOf(src)) + it.Source, it.SourceID, it.URL = commands.Q(src.Name), commands.Q(src.ID), commands.Q(src.URL) + it.Note = commands.Q(m.store.Node(sourceKey(src)).Note) + if r, err := ref.ForSource(src); err == nil { + it.Link = commands.Q(r.String()) + } + } + if depth == 0 { + return it, nil, true + } + c, ok := m.containers.Selected() + if !ok { + return it, nil, ctx == commands.Global + } + it.Vault, it.URL, it.Note = commands.Q(c.Name), commands.Q(c.URL), commands.Q(m.store.Node(storeKey(c)).Note) + if c.Source.Kind == provider.KindAzure { + it.VaultURL = commands.Q(c.Detail) + } + if r, err := ref.ForContainer(c, m.objType); err == nil { + it.Link = commands.Q(r.String()) + } + if depth == 1 { + return it, nil, true + } + o, ok := m.detail.Secret() + if !ok { + return it, nil, ctx == commands.Global + } + version := "" + if m.versionsOf != nil { + version = o.Version // run from the versions view: that version + } + it.Name, it.Type, it.Version = commands.Q(o.Name), commands.Q(strings.ToLower(string(o.Type))), commands.Q(version) + it.Reference, it.URL, it.Note = commands.Q(o.Reference), commands.Q(o.URL), commands.Q(m.store.Node(objectKey(o)).Note) + if !o.Expires.IsZero() { + it.Expires = commands.Q(o.Expires.Format(time.RFC3339)) + } + if r, err := ref.ForObject(o, version); err == nil { + it.Link = commands.Q(r.String()) + } + if version != "" { + o.Version = version + } else { + o.Version = "" // the current value, like copying it + } + return it, &o, true +} + +func newTextInput(prompt string, width int) textinput.Model { + in := textinput.New() + in.Prompt = prompt + in.SetWidth(width) + return in +} + +func cmpOr(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/internal/ui/app/commands_test.go b/internal/ui/app/commands_test.go new file mode 100644 index 0000000..2b18459 --- /dev/null +++ b/internal/ui/app/commands_test.go @@ -0,0 +1,79 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/commands" +) + +func commandDriver(t *testing.T, cmds ...commands.Command) *driver { + t.Setenv("SHELL", "/bin/sh") + d := newDriverOpts(t, Options{Commands: cmds}) + d.wait = 5 * time.Second + return d +} + +func TestCommandGetsValueOnInput(t *testing.T) { + out := filepath.Join(t.TempDir(), "out") + d := commandDriver(t, commands.Command{ + Key: "K", Context: commands.Secrets, Description: "Save it", + Command: "cat > " + out + " && echo {{.Name}} {{.Prompt.ns}} >> " + out, + Value: "stdin", Prompts: []commands.Prompt{{Name: "ns", Title: "Namespace", Default: "team a"}}, + }) + d.keys("3", "K") // db-password + if !strings.Contains(d.screen(), "Namespace: team a") { + t.Fatalf("no prompt:\n%s", d.screen()) + } + d.keys("enter") + scr := d.screen() + // The command as shown, unwrapped: where it wraps depends on the + // temporary path's length. + shown := "" + if d.m.prompt != nil { + shown = ansiStrip(strings.Join(d.m.prompt.lines, "")) + } + if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") { + t.Fatalf("no confirmation with the command %q:\n%s", shown, scr) + } + if strings.Contains(scr, "correct-horse") { + t.Fatal("value shown in the confirmation") + } + d.keys("y") + b, err := os.ReadFile(out) + if err != nil { + t.Fatalf("command didn't run: %v\n%s", err, d.screen()) + } + if got := string(b); got != "correct-horse-battery-staple"+"db-password team a\n" { + t.Errorf("command got %q", got) + } + if !strings.Contains(d.screen(), "✓ Save it") { + t.Errorf("no confirmation message:\n%s", d.screen()) + } +} + +func TestCommandPopupAndMenu(t *testing.T) { + d := commandDriver(t, + commands.Command{Context: commands.Vaults, Description: "Show vault", Command: "echo vault={{.Vault}} url={{.VaultURL}}", Output: commands.Popup}, + commands.Command{Context: commands.Secrets, Description: "Secrets only", Command: "true"}, + ) + d.keys("2", "x") + if !strings.Contains(d.screen(), "Show vault") || strings.Contains(d.screen(), "Secrets only") { + t.Fatalf("commands menu for the Vaults pane:\n%s", d.screen()) + } + d.keys("enter") + if !strings.Contains(d.screen(), "vault=kv-app-prod url=https://kv-app-prod.vault.azure.net") { + t.Errorf("popup output:\n%s", d.screen()) + } +} + +func TestCommandNeedsItsSelection(t *testing.T) { + d := commandDriver(t, commands.Command{Key: "K", Context: commands.Secrets, Description: "Secret thing", Command: "true"}) + d.keys("1", "K") // Sources pane: not a secrets command's context + if d.m.prompt != nil { + t.Error("ran from the wrong pane") + } +} diff --git a/internal/ui/app/expiry.go b/internal/ui/app/expiry.go new file mode 100644 index 0000000..2d25440 --- /dev/null +++ b/internal/ui/app/expiry.go @@ -0,0 +1,55 @@ +package app + +import ( + "fmt" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// Secrets, keys and certificates with an expiry date are marked in the +// list once they have expired (✗ expired) or will within the configured +// warning period (⚠ 12d), and typing "expir" in the filter finds them. + +type expiryState int + +const ( + notExpiring expiryState = iota + expiresSoon + expired +) + +// expiry tells whether o has expired or will within the warning period, +// and how long it has left. +func (m Model) expiry(o provider.Object) (expiryState, time.Duration) { + if o.Expires.IsZero() { + return notExpiring, 0 + } + left := o.Expires.Sub(m.now()) + switch { + case left <= 0: + return expired, 0 + case left <= m.expiryWarning: + return expiresSoon, left + } + return notExpiring, left +} + +// expiryWord is matched by the filter: "expired" or "expiring". +func (m Model) expiryWord(o provider.Object) string { + switch state, _ := m.expiry(o); state { + case expired: + return "expired" + case expiresSoon: + return "expiring" + } + return "" +} + +// daysLeft is a short "12d", or "5h" on the last day. +func daysLeft(d time.Duration) string { + if d < 24*time.Hour { + return fmt.Sprintf("%dh", max(1, int(d.Hours()))) + } + return fmt.Sprintf("%dd", int(d.Hours()/24)) +} diff --git a/internal/ui/app/favourites.go b/internal/ui/app/favourites.go new file mode 100644 index 0000000..9faf2d8 --- /dev/null +++ b/internal/ui/app/favourites.go @@ -0,0 +1,245 @@ +package app + +import ( + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/store" + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +// What lazysecrets remembers about subscriptions, vaults and secrets — +// stars, notes — lives in the store, one record per item keyed by its +// lazysecrets:// link. Keys leave out the account, so a star or note on a +// subscription shows under every account that sees it. + +const star = "★ " + +func sourceKey(s provider.Source) string { + if r, err := ref.ForSource(s); err == nil { + return r.String() + } + return "source:" + string(s.Kind) + "/" + s.ID +} + +func storeKey(c provider.Container) string { + if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil { + return r.String() + } + return "container:" + c.ID +} + +func objectKey(o provider.Object) string { + if r, err := ref.ForObject(o, ""); err == nil { + return r.String() + } + return "object:" + o.ID +} + +// remember records context with a change, so lists of remembered items +// can show and jump to them later. +func (m *Model) remember(key, name string, path []string, src provider.Source, change func(*store.Node)) error { + ctx := withContext(name, path, src) + return m.store.Update(key, func(n *store.Node) { + ctx(n) + change(n) + }) +} + +// withContext sets a record's context: where the item is, and the account +// that read it. A vault opened from a link has no known subscription; +// that keeps the one already recorded. +func withContext(name string, path []string, src provider.Source) func(*store.Node) { + return func(n *store.Node) { + n.Name = name + if src.ID != "" || n.Source == "" { + n.Path, n.Source = path, src.ID + } + if src.Provider != "" { + n.Account = src.Provider + } + } +} + +// refreshContext updates a remembered item's context after reaching it, +// e.g. the account that could read it. Items without a record are left +// alone, so browsing doesn't write the file. +func (m *Model) refreshContext(key, name string, path []string, src provider.Source) { + if m.store.Has(key) && src.Provider != "" { + _ = m.remember(key, name, path, src, func(*store.Node) {}) + } +} + +// containerRender draws a vault or scope, starred or not. In the +// Opened links list it also names the source, since they come from many. +func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Container, int) string { + st, iconSet := m.store, m.icons + return func(sty styles.Styles, c provider.Container, width int) string { + n := st.Node(storeKey(c)) + line := c.Name + if c.Source.Kind == provider.KindRepo { + // A secret file: a lock for an encrypted one, a file in the + // warning colour for plain text, and its type after it + // ("sops · age", "env · plain text"). + icon, style := iconSet.Encrypted, sty.KindRepo + if !strings.HasPrefix(c.Detail, "sops") { + icon, style = iconSet.PlainFile, sty.Warning + } + if icon != "" { + line = style.Render(icon) + " " + line + } + line += sty.Muted.Render(" " + c.Detail) + } + if n.StarredFor(c.Source.Provider) { + line = sty.Favourite.Render(star) + line + } + if withSource { + src := c.Source.Name + if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok { + src = account + " / " + src + } + line += sty.Muted.Render(" · " + src) + } + return withNote(sty, line, n.Note, width) + } +} + +func (m Model) objectRender() func(styles.Styles, provider.Object, int) string { + st := m.store + return func(sty styles.Styles, o provider.Object, width int) string { + n := st.Node(objectKey(o)) + line := o.Name + if o.Enabled != nil && !*o.Enabled { + line = sty.Muted.Render(o.Name + " (disabled)") + } + if n.StarredFor(o.Container.Source.Provider) { + line = sty.Favourite.Render(star) + line + } + switch state, left := m.expiry(o); state { + case expired: + line += " " + sty.Error.Render("✗ expired") + case expiresSoon: + line += " " + sty.Warning.Render("⚠ "+daysLeft(left)) + } + return withNote(sty, line, n.Note, width) + } +} + +// starredContainersFirst moves starred vaults and scopes to the front, +// keeping the order otherwise. It doesn't modify items, which may be cached. +func (m Model) starredContainersFirst(items []provider.Container) []provider.Container { + out := slices.Clone(items) + slices.SortStableFunc(out, func(a, b provider.Container) int { + return boolRank(m.store.Node(storeKey(a)).StarredFor(a.Source.Provider)) - boolRank(m.store.Node(storeKey(b)).StarredFor(b.Source.Provider)) + }) + return out +} + +// starredFirst moves starred objects to the front, keeping the order +// otherwise. It doesn't modify items, which may be cached. +func (m Model) starredFirst(items []provider.Object) []provider.Object { + out := slices.Clone(items) + slices.SortStableFunc(out, func(a, b provider.Object) int { + return boolRank(m.store.Node(objectKey(a)).StarredFor(a.Container.Source.Provider)) - boolRank(m.store.Node(objectKey(b)).StarredFor(b.Container.Source.Provider)) + }) + return out +} + +// showLinkedContainers lists the vaults opened from links. +func (m *Model) showLinkedContainers() { + m.containers.Title, m.containers.Empty = "[2] Opened links", "no links opened" + m.containers.SetRender(m.containerRender(true)) + m.containers.SetItems(m.linked) + m.containers.Select(m.cache.cursor[linksRowID]) +} + +// rebind records the account that served a vault opened from a link. The +// container's key includes the account, so the selection moves to the +// new key without reloading. It returns the new key for the object list. +func (m *Model) rebind(c provider.Container) string { + m.refreshContext(storeKey(c), c.Name, []string{c.Source.Name}, c.Source) + for i := range m.linked { + if m.linked[i].ID == c.ID { + m.linked[i].Source = c.Source + } + } + key := containerKey(c) + "|" + string(m.objType) + if m.onLinks() { + m.containers.SetItems(m.linked) + m.containers.Select(containerKey(c)) + m.selContainer = key + } + return key +} + +// resortStarred puts starred items first again in every pane, after +// stars changed elsewhere (the favourites list). +func (m *Model) resortStarred() { + m.rebuildSources() + if !m.onLinks() { + m.containers.SetItems(m.starredContainersFirst(m.containers.Items())) + } + if m.versionsOf == nil { + m.secrets.SetItems(m.starredFirst(m.secrets.Items())) + } +} + +// toggleFavourite stars or unstars the selected subscription (Sources), +// vault or scope (Vaults) or object (Secrets and detail panes). +func (m *Model) toggleFavourite() tea.Cmd { + var name string + var on bool + // A star is the item through the account showing it. + var err error + switch m.focus { + case paneSources: + src, ok := m.selectedSource() + if !ok { + m.flash = warning("select a subscription or workspace to star") + return nil + } + name = src.Name + toggle := func(n *store.Node) { on = n.ToggleStar(src.Provider) } + if err = m.remember(sourceKey(src), src.Name, nil, src, toggle); err == nil { + m.rebuildSources() // re-sorts; the cursor follows the source + } + case paneContainers: + c, ok := m.containers.Selected() + if !ok { + return nil + } + name = c.Name + toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) } + if err = m.remember(storeKey(c), c.Name, []string{c.Source.Name}, c.Source, toggle); err == nil { + m.containers.SetItems(m.starredContainersFirst(m.containers.Items())) + } + default: + if m.versionsOf != nil { + m.flash = warning("star the item from the list rather than a version") + return nil + } + o, ok := m.detail.Secret() + if !ok { + return nil + } + name = o.Name + c := o.Container + toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) } + if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil { + m.secrets.SetItems(m.starredFirst(m.secrets.Items())) + } + } + switch { + case err != nil: + m.flash = flash{text: "saving: " + err.Error(), isErr: true} + case on: + m.flash = done("starred " + name) + default: + m.flash = done("unstarred " + name) + } + return nil +} diff --git a/internal/ui/app/favourites_test.go b/internal/ui/app/favourites_test.go new file mode 100644 index 0000000..d1ce13a --- /dev/null +++ b/internal/ui/app/favourites_test.go @@ -0,0 +1,435 @@ +package app + +import ( + "context" + "path/filepath" + "slices" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +// jump opens the favourites list, filters it and jumps to the first match. +func (d *driver) jump(filter string) { + d.t.Helper() + d.keys("F") + if d.m.search == nil || d.m.search.scope().id != scopeFavs { + d.t.Fatalf("F didn't open Go to on Favourites:\n%s", d.screen()) + } + for _, r := range filter { + d.send(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + d.keys("enter") +} + +func TestStarVaultSortsFirstAndJumps(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("2", "j", "f") // star kv-shared-prod + if items := d.m.containers.Items(); items[0].Name != "kv-shared-prod" { + t.Errorf("starred vault not first: %s", items[0].Name) + } + if !strings.Contains(d.screen(), "★ kv-shared-prod") { + t.Errorf("no star:\n%s", d.screen()) + } + if strings.Contains(d.screen(), "Favourites (") { + t.Error("the old Favourites row is back") + } + + d.keys("1", "j") // elsewhere + d.keys("F") + if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod") { + t.Fatalf("favourites list:\n%s", d.screen()) + } + d.keys("esc") + d.jump("shared") + if src, c, _ := d.selected(); src != "platform-prod" || c != "kv-shared-prod" { + t.Errorf("jumped to %s/%s", src, c) + } + if d.m.focus != paneContainers { + t.Errorf("focus %d after jumping to a vault", d.m.focus) + } +} + +func TestJumpToStarredSourceAndSecret(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "j", "f") // star api-key in platform-prod / kv-app-prod + d.keys("1", "j", "f") // star platform-dev (it moves to the top of its group) + d.keys("j", "j", "j") // down to Databricks prod + + d.jump("platform-dev") + if src, _, _ := d.selected(); src != "platform-dev" || d.m.focus != paneContainers { + t.Errorf("source jump: %s, focus %d", src, d.m.focus) + } + d.jump("api") + if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" { + t.Errorf("secret jump: %s/%s/%s", src, c, s) + } +} + +func TestFavouritesListEmpty(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("F") + if d.m.search == nil || !strings.Contains(d.screen(), "nothing starred yet") { + t.Errorf("empty favourites:\n%s", d.screen()) + } +} + +func TestStarSecretSortsFirst(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "j", "f") // star api-key + if _, _, s := d.selected(); s != "api-key" { + t.Errorf("cursor left the starred item: %q", s) + } + rows := d.m.secrets.Items() + if rows[0].Name != "api-key" || !strings.Contains(d.screen(), "★ api-key") { + t.Errorf("starred secret not first: %v\n%s", rows[0].Name, d.screen()) + } + // Still first after leaving and coming back (cached list). + d.keys("2", "j", "k") + if d.m.secrets.Items()[0].Name != "api-key" { + t.Error("starred secret not first after returning") + } +} + +func TestNoStarringVersions(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("v", "f") + if d.m.store.Node(objectKey(*d.m.versionsOf)).StarredFor("Azure") || !strings.Contains(d.screen(), "rather than a version") { + t.Errorf("starring a version:\n%s", d.screen()) + } +} + +func TestFavouritesPersist(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "state.json") + load := func() *store.Store { + s, err := store.Load(path) + if err != nil { + t.Fatal(err) + } + return s + } + d := newDriverOpts(t, Options{Store: load()}) + d.keys("2", "f", "3", "j", "f") + + d2 := newDriverOpts(t, Options{Store: load()}) + d2.keys("F") + if !strings.Contains(d2.screen(), "platform-prod / kv-app-prod") || + !strings.Contains(d2.screen(), "platform-prod / kv-app-prod / api-key") { + t.Errorf("favourites not restored:\n%s", d2.screen()) + } + d2.keys("esc") + if d2.m.secrets.Items()[0].Name != "api-key" { + t.Error("secret favourite not restored") + } +} + +func TestStarSourceMovesItToTheTop(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("j", "f") // star platform-dev + if src, _, _ := d.selected(); src != "platform-dev" { + t.Errorf("cursor left the starred source: %q", src) + } + rows := d.m.sources.Items() + if rows[1].source.Name != "platform-dev" || !strings.Contains(d.screen(), "★ platform-dev") { + t.Errorf("not first in its group: %q\n%s", rows[1].source.Name, d.screen()) + } + + // Flat: starred sources come before every other provider's. + // platform-dev now leads its group: platform-prod, header, prod, dev. + d.keys("j", "j", "j", "j", "f", "`") // also star Databricks dev + var flat []string + for _, r := range d.m.sources.Items() { + flat = append(flat, r.source.Name) + } + if strings.Join(flat[:2], ",") != "platform-dev,dev" { + t.Errorf("flat order = %v", flat) + } + + d.keys("`", "k") // grouped again; dev leads its group, so k is the header + d.keys("f") + if !strings.Contains(d.screen(), "select a subscription or workspace to star") { + t.Errorf("starring a header:\n%s", d.screen()) + } +} + +// denying refuses to list objects, like an account without access. +type denying struct{ *counting } + +func (d denying) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) { + d.objects++ + return nil, fake.ErrForbidden +} + +// kvKey is kv-app-prod's store key, kvSharedKey kv-shared-prod's. +const ( + kvKey = "lazysecrets://azure/kv-app-prod.vault.azure.net" + kvSharedKey = "lazysecrets://azure/kv-shared-prod.vault.azure.net" +) + +// starredWithoutAccount is kv-app-prod starred with no account recorded, +// like one starred by an account that's no longer signed in. +func starredWithoutAccount(t *testing.T) *store.Store { + t.Helper() + s, _ := store.Load("") + if err := s.Update(kvKey, func(n *store.Node) { + n.Name, n.Path, n.Source, n.StarredBy = "kv-app-prod", []string{"platform-prod"}, "sub-prod", []string{"Azure:carol"} + }); err != nil { + t.Fatal(err) + } + return s +} + +func TestOldFavouriteFindsTheAccountWithAccess(t *testing.T) { + t.Parallel() + alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")} + bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")} + mem := starredWithoutAccount(t) + d := newDriverOpts(t, Options{Store: mem}, denying{alice}, bob) + + d.jump("kv-app") // both accounts see platform-prod: which one? + if !strings.Contains(d.screen(), "Several accounts can see kv-app-prod") { + t.Fatalf("no account choice:\n%s", d.screen()) + } + d.keys("2") // bob + if _, c, s := d.selected(); c != "kv-app-prod" || s != "db-password" { + t.Fatalf("favourite not browsable: %q/%q\n%s", c, s, d.screen()) + } + if got := mem.Node(kvKey).Account; got != "Azure:bob" { + t.Errorf("favourite bound to %q, want Azure:bob", got) + } + d.keys("R") // used through bob now + if !strings.Contains(d.screen(), "platform-prod / kv-app-prod · bob") { + t.Errorf("recent should show the account:\n%s", d.screen()) + } + d.keys("esc") + + // Reads go through bob, and alice isn't asked again. + before := alice.objects + d.keys("space") + if bob.values != 1 || alice.values != 0 { + t.Errorf("reveal: alice %d, bob %d", alice.values, bob.values) + } + d.keys("2", "r") + if alice.objects != before { + t.Errorf("alice asked again after binding: %d → %d", before, alice.objects) + } +} + +func TestOldFavouriteNoAccountHasAccess(t *testing.T) { + t.Parallel() + alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")} + bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")} + mem := starredWithoutAccount(t) + d := newDriverOpts(t, Options{Store: mem}, denying{alice}, denying{bob}) + + d.jump("kv-app") + d.keys("1") // alice: no access + if !strings.Contains(d.screen(), "403 Forbidden") { + t.Errorf("no access error:\n%s", d.screen()) + } + if mem.Node(kvKey).Account != "" { + t.Error("favourite bound although no account worked") + } +} + +func TestFavouritesPopupKeepsItsSize(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("j", "f", "2", "j", "f", "3", "j", "f") // a subscription, a vault, a secret + d.keys("F") + size := func() (int, int) { + box := d.m.searchViewBox() + return lipgloss.Width(box), lipgloss.Height(box) + } + w0, h0 := size() + for _, k := range []string{"down", "down", "up"} { + d.send(tea.KeyPressMsg{Code: map[string]rune{"down": tea.KeyDown, "up": tea.KeyUp}[k]}) + if w, h := size(); w != w0 || h != h0 { + t.Fatalf("popup changed from %dx%d to %dx%d moving %s", w0, h0, w, h, k) + } + } + d.send(tea.KeyPressMsg{Code: 'z', Text: "z"}) // filter down to nothing + if w, h := size(); w != w0 || h != h0 { + t.Errorf("popup changed from %dx%d to %dx%d while filtering", w0, h0, w, h) + } +} + +func TestStoreKeysAreLinks(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("f", "2", "f", "3", "j", "f") + var keys []string + for k := range d.m.store.Nodes() { + keys = append(keys, k) + } + slices.Sort(keys) + want := []string{ + "lazysecrets://azure/kv-app-prod.vault.azure.net", + "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key", + "lazysecrets://azure/subscriptions/sub-prod", + } + if !slices.Equal(keys, want) { + t.Errorf("keys = %v", keys) + } + n := d.m.store.Node(want[1]) + if n.Name != "api-key" || n.Source != "sub-prod" || n.Account != "Azure" || !slices.Equal(n.Path, []string{"platform-prod", "kv-app-prod"}) { + t.Errorf("context = %+v", n) + } +} + +func TestFavouritesShowNotes(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("2", "j", "f", "n") // star kv-shared-prod and note it + for _, r := range "shared certs" { + d.send(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + d.keys("enter", "F") + line := "" + for l := range strings.SplitSeq(d.screen(), "\n") { + if strings.Contains(l, "kv-shared-prod") && strings.Contains(l, "│") && strings.Contains(l, "/") { + line = l + } + } + if !strings.Contains(line, "shared certs") { + t.Fatalf("no note in the favourites row %q:\n%s", line, d.screen()) + } + for _, r := range "certs" { // the filter matches notes + d.send(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + if len(d.m.search.shown) != 1 { + t.Errorf("filtering by note: %d entries", len(d.m.search.shown)) + } +} + +func TestSecretWithoutContextJumpsThroughItsVault(t *testing.T) { + t.Parallel() + mem, _ := store.Load("") + _ = mem.Update(kvKey, func(n *store.Node) { + n.Name, n.Path, n.Source, n.Account, n.StarredBy = "kv-app-prod", []string{"platform-prod"}, "sub-prod", "Azure", []string{"Azure"} + }) + _ = mem.Update(kvKey+"/secrets/api-key", func(n *store.Node) { n.StarredBy = []string{"Azure"} }) + // counting hides Locate, like an account that can't find the vault + // in Resource Graph: opening it as a link would land in Opened links. + d := newDriverOpts(t, Options{Store: mem}, &counting{Provider: fake.Azure(0)}) + d.keys("j") // elsewhere + d.jump("api-key") + if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" { + t.Errorf("jumped to %s/%s/%s", src, c, s) + } + if strings.Contains(d.screen(), "Opened links") { + t.Errorf("went through Opened links:\n%s", d.screen()) + } +} + +func TestLinkedVaultKeepsKnownContext(t *testing.T) { + t.Parallel() + n := store.Node{Source: "sub-prod", Path: []string{"platform-prod", "kv-app-prod"}} + // A vault opened from a link: account known, subscription not. + withContext("api-key", []string{"kv-app-prod.vault.azure.net", "kv-app-prod"}, + provider.Source{Kind: provider.KindAzure, Provider: "Azure:bob"})(&n) + if n.Source != "sub-prod" || n.Path[0] != "platform-prod" || n.Account != "Azure:bob" { + t.Errorf("context = %+v", n) + } +} + +func TestToggleStarInGoTo(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("2", "j", "f") // star kv-shared-prod + d.keys("3", "j", "space", "2") // use smtp-password, unstarred + smtp := kvSharedKey + "/secrets/smtp-password" + ctrlF := tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl} + + d.keys("R", "/") + d.typeText("smtp") + d.send(ctrlF) // star it from Recent, typing + if !d.m.store.Node(smtp).StarredFor("Azure") || !strings.Contains(d.screen(), "★ ") { + t.Fatalf("ctrl+f didn't star a recent item:\n%s", d.screen()) + } + d.send(ctrlF) // and back + if d.m.store.Node(smtp).StarredFor("Azure") { + t.Error("second ctrl+f didn't unstar") + } + if d.m.search == nil || d.m.search.input.Value() != "smtp" || len(d.m.search.shown) == 0 { + t.Fatalf("entry or query gone after toggling:\n%s", d.screen()) + } + + // Unstarring a favourite keeps it listed until Go to opens again. + d.keys("esc", "esc", "F") // stop typing, close, open on Favourites + v := d.m.search + for v.res < len(v.shown)-1 && v.shown[v.res].star != kvSharedKey { + d.send(tea.KeyPressMsg{Code: tea.KeyDown}) + } + if v.shown[v.res].star != kvSharedKey { + t.Fatalf("selected %q", v.shown[v.res].star) + } + d.keys("f") // browsing: f + if d.m.store.Node(kvSharedKey).StarredFor("Azure") { + t.Error("favourite still starred") + } + if v.shown[v.res].star != kvSharedKey || v.shown[v.res].starred { + t.Error("unstarred favourite didn't stay in place, unstarred") + } + d.keys("esc") + if strings.Contains(d.screen(), "★ kv-shared-prod") { + t.Errorf("Vaults pane still shows the star:\n%s", d.screen()) + } +} + +func TestStarWhileFiltering(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "/") + for _, r := range "api" { + d.send(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}) + if !d.m.store.Node(kvKey + "/secrets/api-key").StarredFor("Azure") { + t.Errorf("ctrl+f while filtering didn't star:\n%s", d.screen()) + } +} + +func TestStarIsPerAccount(t *testing.T) { + t.Parallel() + alice := fake.Azure(0).As("Azure:alice", "Azure · alice") + bob := fake.Azure(0).As("Azure:bob", "Azure · bob") + d := newDriverOpts(t, Options{}, alice, bob) + // Rows: alice header, platform-prod, platform-dev, bob header, …. + d.keys("f") // alice's platform-prod + n := d.m.store.Node("lazysecrets://azure/subscriptions/sub-prod") + if !n.StarredFor("Azure:alice") || n.StarredFor("Azure:bob") { + t.Errorf("starred by %v, want alice only", n.StarredBy) + } + if c := strings.Count(d.screen(), "★ platform-prod"); c != 1 { + t.Errorf("star shown %d times, want once:\n%s", c, d.screen()) + } + // A note is about the thing: it shows under both. + d.keys("n") + d.typeText("shared note") + d.keys("enter") + if n := strings.Count(d.screen(), "shared note"); n != 2 { + t.Errorf("note shown %d times, want 2:\n%s", n, d.screen()) + } + d.keys("F") + var labels []string + for _, o := range d.m.search.shown { + labels = append(labels, ansiStrip(o.label)) + } + if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") { + t.Errorf("favourites should list alice's star only: %q", labels) + } +} diff --git a/internal/ui/app/handlersetup.go b/internal/ui/app/handlersetup.go new file mode 100644 index 0000000..002d40d --- /dev/null +++ b/internal/ui/app/handlersetup.go @@ -0,0 +1,74 @@ +package app + +import tea "charm.land/bubbletea/v2" + +// At startup, if lazysecrets:// links don't open lazysecrets, a popup +// offers to set that up, once per start until the user says not to ask +// again; that choice is kept in the store. + +// HandlerSetup offers to set up the link handler. +type HandlerSetup struct { + Reason string // why links don't open lazysecrets + // Install sets it up, returning what links open in ("kitty"). + Install func() (string, error) +} + +const ( + flagNoHandlerPrompt = "noLinkHandlerPrompt" + setupTitle = "Open links in lazysecrets?" +) + +// deferHandlerSetup puts the question back to show later, if a link +// popup is about to replace it. +func (m *Model) deferHandlerSetup() { + if m.prompt != nil && m.prompt.title == setupTitle { + m.askHandler = true + } +} + +type handlerInstalledMsg struct { + opens string + err error +} + +// askHandlerSetup shows the popup, once no other popup is in the way. +func (m *Model) askHandlerSetup() { + if !m.askHandler || m.prompt != nil || m.search != nil { + return + } + m.askHandler = false + setup := m.handlerSetup + m.prompt = &modal{ + title: setupTitle, + lines: []string{ + setup.Reason + ".", + m.st.Muted.Render("Set up, clicking a lazysecrets:// link (in Slack, a browser…) opens it here."), + }, + options: []option{ + {key: "i", label: "Set it up", run: func(*Model) tea.Cmd { + return func() tea.Msg { + opens, err := setup.Install() + return handlerInstalledMsg{opens, err} + } + }}, + {key: "s", label: "Not now", tip: "Asks again next time lazysecrets starts.", run: func(*Model) tea.Cmd { return nil }}, + {key: "n", label: "Don't ask again", tip: "lazysecrets handler install sets it up later.", run: func(m *Model) tea.Cmd { + if err := m.store.SetFlag(flagNoHandlerPrompt, true); err != nil { + m.flash = flash{text: "saving: " + err.Error(), isErr: true} + } + return nil + }}, + }, + } +} + +func (m *Model) handlerInstalled(msg handlerInstalledMsg) { + switch { + case msg.err != nil: + m.flash = flash{text: "setting up links: " + msg.err.Error(), isErr: true} + case msg.opens != "": + m.flash = done("lazysecrets:// links now open here, or in a new " + msg.opens + " window") + default: + m.flash = done("lazysecrets:// links now open in lazysecrets") + } +} diff --git a/internal/ui/app/handlersetup_test.go b/internal/ui/app/handlersetup_test.go new file mode 100644 index 0000000..38d9ddf --- /dev/null +++ b/internal/ui/app/handlersetup_test.go @@ -0,0 +1,71 @@ +package app + +import ( + "strings" + "testing" + + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +func setupOpts(mem *store.Store, installs *int) Options { + return Options{Store: mem, HandlerSetup: &HandlerSetup{ + Reason: "lazysecrets:// links don't open lazysecrets yet", + Install: func() (string, error) { *installs++; return "kitty", nil }, + }} +} + +func TestHandlerSetupPopup(t *testing.T) { + t.Parallel() + mem, _ := store.Load("") + installs := 0 + d := newDriverOpts(t, setupOpts(mem, &installs)) + for _, want := range []string{"Open links in lazysecrets?", "links don't open lazysecrets yet", "i Set it up", "s Not now", "n Don't ask again"} { + if !strings.Contains(d.screen(), want) { + t.Fatalf("missing %q:\n%s", want, d.screen()) + } + } + d.keys("i") + if installs != 1 || !strings.Contains(d.screen(), "links now open here, or in a new kitty window") { + t.Errorf("set up: %d installs\n%s", installs, d.screen()) + } + + // Not now: asked again next start. + d = newDriverOpts(t, setupOpts(mem, &installs)) + d.keys("s") + if d.m.prompt != nil || mem.Flag(flagNoHandlerPrompt) { + t.Error("not now") + } + d = newDriverOpts(t, setupOpts(mem, &installs)) + if d.m.prompt == nil { + t.Fatal("not asked again after not now") + } + + // Don't ask again: remembered. + d.keys("n") + if !mem.Flag(flagNoHandlerPrompt) { + t.Error("don't ask again not stored") + } + d = newDriverOpts(t, setupOpts(mem, &installs)) + if d.m.prompt != nil { + t.Errorf("asked after don't ask again:\n%s", d.screen()) + } +} + +// A link given at startup is offered first; the setup question waits. +func TestHandlerSetupWaitsForLink(t *testing.T) { + t.Parallel() + mem, _ := store.Load("") + installs := 0 + opts := setupOpts(mem, &installs) + r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key") + opts.Open = &r + d := newDriverOpts(t, opts) + if !strings.Contains(d.screen(), "Open link") { + t.Fatalf("link not first:\n%s", d.screen()) + } + d.keys("x") + if !strings.Contains(d.screen(), "Open links in lazysecrets?") { + t.Errorf("setup question not shown after the link:\n%s", d.screen()) + } +} diff --git a/internal/ui/app/help.go b/internal/ui/app/help.go new file mode 100644 index 0000000..da2f222 --- /dev/null +++ b/internal/ui/app/help.go @@ -0,0 +1,314 @@ +package app + +import ( + "fmt" + "slices" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/ui/keys" + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +// The help bar shows the keys that matter in the focused pane; ? lists all +// of them in a menu (showKeybindings). + +// newHelp styles the help bar to read clearly: keys like labels elsewhere, +// descriptions in the normal text colour, only the separators dimmed. The +// bubbles defaults are grey on grey. +func newHelp(st styles.Styles) help.Model { + h := help.New() + h.Styles.ShortKey = st.Label + h.Styles.ShortDesc = lipgloss.NewStyle() + h.Styles.ShortSeparator = st.Muted + h.Styles.Ellipsis = st.Muted + h.Styles.FullKey, h.Styles.FullDesc, h.Styles.FullSeparator = h.Styles.ShortKey, h.Styles.ShortDesc, h.Styles.ShortSeparator + return h +} + +// contextHelp is the help bar for the focused pane. +type contextHelp struct { + k keys.Map + focus pane + commands bool // custom commands apply here +} + +func (c contextHelp) ShortHelp() []key.Binding { + k := c.k + var b []key.Binding + if k.Login.Enabled() { + b = append(b, k.Login) + } + switch c.focus { + case paneSources: + b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.SourcesView, k.Filter) + case paneContainers: + b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Note, k.Open, k.Filter) + default: + b = append(b, k.Reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Note, k.Filter) + } + if c.commands { + b = append(b, k.Commands) + } + return append(b, k.OpenRef, k.Help, k.Quit) +} + +func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.ShortHelp()} } + +var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail"} + +// showKeybindings opens the ? menu, like lazygit's: every binding with +// what it does, the focused pane's first, filterable by key, description +// and group. Enter runs the selected one. +func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") } + +// showKeybindingsFor opens the ? menu, like lazygit's: every binding with +// what it does, filterable by key, description and group; enter runs the +// selected one. Opened from the panes, it lists the focused pane's keys +// ("") first, then the rest; from a popup (keys.GoTo, keys.Menu), only +// the keys that work there, and it comes back to the popup when closed. +func (m *Model) showKeybindingsFor(ctx string) tea.Cmd { + // Where it was opened from, to come back to. + search, prompt := m.search, m.prompt + m.search = nil + + actions := m.keys.Actions() + id := func(a keys.Action) string { return a.Context + "." + a.Name } + here := map[string]bool{} + var local []keys.Action + switch ctx { + case "": + for _, b := range (contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}).ShortHelp() { + for _, a := range actions { + if a.Context == keys.Lists && !here[id(a)] && slices.Equal(a.Binding.Keys(), b.Keys()) && a.Desc == b.Help().Desc { + here[id(a)] = true + local = append(local, a) + } + } + } + default: + for _, a := range actions { + if a.Context == ctx { + here[id(a)] = true + local = append(local, a) + } + } + } + var rest []keys.Action + for _, a := range actions { + if !here[id(a)] && a.Binding.Enabled() && !(a.Context == keys.Lists && a.Name == "help") { + rest = append(rest, a) + } + } + var opts []option + section := func(title string, list []keys.Action) { + opts = append(opts, option{label: title, header: true}) + for _, a := range list { + h := a.Binding.Help() + if len(a.Binding.Keys()) == 0 { + continue // unbound + } + msg := keyPress(a.Binding.Keys()[0]) + // Go to's typing keys need Go to typing: start it first. + typing := ctx == keys.GoTo && (a.Context == keys.Popup || a.Context == keys.Search) + label := m.st.Label.Render(fmt.Sprintf("%-14s", h.Key)) + fmt.Sprintf("%-26s", h.Desc) + m.st.Muted.Render(a.Group) + opts = append(opts, option{label: label, tip: a.Tip, run: func(m *Model) tea.Cmd { + send := func() tea.Msg { return msg } + if typing && m.search != nil && !m.search.typing { + // Typing starts now; its command only blinks the cursor. + return tea.Batch(m.setTyping(true), send) + } + return send + }}) + } + } + title := map[string]string{"": paneNames[m.focus] + " pane", keys.GoTo: "Go to", keys.Menu: "This menu"}[ctx] + section(title, local) + if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 { + opts = append(opts, option{label: "Custom commands", header: true}) + for _, c := range cmds { + label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom") + opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }}) + } + } + switch ctx { + case "": + section("Global", rest) + case keys.GoTo: + // Only what works here: Go to's keys, browsing and typing. + var typing []keys.Action + for _, a := range actions { + if a.Context == keys.Popup || a.Context == keys.Search { + typing = append(typing, a) + } + } + section("While typing, after "+keys.Name(m.keys.GoFilter), typing) + } + cmd := m.showFilterMenu("Keybindings", opts) + m.prompt.footer = footer(move(m.keys.PopupUp, m.keys.PopupDown), hint(m.keys.PopupSelect, "run"), hint(m.keys.PopupClose, "close")) + if ctx != "" { + m.prompt.onClose = func(m *Model) { m.search, m.prompt = search, prompt } + } + return cmd +} + +// versionLabel is the version as shown: "v0.1.0", or the build for +// development builds ("dev-c1a4f90"). +func (m Model) versionLabel() string { + if m.version == "" || strings.HasPrefix(m.version, "dev") || strings.HasPrefix(m.version, "v") { + return m.version + } + return "v" + m.version +} + +// helpBar draws bindings as the help bar. When they don't fit, entries +// before the last keep (like "? help • q quit") give way, marked "…", +// so those always show. +func (m Model) helpBar(bindings []key.Binding, keep int) string { + var bs []key.Binding + for _, b := range bindings { + if b.Enabled() { + bs = append(bs, b) + } + } + h := m.help + h.SetWidth(0) // measure untruncated + limit := max(0, m.w-1) + if v := m.versionLabel(); v != "" { + limit -= ansi.StringWidth(v) + 2 // a gap, the version, the margin + } + if full := h.ShortHelpView(bs); ansi.StringWidth(full) <= limit { + return full + } + keep = min(keep, len(bs)) + head, tail := bs[:len(bs)-keep], bs[len(bs)-keep:] + sep := h.Styles.ShortSeparator.Render(h.ShortSeparator) + more := h.Styles.Ellipsis.Render("…") + for n := len(head); n >= 0; n-- { + line := more + sep + h.ShortHelpView(tail) + if n > 0 { + line = h.ShortHelpView(head[:n]) + sep + line + } + if ansi.StringWidth(line) <= limit || n == 0 { + return ansi.Truncate(line, limit, "…") + } + } + return "" +} + +// popupKeys are the open popup's keys for the help bar: Go to's for its +// mode and tab, a menu's, or a text field's. +func (m Model) popupKeys() []key.Binding { + km := m.keys + describe := func(b key.Binding, desc string) key.Binding { + b.SetHelp(b.Help().Key, desc) + return b + } + if v := m.search; v != nil { + inSearch := v.tab == tabSearch + if v.typing { + b := []key.Binding{km.PopupDown, km.PopupUp} + if inSearch { + b = append(b, km.SearchSources, km.SearchResults, km.SearchLoad, km.SearchLoadAll) + } + return append(b, km.PopupStar, km.PopupSelect, describe(km.PopupClose, "stop typing")) + } + b := []key.Binding{km.GoDown, km.GoUp, km.GoPrevTab, km.GoNextTab} + if inSearch { + b = append(b, km.GoSources, km.GoResults, km.GoLoad, km.GoLoadAll) + } + return append(b, km.GoFilter, km.GoStar, km.GoSelect, km.GoClose, km.Help) + } + switch p := m.prompt; { + case p.input != nil: + return []key.Binding{describe(km.PopupSelect, "confirm"), describe(km.PopupClose, "cancel")} + case p.filter != nil: + return []key.Binding{km.PopupDown, km.PopupUp, km.PopupSelect, km.PopupClose} + } + return []key.Binding{km.MenuDown, km.MenuUp, km.MenuSelect, km.MenuClose, km.Help} +} + +// A keyHint is a key and what it does, for footers and hints, read from +// the bindings so they show what's bound. +type keyHint struct{ key, desc string } + +func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), desc} } + +// move is "↑/↓ move" for an up and a down binding. +func move(up, down key.Binding) keyHint { + u, d := keys.Name(up), keys.Name(down) + if u == "" || d == "" { + return keyHint{u + d, "move"} + } + return keyHint{u + "/" + d, "move"} +} + +// withKey fills a hint's format with the binding's key ("· %s loads +// everything"), or is empty when the action is unbound. +func withKey(b key.Binding, format string) string { + if k := keys.Name(b); k != "" { + return fmt.Sprintf(format, k) + } + return "" +} + +// joinKeys is "ctrl+h/ctrl+l" for two bindings, or whichever is bound. +func joinKeys(a, b key.Binding) string { + x, y := keys.Name(a), keys.Name(b) + if x != "" && y != "" { + return x + "/" + y + } + return x + y +} + +// footer joins hints as "↑/↓ move · enter go", leaving out unbound keys. +func footer(hints ...keyHint) string { + var parts []string + for _, h := range hints { + if h.key != "" { + parts = append(parts, h.key+" "+h.desc) + } + } + return strings.Join(parts, " · ") +} + +// keyPress builds the key press a binding's key name stands for, so the ? +// menu can run it. +func keyPress(name string) tea.KeyPressMsg { + var mod tea.KeyMod + for { + switch { + case strings.HasPrefix(name, "ctrl+") && len(name) > 5: + mod |= tea.ModCtrl + name = name[5:] + continue + case strings.HasPrefix(name, "shift+") && len(name) > 6: + mod |= tea.ModShift + name = name[6:] + continue + } + break + } + named := map[string]rune{ + "space": tea.KeySpace, "enter": tea.KeyEnter, "tab": tea.KeyTab, "esc": tea.KeyEscape, + "up": tea.KeyUp, "down": tea.KeyDown, "left": tea.KeyLeft, "right": tea.KeyRight, + "home": tea.KeyHome, "end": tea.KeyEnd, "backspace": tea.KeyBackspace, + } + if code, ok := named[name]; ok { + k := tea.KeyPressMsg{Code: code, Mod: mod} + if code == tea.KeySpace && mod == 0 { + k.Text = " " + } + return k + } + r := []rune(name)[0] + if mod != 0 { + return tea.KeyPressMsg{Code: r, Mod: mod} + } + return tea.KeyPressMsg{Code: r, Text: name} +} diff --git a/internal/ui/app/jump.go b/internal/ui/app/jump.go new file mode 100644 index 0000000..97c190f --- /dev/null +++ b/internal/ui/app/jump.go @@ -0,0 +1,198 @@ +package app + +import ( + "cmp" + "maps" + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +// The Favourites and Recent scopes of the Go to popup list what's +// remembered in the store, so they work before anything has loaded. + +// storeEntries lists starred items (sources first, then vaults, then +// secrets) and recently used ones (newest first), as Go to entries. +// Entries read as paths: "󰠅 platform-prod / kv-app-prod / api-key · dev", +// the account last, and only when there are several. +func (m *Model) storeEntries() (favs, recent []option) { + type entry struct { + depth int // 0 source, 1 vault, 2 secret: sources list first + sort string + opt option + } + nodes := m.store.Nodes() + keys := slices.Sorted(maps.Keys(nodes)) + + // entries lists jumps to the item through each of accounts; nil + // means through whichever accounts list a source, else the account + // that last read the item. + entries := func(prefix, key string, n store.Node, accounts []string) []entry { + r, err := ref.Parse(key) + if err != nil { + return nil + } + var out []entry + add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) { + account := "" + if p, ok := m.byID[acct]; ok { + account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()}) + } + sort := strings.ToLower(strings.Join(path, "/")) + " " + account + out = append(out, entry{depth, sort, option{ + label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ", + id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct), + run: func(m *Model) tea.Cmd { + m.used(key) + return run(m) + }}}) + } + if r.IsSource() { + for _, p := range m.providers { + if p.Kind() != r.Kind || (accounts != nil && !slices.Contains(accounts, p.ID())) { + continue + } + for _, src := range m.sourceResults[p.ID()].sources { + if !sourceMatches(r, src) { + continue + } + row := sourceRowKey(p.ID(), src.ID) + add([]string{src.Name}, p.ID(), 0, func(m *Model) tea.Cmd { + m.nav = &nav{sourceRow: row, sourceOnly: true} + return nil + }) + } + } + return out + } + if accounts == nil { + accounts = []string{n.Account} + } + depth := 1 + if !r.IsContainer() { + depth = 2 + } + path := slices.Clone(n.Path) + if len(path) == 0 && !r.IsContainer() { + path = []string{r.ContainerName()} // no context recorded + } + for _, acct := range accounts { + via := n + via.Account = acct + add(append(slices.Clone(path), cmp.Or(n.Name, r.Name, r.ContainerName())), acct, depth, + func(m *Model) tea.Cmd { return m.jumpTo(r, via) }) + } + return out + } + + var starred []entry + for _, k := range keys { + if n := nodes[k]; len(n.StarredBy) > 0 { + starred = append(starred, entries("fav|", k, n, n.StarredBy)...) + } + } + slices.SortFunc(starred, func(a, b entry) int { + return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort)) + }) + for _, e := range starred { + favs = append(favs, e.opt) + } + + if m.history { + var used []string + for _, k := range keys { + if !nodes[k].LastUsed.IsZero() { + used = append(used, k) + } + } + slices.SortStableFunc(used, func(a, b string) int { return nodes[b].LastUsed.Compare(nodes[a].LastUsed) }) + for _, k := range used[:min(len(used), recentMax)] { + for _, e := range entries("recent|", k, nodes[k], nil) { + recent = append(recent, e.opt) + } + } + } + return favs, recent +} + +// recentMax is how many recently used items Recent lists. +const recentMax = 20 + +// pathLabel draws a jump list entry: the provider's icon and the path in +// its colour, then the account. +func (m Model) pathLabel(kind provider.Kind, path []string, account string) string { + style := m.st.Kind(kind) + parts := make([]string, len(path)) + for i, p := range path { + parts[i] = style.Render(p) + } + label := style.Render(m.icons.Prefix(kind)) + strings.Join(parts, m.st.Muted.Render(" / ")) + if account != "" { + label += m.st.Muted.Render(" · " + account) + } + return label +} + +// sourceMatches reports whether src is the subscription or workspace r +// links to. +func sourceMatches(r ref.Ref, src provider.Source) bool { + if r.Kind != provider.KindAzure { // by host: workspaces, repos + return strings.EqualFold(strings.TrimSuffix(src.Detail, "/"), r.Host) + } + return src.ID == r.Subscription +} + +// accountOf is the account part of a source's provider label ("dev" in +// "Azure · dev"), or "" when there's a single account. +func accountOf(src provider.Source) string { + account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ") + if !ok { + return "" + } + return account +} + +// jumpTo goes to a remembered vault or secret. With the account that last +// read it signed in, it goes straight there; otherwise it uses the +// accounts that list its subscription, asking if there are several, or +// finds it like a link. Reaching it updates the remembered account. +func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd { + if n.Source == "" && !r.IsContainer() { + // A secret without context: its vault's record may have it. + parent := r + parent.Type, parent.Name, parent.Version = "", "", "" + if p := m.store.Node(parent.String()); p.Source != "" { + n.Source, n.Account = p.Source, cmp.Or(n.Account, p.Account) + } + } + if _, ok := m.byID[n.Account]; ok && n.Source != "" { + m.startNav(r, actGo, sourceRowKey(n.Account, n.Source)) + return nil + } + var choices []choice + for _, p := range m.providers { + if p.Kind() != r.Kind { + continue + } + for _, src := range m.sourceResults[p.ID()].sources { + if src.ID == n.Source && n.Source != "" { + row := sourceRowKey(p.ID(), src.ID) + choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row}) + } + } + } + switch len(choices) { + case 0: + return m.openRef(r, actGo) + case 1: + m.startNav(r, actGo, choices[0].sourceRow) + default: + m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices) + } + return nil +} diff --git a/internal/ui/app/layout.go b/internal/ui/app/layout.go new file mode 100644 index 0000000..94e628a --- /dev/null +++ b/internal/ui/app/layout.go @@ -0,0 +1,189 @@ +package app + +import ( + "charm.land/lipgloss/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +// Layout: the left column stacks Sources, Vaults and Secrets; the detail +// pane fills the rest. The user can move the divider between them, change +// the left panes' heights, and zoom the focused pane. + +type zoom int + +const ( + zoomNormal zoom = iota + zoomHalf // the focused pane gets most of the screen + zoomFull // only the focused pane + zoomCount +) + +const ( + // Divider between the left column and the detail pane. + splitStep = 4 + minLeftW = 20 + minDetailW = 30 + defaultMinW = 28 + defaultMaxW = 48 + + // Heights of the left panes. + heightStep = 2 + minPaneH = 4 // border, title and two rows + tinyPaneH = 3 // a squeezed pane in half zoom: border and one row +) + +func (m Model) defaultLeftWidth() int { return max(defaultMinW, min(defaultMaxW, m.w/3)) } + +// leftWidth is the default width moved by the user's divider offset, +// kept within bounds that leave both sides usable. +func (m Model) leftWidth() int { + return max(minLeftW, min(m.w-minDetailW, m.defaultLeftWidth()+m.split)) +} + +// moveSplit moves the divider by delta columns. The offset is stored as +// what actually applied, so after hitting a limit the other direction +// responds immediately. +func (m *Model) moveSplit(delta int) { + m.split += delta + m.split = m.leftWidth() - m.defaultLeftWidth() +} + +func defaultHeights(avail int) [3]int { + src, cont := max(minPaneH, avail/4), max(minPaneH, avail/3) + return [3]int{src, cont, avail - src - cont} +} + +// leftHeights applies the user's height changes to the default split. +func (m Model) leftHeights(avail int) [3]int { + h := defaultHeights(avail) + for i := range h { + h[i] += m.heights[i] + } + return fitHeights(h, avail, minPaneH) +} + +// fitHeights makes the heights sum to avail with none below lo, taking +// from or giving to the tallest panes. +func fitHeights(h [3]int, avail, lo int) [3]int { + for i := range h { + h[i] = max(lo, h[i]) + } + for sum(h) > avail { + i := tallest(h) + if h[i] <= lo { + break // the terminal is too short; the layout check catches it + } + h[i]-- + } + for sum(h) < avail { + h[tallest(h)]++ + } + return h +} + +func sum(h [3]int) int { return h[0] + h[1] + h[2] } + +func tallest(h [3]int) int { + i := 0 + for j := range h { + if h[j] > h[i] { + i = j + } + } + return i +} + +// resizeFocused makes the focused left pane taller (delta > 0) or shorter, +// trading rows with the pane below it, or above it for the bottom pane. +// It reports false if the detail pane is focused. +func (m *Model) resizeFocused(delta int) bool { + if m.focus > paneSecrets { + return false + } + avail := m.leftAvail() + h := m.leftHeights(avail) + i := int(m.focus) + j := i + 1 + if i == int(paneSecrets) { + j = i - 1 + } + delta = max(delta, minPaneH-h[i]) // don't shrink below the minimum + delta = min(delta, h[j]-minPaneH) // or squeeze the neighbour below it + h[i] += delta + h[j] -= delta + base := defaultHeights(avail) + for k := range h { + m.heights[k] = h[k] - base[k] + } + return true +} + +// saveLayout remembers the divider and heights for the next run. +func (m *Model) saveLayout() { + if err := m.store.SetLayout(store.Layout{Split: m.split, Heights: m.heights}); err != nil { + m.flash = flash{text: "saving layout: " + err.Error(), isErr: true} + } +} + +func (m Model) leftAvail() int { return m.h - lipgloss.Height(m.footerView()) } + +func (m *Model) cycleZoom(delta int) { + m.zoom = (m.zoom + zoom(delta) + zoomCount) % zoomCount +} + +func (m *Model) layout() { + if m.w < minWidth || m.h < minHeight { + return + } + m.help.SetWidth(max(0, m.w-1)) // one column in, like messages + avail := m.leftAvail() + leftW, h := m.leftWidth(), m.leftHeights(avail) + + switch m.zoom { + case zoomHalf: + if m.focus == paneDetail { + leftW = minLeftW + } else { + leftW = max(m.leftWidth(), m.w/2) + h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH} + h[m.focus] = avail + h = fitHeights(h, avail, tinyPaneH) + } + case zoomFull: + // Only the focused pane is drawn; it gets the whole screen. + switch m.focus { + case paneDetail: + leftW = 0 + default: + leftW = m.w + h = [3]int{} + h[m.focus] = avail + } + } + + m.sources.SetSize(leftW, h[0]) + m.containers.SetSize(leftW, h[1]) + m.secrets.SetSize(leftW, h[2]) + m.detail.SetSize(m.w-leftW, avail) +} + +// active reports whether p is drawn focused: it has focus and the terminal +// does too. Like lazygit, nothing is highlighted while you're in another +// window, so the focus is easy to spot when you come back. +func (m Model) active(p pane) bool { return m.focus == p && !m.termBlurred } + +func (m Model) panesView() string { + spin := m.spin.View() + views := [paneCount]func() string{ + func() string { return m.sources.View(m.st, m.active(paneSources), spin) }, + func() string { return m.containers.View(m.st, m.active(paneContainers), spin) }, + func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) }, + func() string { return m.detail.View(m.st, m.active(paneDetail), spin) }, + } + if m.zoom == zoomFull { + return views[m.focus]() + } + left := lipgloss.JoinVertical(lipgloss.Left, views[0](), views[1](), views[2]()) + return lipgloss.JoinHorizontal(lipgloss.Top, left, views[3]()) +} diff --git a/internal/ui/app/layout_test.go b/internal/ui/app/layout_test.go new file mode 100644 index 0000000..c5a67de --- /dev/null +++ b/internal/ui/app/layout_test.go @@ -0,0 +1,271 @@ +package app + +import ( + "path/filepath" + "slices" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +// paneHeights reads the left panes' heights back from the screen: each +// starts with a "╭─ [n]" line. +func (d *driver) paneHeights() [3]int { + lines := strings.Split(d.screen(), "\n") + var starts []int + for i, l := range lines { + for n := 1; n <= 3; n++ { + if strings.HasPrefix(l, "╭─ ["+string(rune('0'+n))+"]") { + starts = append(starts, i) + } + } + } + if len(starts) != 3 { + d.t.Fatalf("found %d left panes:\n%s", len(starts), d.screen()) + } + bottom := len(lines) - 1 - strings.Count(d.m.footerView(), "\n") // first footer line + return [3]int{starts[1] - starts[0], starts[2] - starts[1], bottom - starts[2]} +} + +func TestPaneHeights(t *testing.T) { + t.Parallel() + d := newDriver(t) // 30 rows, one footer line: 29 for panes + start := d.paneHeights() + if sum(start) != 29 { + t.Fatalf("heights %v don't fill the column", start) + } + + d.send(tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModCtrl}) // Sources taller, from Vaults + got := d.paneHeights() + if got[0] != start[0]+heightStep || got[1] != start[1]-heightStep || got[2] != start[2] { + t.Errorf("ctrl+↓ on Sources: %v → %v", start, got) + } + + d.keys("3", "}") // Secrets is the bottom pane: it takes from Vaults above + got2 := d.paneHeights() + if got2[2] != got[2]+heightStep || got2[1] != got[1]-heightStep { + t.Errorf("} on Secrets: %v → %v", got, got2) + } + + for range 20 { + d.keys("{") // shrink Secrets as far as it goes + } + if h := d.paneHeights(); h[2] != minPaneH || sum(h) != 29 { + t.Errorf("minimum height: %v", h) + } + + d.keys("4", "}") + if !strings.Contains(d.screen(), "focus a left pane") { + t.Error("no hint when resizing from the detail pane") + } + + d.keys("=") + if h := d.paneHeights(); h != start { + t.Errorf("= didn't reset heights: %v", h) + } +} + +func TestZoom(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "+") // half: Secrets gets most of the column and half the width + h := d.paneHeights() + if h[0] != tinyPaneH || h[1] != tinyPaneH || h[2] != 29-2*tinyPaneH { + t.Errorf("half zoom heights = %v", h) + } + if got := d.leftEdge(); got != 50 { + t.Errorf("half zoom width = %d, want half of 100", got) + } + + d.keys("+") // full: only Secrets + screen := d.screen() + if !strings.Contains(screen, "[3] Secrets") || strings.Contains(screen, "[1] Sources") || strings.Contains(screen, "Secret ─") { + t.Errorf("full zoom should show only the Secrets pane:\n%s", screen) + } + if w := len([]rune(strings.Split(screen, "\n")[0])); w != 100 { + t.Errorf("full zoom width = %d", w) + } + + d.keys("4") // the zoom follows focus + if !strings.Contains(d.screen(), "╭─ Secret ") || strings.Contains(d.screen(), "[3] Secrets") { + t.Errorf("full zoom on the detail pane:\n%s", d.screen()) + } + + d.keys("+") // wraps to normal + if d.m.zoom != zoomNormal { + t.Errorf("zoom = %d after three +", d.m.zoom) + } + d.keys("_") + if d.m.zoom != zoomFull { + t.Errorf("_ from normal should go to full, got %d", d.m.zoom) + } + d.keys("=") + if d.m.zoom != zoomNormal { + t.Error("= didn't reset zoom") + } +} + +func TestLayoutIsSaved(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "state.json") + load := func() *store.Store { + s, err := store.Load(path) + if err != nil { + t.Fatal(err) + } + return s + } + d := newDriverOpts(t, Options{Store: load()}) + d.keys(">", ">", "}") + edge, heights := d.leftEdge(), d.paneHeights() + + d2 := newDriverOpts(t, Options{Store: load()}) + if d2.leftEdge() != edge || d2.paneHeights() != heights { + t.Errorf("restored %d %v, want %d %v", d2.leftEdge(), d2.paneHeights(), edge, heights) + } + d2.keys("=") + if l := load().Layout(); l.Split != 0 || l.Heights != [3]int{} { + t.Errorf("reset not saved: %+v", l) + } +} + +func TestContextHelpBar(t *testing.T) { + t.Parallel() + d := newDriver(t) + if bar := lastLine(d.screen()); !strings.Contains(bar, "S share…") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space reveal") { + t.Errorf("Sources help bar: %q", bar) + } + d.keys("3") + if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share…") { + t.Errorf("Secrets help bar: %q", bar) + } +} + +func TestKeybindingsMenu(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "?") + if d.m.prompt == nil || d.m.prompt.title != "Keybindings" { + t.Fatalf("? didn't open the menu:\n%s", d.screen()) + } + // Sections: the focused pane's keys, then global ones; the cursor + // starts on the first key, and a counter shows where it is. + p := d.m.prompt + if !p.options[0].header || p.options[0].label != "Secrets pane" { + t.Errorf("first section: %+v", p.options[0]) + } + if sel := ansi.Strip(p.options[p.cursor].label); !strings.HasPrefix(sel, "space") { + t.Errorf("cursor starts on %q", sel) + } + screen := d.screen() + if !strings.Contains(screen, "Global") || !strings.Contains(screen, "1 of ") { + t.Errorf("no Global section or counter:\n%s", screen) + } + d.send(tea.KeyPressMsg{Code: tea.KeyUp}) // can't go up onto the header + if p.options[p.cursor].header { + t.Error("cursor landed on a header") + } + if !strings.Contains(d.screen(), "Show or hide the selected value") { + t.Errorf("no tip for the selected row:\n%s", d.screen()) + } + + // The filter matches descriptions and groups too; matches stay under + // their section headers, and empty sections drop out. + for _, r := range "copy" { + d.send(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + var headers []string + for _, o := range d.m.prompt.options { + if o.header { + headers = append(headers, o.label) + } + } + if strings.Join(headers, ",") != "Secrets pane,Global" { + t.Errorf("sections while filtering: %v", headers) + } + if d.m.prompt.options[d.m.prompt.cursor].header { + t.Error("cursor on a header after filtering") + } + var keys []string + for _, o := range d.m.prompt.options { + keys = append(keys, strings.Fields(ansi.Strip(o.label))[0]) + } + for _, want := range []string{"y", "Y", "s", "ctrl+y"} { + if !slices.Contains(keys, want) { + t.Errorf("filter copy: %v lacks %s", keys, want) + } + } + + // Enter runs the selected binding. + d.keys("esc", "?") + for _, r := range "space" { + d.send(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + if sel := ansi.Strip(d.m.prompt.options[d.m.prompt.cursor].label); !strings.HasPrefix(sel, "space") { + t.Fatalf("filter space: selected %q", sel) + } + d.keys("enter") + if !strings.Contains(d.screen(), "correct-horse-battery-staple") { + t.Errorf("running reveal from the menu didn't reveal:\n%s", d.screen()) + } +} + +func TestKeyPress(t *testing.T) { + t.Parallel() + for name, want := range map[string]string{ + "space": "space", "ctrl+y": "ctrl+y", "shift+tab": "shift+tab", "S": "S", "`": "`", "ctrl+left": "ctrl+left", + } { + if got := keyPress(name).String(); got != want { + t.Errorf("keyPress(%q).String() = %q", name, got) + } + } +} + +func lastLine(s string) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + return lines[len(lines)-1] +} + +func TestNoHighlightWhileTerminalBlurred(t *testing.T) { + t.Parallel() + d := newDriver(t) + green := "\x1b[32m╭" // a focused border; "current" versions are green too + if !strings.Contains(d.m.View().Content, green) { + t.Fatal("focused pane not highlighted") + } + d.send(tea.BlurMsg{}) + if strings.Contains(d.m.View().Content, green) { + t.Error("still highlighted while the terminal is blurred") + } + d.send(tea.FocusMsg{}) + if !strings.Contains(d.m.View().Content, green) { + t.Error("highlight not back after focus") + } + + // Popups too. + d.keys("?") + d.send(tea.BlurMsg{}) + if strings.Contains(d.m.modalView(), "\x1b[32m") || strings.Contains(d.m.modalView(), "\x1b[7m") { + t.Error("popup still highlighted while the terminal is blurred") + } + d.send(tea.FocusMsg{}) + if !strings.Contains(d.m.modalView(), "\x1b[32m╭") { + t.Error("popup highlight not back after focus") + } +} + +func TestVersionBottomRight(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{Version: "0.1.0"}) + bar := lastLine(d.screen()) + if !strings.HasSuffix(bar, "v0.1.0 ") || ansi.StringWidth(bar) != 100 { + t.Errorf("help line %q (%d wide)", bar, ansi.StringWidth(bar)) + } + if !strings.Contains(bar, "? help • q quit") { + t.Errorf("help squeezed out by the version: %q", bar) + } +} diff --git a/internal/ui/app/links.go b/internal/ui/app/links.go new file mode 100644 index 0000000..e7ed9df --- /dev/null +++ b/internal/ui/app/links.go @@ -0,0 +1,887 @@ +package app + +import ( + "cmp" + "context" + "errors" + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +// Opening references: a reference (lazysecrets:// link, Key Vault URL, …) +// is first resolved to a source, then the panes are stepped through to it +// as each list loads. Copy and reveal actions ask before reading the value. + +// linksRowID is the Sources row listing vaults opened from links that no +// account could locate (e.g. no read access to the vault resource). +const linksRowID = "links" + +type navStage int + +const ( + navSource navStage = iota + navContainer + navObject + navVersion + navAction +) + +// nav is an open in progress. +type nav struct { + ref ref.Ref + act linkAction + // sourceOnly stops at the source (jumping to a starred subscription). + sourceOnly bool + + sourceRow string // Sources row to select + stage navStage +} + +type resolvedMsg struct { + ref ref.Ref + act linkAction + sourceRow string + others []string // other accounts' rows that also found the vault + choices []choice // several candidates: ask + linked *provider.Container + err error +} + +type choice struct { + label string + sourceRow string +} + +// linkAction is what to do with an object once it's opened. Links don't +// carry it: whoever opens one chooses. +type linkAction int + +const ( + actGo linkAction = iota // just go to it + actCopy // and copy its value + actShow // and show its value +) + +// offerRef asks what to do with a reference: for an object, go to it, or +// go to it and copy or show its value; for a subscription, vault or scope, +// just open it. +// With ask false, vault and scope links open straight away. +func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd { + if r.IsContainer() || r.IsSource() { + if !ask { + return m.openRef(r, actGo) + } + m.showMenu(title, nil, []option{ + {key: "o", label: "Open it", run: func(m *Model) tea.Cmd { return m.openRef(r, actGo) }}, + }) + m.prompt.about = &r + return nil + } + do := func(a linkAction) func(*Model) tea.Cmd { + return func(m *Model) tea.Cmd { return m.openRef(r, a) } + } + opts := []option{ + {key: "o", label: "Go to it", run: do(actGo)}, + {key: "c", label: "Go to it and copy its value", run: do(actCopy)}, + {key: "r", label: "Go to it and show its value", run: do(actShow)}, + } + if r.Kind == provider.KindAzure || r.Host != "" { // {{secrets/s/k}} names no workspace + opts = append(opts, option{key: "y", label: "Copy its value, staying here", run: func(m *Model) tea.Cmd { return m.copyFromRef(r) }}) + } + m.showMenu(title, nil, opts) + m.prompt.about = &r + return nil +} + +// copyFromRef copies the value a link points at without going there. The +// link says enough to read it: the vault and name (Azure, tried with each +// account, the one that last read it first) or the workspace, scope and +// key (Databricks). +func (m *Model) copyFromRef(r ref.Ref) tea.Cmd { + type try struct { + p provider.Provider + o provider.Object + } + var tries []try + key := r + key.Version = "" + node := m.store.Node(key.String()) + for _, p := range m.providers { + if p.Kind() != r.Kind { + continue + } + o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version} + src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: node.Source} + if len(node.Path) > 0 { + src.Name = node.Path[0] + } + if r.Kind != provider.KindAzure { // by host: Databricks workspaces, repos + o.Type = provider.TypeSecret + src.ID = "" + for _, s := range m.sourceResults[p.ID()].sources { + if sourceMatches(r, s) { + src = s + } + } + if src.ID == "" { + continue + } + o.Container = provider.Container{Source: src, Name: r.Scope} + } else { + o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()} + } + t := try{p, o} + if p.ID() == node.Account { + tries = append([]try{t}, tries...) + } else { + tries = append(tries, t) + } + } + if len(tries) == 0 { + m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true} + return nil + } + ctx, req := m.begin(slotCopy) + m.flash = flash{text: "copying " + r.Name + "…"} + return func() tea.Msg { + var errs []error + for _, t := range tries { + v, err := t.p.Value(ctx, t.o) + if err == nil { + msg := copyMsg{req: req, name: r.Name, value: v} + if t.o.Container.Source.ID != "" { + msg.obj = t.o // where it is is known: count the use + } + return msg + } + if ctx.Err() != nil { + return copyMsg{req: req, name: r.Name, err: err} + } + errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err)) + } + if len(errs) == 1 { + return copyMsg{req: req, name: r.Name, err: errors.Unwrap(errs[0])} // one account: no need to name it + } + return copyMsg{req: req, name: r.Name, err: errors.Join(errs...)} + } +} + +// openRef starts opening a reference, doing act once there. +func (m *Model) openRef(r ref.Ref, act linkAction) tea.Cmd { + m.nav = nil + if r.IsSource() { + m.openSource(r) + return nil + } + m.flash = flash{text: "opening " + describeRef(r) + "…"} + cands := m.providers + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), defaultResolveTimeout) + defer cancel() + msg := resolve(ctx, cands, r) + msg.act = act + return msg + } +} + +const defaultResolveTimeout = 30 * time.Second + +// openSource goes to a subscription or workspace, asking which account +// when several list it. Sources are loaded already, so it needs no lookup. +func (m *Model) openSource(r ref.Ref) { + var choices []choice + for _, p := range m.providers { + if p.Kind() != r.Kind { + continue + } + for _, src := range m.sourceResults[p.ID()].sources { + if sourceMatches(r, src) { + row := sourceRowKey(p.ID(), src.ID) + choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row}) + } + } + } + switch len(choices) { + case 0: + m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true} + case 1: + m.nav = &nav{ref: r, sourceRow: choices[0].sourceRow, sourceOnly: true} + default: + var opts []option + for i, c := range choices { + row := c.sourceRow + opts = append(opts, option{key: fmt.Sprint(i + 1), label: c.label, run: func(m *Model) tea.Cmd { + m.nav = &nav{ref: r, sourceRow: row, sourceOnly: true} + return nil + }}) + } + m.showMenu("Several accounts can see "+describeRef(r), nil, opts) + } +} + +// refLabel draws what a link points at like the favourites list: the +// provider's icon and the path in its colour, "󰠅 platform-prod / +// kv-app-prod / api-key", with the type and version after it. The +// subscription or workspace isn't in the link: it comes from what's +// remembered about the item, or from lists already loaded. +func (m Model) refLabel(r ref.Ref) string { + var path []string + if src := m.refSource(r); src != "" { + path = append(path, src) + } + switch { + case r.IsSource(): + if len(path) == 0 { + path = append(path, cmp.Or(r.Subscription, r.Host)) + } + case r.IsContainer(): + path = append(path, r.ContainerName()) + default: + path = append(path, r.ContainerName(), r.Name) + } + label := m.pathLabel(r.Kind, path, "") + var extra []string + if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret { + if r.IsContainer() { + extra = append(extra, t+" tab") + } else { + extra = append(extra, strings.TrimSuffix(t, "s")) // "key", "certificate" + } + } + if r.Version != "" { + extra = append(extra, "version "+r.Version) + } + if len(extra) > 0 { + label += m.st.Muted.Render(" · " + strings.Join(extra, " · ")) + } + return label +} + +// refSource names the subscription or workspace r is in, if known. +func (m Model) refSource(r ref.Ref) string { + key := r + key.Version = "" + if n := m.store.Node(key.String()); len(n.Path) > 0 { + return n.Path[0] + } + for _, res := range m.sourceResults { + for _, src := range res.sources { + if (r.Kind != provider.KindAzure || r.IsSource()) && src.Kind == r.Kind && sourceMatches(r, src) { + return src.Name + } + } + } + if r.Kind == provider.KindAzure && !r.IsSource() { + for _, e := range m.cache.containers { + for _, c := range e.value { + if matchesContainer(c, r) { + return c.Source.Name + } + } + } + } + return "" +} + +func describeRef(r ref.Ref) string { + if r.IsSource() { + switch r.Kind { + case provider.KindDatabricks: + return "workspace " + r.Host + case provider.KindRepo: + return "repo " + r.Host + } + return "subscription " + r.Subscription + } + if r.IsContainer() { + return r.ContainerName() + } + return r.Name + " in " + r.ContainerName() +} + +// resolve finds the source holding the reference's container. +func resolve(ctx context.Context, providers []provider.Provider, r ref.Ref) resolvedMsg { + msg := resolvedMsg{ref: r} + var errs []error + switch { + case r.Kind == provider.KindDatabricks && r.Host == "": + // {{secrets/scope/key}}: search every workspace for the scope. + for _, p := range providers { + if p.Kind() != provider.KindDatabricks { + continue + } + srcs, err := p.Sources(ctx) + if err != nil { + errs = append(errs, err) + continue + } + for _, src := range srcs { + cs, err := p.Containers(ctx, src) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", src.Name, err)) + continue + } + for _, c := range cs { + if c.Name == r.Scope { + msg.choices = append(msg.choices, choice{label: src.Name, sourceRow: sourceRowKey(p.ID(), src.ID)}) + } + } + } + } + switch len(msg.choices) { + case 0: + msg.err = errors.Join(append([]error{fmt.Errorf("scope %q isn't in any workspace", r.Scope)}, errs...)...) + case 1: + msg.sourceRow, msg.choices = msg.choices[0].sourceRow, nil + } + return msg + } + + for _, p := range providers { + loc, ok := p.(provider.Locator) + if !ok || p.Kind() != r.Kind { + continue + } + id, found, err := loc.Locate(ctx, r.Host) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", p.Label(), err)) + continue + } + if found { + // Several accounts may see it, with different access: the + // user picks. + row := sourceRowKey(p.ID(), id) + if msg.sourceRow == "" { + msg.sourceRow = row + } else { + msg.others = append(msg.others, row) + } + } + } + if msg.sourceRow != "" { + return msg + } + if r.Kind == provider.KindAzure { + // No account can find the vault resource, but one may still read + // it: open it under "Opened links", where each account is tried. + msg.linked = &provider.Container{ + Source: provider.Source{Kind: provider.KindAzure, Name: r.Host}, + ID: r.VaultURL(), Name: r.ContainerName(), Detail: r.VaultURL(), + Types: []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}, + } + return msg + } + missing := fmt.Errorf("no Databricks profile for workspace %s", r.Host) + if r.Kind == provider.KindRepo { + missing = fmt.Errorf("repo %s isn't here: start lazysecrets in it, or list it under providers.repos.paths", r.Host) + } + msg.err = errors.Join(append([]error{missing}, errs...)...) + return msg +} + +func (m *Model) handleResolved(msg resolvedMsg) { + switch { + case msg.err != nil: + m.flash = flash{text: "open: " + msg.err.Error(), isErr: true} + case len(msg.choices) > 1: + for i := range msg.choices { + msg.choices[i].label = "Open in " + msg.choices[i].label + } + m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices) + case len(msg.others) > 0: + var choices []choice + for _, row := range append([]string{msg.sourceRow}, msg.others...) { + choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row}) + } + m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices) + case msg.linked != nil: + m.addLinked(*msg.linked) + m.startNav(msg.ref, msg.act, linksRowID) + default: + m.startNav(msg.ref, msg.act, msg.sourceRow) + } +} + +// chooseSource asks where to open r. +func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice) { + var opts []option + for i, c := range choices { + key := "" + if i < 9 { + key = fmt.Sprint(i + 1) + } + row := c.sourceRow + opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd { + m.startNav(r, act, row) + return nil + }}) + } + m.showMenu(title, nil, opts) +} + +// sourceLabel names a Sources row as "Azure · admin / app-dev". +func (m Model) sourceLabel(row string) string { + providerID, sourceID := splitSourceRow(row) + label := providerID + if p, ok := m.byID[providerID]; ok { + label = p.Label() + } + for _, s := range m.sourceResults[providerID].sources { + if s.ID == sourceID { + return label + " / " + s.Name + } + } + return label +} + +func (m *Model) startNav(r ref.Ref, act linkAction, sourceRow string) { + m.nav = &nav{ref: r, act: act, sourceRow: sourceRow} + m.versionsOf = nil + m.flash = flash{} +} + +// addLinked lists a vault under "Opened links" for this session. +func (m *Model) addLinked(c provider.Container) { + for _, l := range m.linked { + if l.ID == c.ID { + return + } + } + m.linked = append(m.linked, c) + m.rebuildSources() +} + +func (m *Model) navFail(format string, a ...any) { + m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true} + m.nav = nil +} + +// advanceNav moves an open along as far as the loaded lists allow. It +// reports whether it got further, so the caller can sync and try again. +func (m *Model) advanceNav() bool { + n := m.nav + if n == nil { + return false + } + r := n.ref + switch n.stage { + case navSource: + if !m.sources.Select(n.sourceRow) { + group, _ := splitSourceRow(n.sourceRow) + if m.collapsed[group] { + m.collapsed[group] = false + m.rebuildSources() + return true + } + if m.sourcesPending > 0 { + return false // still loading + } + m.navFail("%s isn't in the Sources list (hidden in the config?)", describeRef(r)) + return false + } + m.sourcesTouched = true + m.focus = paneContainers + if n.sourceOnly { + m.nav = nil + return true + } + n.stage = navContainer + return true + + case navContainer: + if m.selSource != n.sourceRow || m.containers.Loading { + return false + } + if !m.containers.SelectWhere(func(c provider.Container) bool { return matchesContainer(c, r) }) { + m.navFail("%s not found", r.ContainerName()) + return false + } + if r.Type != "" { + m.wantType = r.Type + } + if !r.IsContainer() { + m.focus = paneSecrets + } + n.stage = navObject + return true + + case navObject: + c, _ := m.containers.Selected() + if (r.Type != "" && m.objType != r.Type) || m.secrets.Loading || + !strings.HasPrefix(m.selContainer, containerKey(c)+"|") { + return false + } + if m.secrets.Err != nil { + m.nav = nil // the pane shows the error + return false + } + // Reached it, and its account can read it: remember that. + m.refreshContext(storeKey(c), c.Name, []string{c.Source.Name}, c.Source) + if r.IsContainer() { + m.nav = nil + return true + } + if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Name == r.Name }) { + m.navFail("%s not found in %s", r.Name, r.ContainerName()) + return false + } + if o, ok := m.secrets.Selected(); ok { + m.refreshContext(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source) + } + if r.Version != "" { + o, _ := m.secrets.Selected() + m.versionsOf = &o + n.stage = navVersion + return true + } + n.stage = navAction + return true + + case navVersion: + if m.versionsOf == nil || m.secrets.Loading || m.secrets.Err != nil { + if m.secrets.Err != nil { + m.nav = nil + } + return false + } + if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Version == r.Version }) { + m.flash = flash{text: "version " + r.Version + " not found; showing all versions", isErr: true} + } + n.stage = navAction + return true + + case navAction: + // The user chose this when opening the link; no need to ask again. + m.nav = nil + switch n.act { + case actCopy: + m.pendingCmd = m.copyValue() + case actShow: + if m.detail.State() != panes.ValueShown { + m.pendingCmd = m.toggleReveal() + } + } + return true + } + return false +} + +// matchesContainer compares by vault URL (Azure), or scope or file name. +func matchesContainer(c provider.Container, r ref.Ref) bool { + if r.Kind != provider.KindAzure { + return c.Name == r.Scope + } + return strings.EqualFold(strings.TrimSuffix(c.Detail, "/")+"/", r.VaultURL()) +} + +// share copies a link to the focused item. With menu, it offers the +// other things worth sharing about it. +func (m *Model) share(menu bool) tea.Cmd { + switch m.focus { + case paneSources: + s, ok := m.selectedSource() + if !ok { + m.flash = warning("select a subscription, workspace, vault or secret to share") + return nil + } + if !menu { + return m.shareSource(s, "o") + } + share := func(k string) func(*Model) tea.Cmd { + return func(m *Model) tea.Cmd { return m.shareSource(s, k) } + } + opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}} + if s.URL != "" { + opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")}) + } + m.showMenu("Share "+s.Name, nil, opts) + case paneContainers: + c, ok := m.containers.Selected() + if !ok { + return nil + } + if !menu { + return m.shareContainer(c, "o") + } + share := func(k string) func(*Model) tea.Cmd { + return func(m *Model) tea.Cmd { return m.shareContainer(c, k) } + } + opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}} + if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret { + opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")}) + } + if c.Source.Kind == provider.KindAzure { + opts = append(opts, option{key: "u", label: "Vault URL", run: share("u")}) + } + if c.URL != "" { + opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")}) + } + m.showMenu("Share "+c.Name, nil, opts) + default: + o, ok := m.detail.Secret() + if !ok { + return nil + } + if !menu { + return m.shareObject(o, "o") + } + share := func(k string) func(*Model) tea.Cmd { + return func(m *Model) tea.Cmd { return m.shareObject(o, k) } + } + opts := []option{ + option{key: "o", label: "Lazysecrets link", run: share("o")}, + } + if o.Container.Source.Kind == provider.KindAzure { + opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")}) + } + if o.Reference != "" { + opts = append(opts, option{key: "y", label: "Config reference", run: share("y")}) + } + if o.URL != "" { + opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")}) + } + m.showMenu("Share "+o.Name, nil, opts) + } + return nil +} + +// webLabel names the web page o (open in browser) opens. +func webLabel(kind provider.Kind) string { + switch kind { + case provider.KindDatabricks: + return "Web page (Databricks workspace)" + case provider.KindRepo: + return "Web page (repository)" + } + return "Web page (Azure portal)" +} + +// shareSource copies what the share menu's key k stands for, for a +// subscription or workspace. +func (m *Model) shareSource(s provider.Source, k string) tea.Cmd { + m.usedSource(s) + if k == "b" { + if s.URL == "" { + return nil + } + return m.copyText(s.Name+" web page", s.URL, false) + } + r, err := ref.ForSource(s) + if err != nil { + m.flash = flash{text: "share: " + err.Error(), isErr: true} + return nil + } + return m.copyText("link to "+s.Name, r.String(), false) +} + +// shareContainer copies what the share menu's key k stands for, for a +// vault or scope. +func (m *Model) shareContainer(c provider.Container, k string) tea.Cmd { + m.usedContainer(c) + tab := provider.TypeSecret + switch k { + case "b": + if c.URL == "" { + return nil + } + return m.copyText(c.Name+" web page", c.URL, false) + case "t": + tab = m.objType + case "o", "u": + default: + return nil + } + r, err := ref.ForContainer(c, tab) + if err != nil { + m.flash = flash{text: "share: " + err.Error(), isErr: true} + return nil + } + if k == "u" { + if u := r.IdentifierURL(); u != "" { + return m.copyText(c.Name+" URL", u, false) + } + return nil + } + return m.copyText("link to "+c.Name, r.String(), false) +} + +// shareObject copies what the share menu's key k stands for. +func (m *Model) shareObject(o provider.Object, k string) tea.Cmd { + m.usedObject(o) + if k == "b" { + if o.URL == "" { + return nil + } + return m.copyText(o.Name+" web page", o.URL, false) + } + if k == "y" { + if o.Reference == "" { + return nil + } + return m.copyText(o.Name+" reference", o.Reference, false) + } + version := "" + if m.versionsOf != nil { + version = o.Version // shared from the versions view: that version + } + r, err := ref.ForObject(o, version) + if err != nil { + m.flash = flash{text: "share: " + err.Error(), isErr: true} + return nil + } + switch k { + case "u": + if u := r.IdentifierURL(); u != "" { + return m.copyText(o.Name+" URL", u, false) + } + return nil + } + return m.copyText("link to "+o.Name, r.String(), false) +} + +func (m *Model) startOpenPrompt() tea.Cmd { + in := textinput.New() + in.Prompt = "open: " + in.Placeholder = "lazysecrets:// link, Key Vault URL or reference" + in.SetWidth(max(20, min(m.w-12, 64))) + m.prompt = &modal{ + title: "Open a link", input: &in, + lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")}, + submit: func(m *Model, text string) tea.Cmd { return m.openText(text) }, + } + // Pre-fill from the clipboard, read now: it may hold any reference, + // including ones not offered in the footer (our own, dismissed ones). + clip := m.clip + readClip := func() tea.Msg { + text, err := clip.Read() + if err != nil { + return nil + } + text = strings.TrimSpace(text) + if _, err := ref.Parse(text); err != nil || strings.ContainsAny(text, "\n") { + return nil // not a reference: don't keep it + } + return openPromptFillMsg(text) + } + return tea.Batch(m.prompt.input.Focus(), readClip) +} + +type openPromptFillMsg string + +// fillOpenPrompt puts a reference from the clipboard in the open prompt, +// unless the user has started typing. +func (m *Model) fillOpenPrompt(text string) { + if p := m.prompt; p != nil && p.input != nil && p.input.Value() == "" { + p.input.SetValue(text) + p.input.CursorEnd() + } +} + +// openText parses a pasted or typed reference and asks what to do with it. +func (m *Model) openText(s string) tea.Cmd { + r, err := ref.Parse(s) + if err != nil { + m.flash = flash{text: "open: " + err.Error(), isErr: true} + return nil + } + return m.offerRef("Open link", r, false) +} + +// handlePaste opens a pasted reference, unless a text input has focus. +func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd { + if p := m.prompt; p != nil && p.input != nil { + in, cmd := p.input.Update(msg) + p.input = &in + return cmd + } + if l := m.focusedFilter(); l != nil && l.Filtering() { + return l.UpdateFilter(msg) + } + if m.prompt != nil { + return nil + } + return m.openText(msg.Content) +} + +type clipboardTextMsg string + +// clipCheckEvery throttles the clipboard check on key presses, which +// covers terminals that don't report focus. +const clipCheckEvery = 3 * time.Second + +// maybeCheckClipboard checks the clipboard if it hasn't been for a while. +func (m *Model) maybeCheckClipboard() tea.Cmd { + if now := m.now(); now.Sub(m.clipChecked) >= clipCheckEvery { + m.clipChecked = now + return m.checkClipboard() + } + return nil +} + +// checkClipboard reads the clipboard in the background to look for a +// lazysecrets:// link. Only such links are kept; anything else (it may well +// be a password) is dropped as soon as it has been looked at. +func (m Model) checkClipboard() tea.Cmd { + clip := m.clip + return func() tea.Msg { + text, err := clip.Read() + if err != nil { + return nil + } + text = strings.TrimSpace(text) + if !isLink(text) { + return clipboardTextMsg("") + } + return clipboardTextMsg(text) + } +} + +// isLink reports whether s looks like a lazysecrets:// link, valid or not. +func isLink(s string) bool { + return strings.HasPrefix(strings.ToLower(s), ref.Scheme+"://") +} + +// offerClipboardLink pops up a link found on the clipboard, once: whatever +// the user picks, that link isn't offered again. It isn't offered while +// another popup is open, or if it points at what's selected already (e.g. +// just shared with s). A lazysecrets:// link that doesn't parse is +// reported, so a typo doesn't fail silently. +func (m *Model) offerClipboardLink(text string) { + if text == "" || text == m.dismissedLink || m.prompt != nil || m.search != nil { + return + } + r, err := ref.Parse(text) + if err != nil { + m.dismissedLink = text + m.showMenu("Link on clipboard can't be read", []string{m.st.Error.Render(err.Error())}, nil) + return + } + if m.isSelected(r) { + return + } + m.dismissedLink = text + m.offerRef("Link on clipboard", r, true) +} + +// isSelected reports whether r points at the selected container, or the +// selected object in it. +func (m Model) isSelected(r ref.Ref) bool { + c, ok := m.containers.Selected() + if !ok || c.Source.Kind != r.Kind || !matchesContainer(c, r) { + return false + } + if r.IsContainer() { + return true + } + o, ok := m.detail.Secret() + return ok && o.Name == r.Name && (r.Type == "" || o.Type == r.Type) && + (r.Version == "" || o.Version == r.Version) +} diff --git a/internal/ui/app/links_test.go b/internal/ui/app/links_test.go new file mode 100644 index 0000000..2fc0492 --- /dev/null +++ b/internal/ui/app/links_test.go @@ -0,0 +1,600 @@ +package app + +import ( + "context" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +const kv = "kv-app-prod.vault.azure.net" + +// open pastes a reference and, for an object, chooses to go to it. +func (d *driver) open(link string) { + d.t.Helper() + d.openWith(link, "o") +} + +// openWith pastes a reference and answers the "Open link" menu with k. +func (d *driver) openWith(link, k string) { + d.t.Helper() + d.send(tea.PasteMsg{Content: link}) + if d.m.prompt != nil && d.m.prompt.title == "Open link" { + d.keys(k) + } +} + +func TestOpenSecretLink(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("1", "j") // start elsewhere: platform-dev + d.open("lazysecrets://azure/" + kv + "/secrets/api-key") + if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" { + t.Fatalf("opened %s/%s/%s", src, c, s) + } + if d.m.focus != paneSecrets || d.m.detail.State() != panes.ValueHidden { + t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State()) + } +} + +func TestOpenAcceptsOtherFormats(t *testing.T) { + t.Parallel() + for _, in := range []string{ + "https://" + kv + "/secrets/api-key", + "@Microsoft.KeyVault(SecretUri=https://" + kv + "/secrets/api-key)", + "@Microsoft.KeyVault(VaultName=kv-app-prod;SecretName=api-key)", + } { + d := newDriver(t) + d.open(in) + if _, _, s := d.selected(); s != "api-key" { + t.Errorf("%s: selected %q", in, s) + } + } +} + +func TestOpenVaultLinkOnTab(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("1", "j") + d.open("lazysecrets://azure/" + kv + "/certificates") + if _, c, s := d.selected(); c != "kv-app-prod" || d.m.objType != provider.TypeCertificate || s != "app-tls" { + t.Errorf("vault link: %q tab %s selected %q", c, d.m.objType, s) + } + if d.m.focus != paneContainers { + t.Errorf("focus = %d, want the vaults pane", d.m.focus) + } +} + +func TestOpenVersionLink(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.open("lazysecrets://azure/" + kv + "/secrets/db-password/91d3e0") + if d.m.versionsOf == nil { + t.Fatal("versions view not opened") + } + if o, _ := d.m.secrets.Selected(); o.Version != "91d3e0" { + t.Errorf("selected version %q", o.Version) + } +} + +func TestOpeningAsksWhatToDo(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.send(tea.PasteMsg{Content: "lazysecrets://azure/" + kv + "/secrets/db-password"}) + screen := d.screen() + for _, want := range []string{"Open link", "platform-prod / kv-app-prod / db-password", "o Go to it", + "c Go to it and copy its value", "r Go to it and show its value", "x Dismiss"} { + if !strings.Contains(screen, want) { + t.Fatalf("missing %q:\n%s", want, screen) + } + } + if d.clip().content != "" { + t.Fatal("did something before being asked") + } + d.keys("c") + if d.clip().content != "correct-horse-battery-staple" || strings.Contains(d.screen(), "correct-horse") { + t.Errorf("copy: clipboard %q, revealed %v", d.clip().content, strings.Contains(d.screen(), "correct-horse")) + } + + d2 := newDriver(t) + d2.keys("1", "j") + d2.openWith("lazysecrets://azure/"+kv+"/secrets/api-key", "r") + if _, _, s := d2.selected(); s != "api-key" || !strings.Contains(d2.screen(), "sk_live_51HxDEMO") { + t.Errorf("show: selected %q\n%s", s, d2.screen()) + } + + // Old links carrying ?action= still open, and still ask. + d3 := newDriver(t) + d3.send(tea.PasteMsg{Content: "lazysecrets://azure/" + kv + "/secrets/db-password?action=copy"}) + if d3.m.prompt == nil || d3.clip().content != "" { + t.Error("an old ?action=copy link acted without asking") + } +} + +func TestOpenDatabricksLinks(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.open("lazysecrets://databricks/adb-2222222222222222.2.azuredatabricks.net/app/storage-key") + if src, c, s := d.selected(); src != "dev" || c != "app" || s != "storage-key" { + t.Errorf("databricks link: %s/%s/%s", src, c, s) + } + + // A bare {{secrets/…}} reference: the app scope is in both workspaces. + d.open("{{secrets/app/storage-key}}") + screen := d.screen() + if d.m.prompt == nil || !strings.Contains(screen, "1 Open in prod") || !strings.Contains(screen, "2 Open in dev") || + !strings.Contains(screen, "x Dismiss") { + t.Fatalf("no choice:\n%s", screen) + } + d.keys("1") + if src, _, s := d.selected(); src != "prod" || s != "storage-key" { + t.Errorf("after choosing prod: %s/%s", src, s) + } +} + +// unlocatable hides Locate, like an account that can read a vault's +// secrets but not see the vault resource. +type unlocatable struct{ provider.Provider } + +func TestOpenUnlocatableVaultUsesOpenedLinks(t *testing.T) { + t.Parallel() + d := newDriver(t, unlocatable{fake.Azure(0)}, fake.Databricks(0)) + d.open("lazysecrets://azure/" + kv + "/secrets/api-key") + if !strings.Contains(d.screen(), "↗ Opened links (1)") { + t.Fatalf("no Opened links row:\n%s", d.screen()) + } + if _, c, s := d.selected(); c != "kv-app-prod" || s != "api-key" { + t.Errorf("opened %s/%s", c, s) + } + if c, _ := d.m.containers.Selected(); c.Source.Provider == "" { + t.Error("the account that could read it wasn't recorded") + } +} + +func TestOpenErrors(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.open("hello") + if !strings.Contains(d.screen(), "open: not a lazysecrets link") { + t.Errorf("bad paste:\n%s", d.screen()) + } + d.open("lazysecrets://azure/" + kv + "/secrets/nope") + if !strings.Contains(d.screen(), "open: nope not found in kv-app-prod") { + t.Errorf("missing secret:\n%s", d.screen()) + } + d.open("lazysecrets://databricks/adb-9.9.azuredatabricks.net/app") + if !strings.Contains(d.screen(), "no Databricks profile for workspace adb-9.9") { + t.Errorf("unknown workspace:\n%s", d.screen()) + } +} + +func TestPasteWhileFilteringFilters(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "/") + d.send(tea.PasteMsg{Content: "api"}) + if d.m.secrets.Len() != 1 || d.m.nav != nil { + t.Errorf("paste in filter: visible %d", d.m.secrets.Len()) + } +} + +func TestOpenPromptAndStartupOpen(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl}) + d.send(tea.PasteMsg{Content: "lazysecrets://azure/" + kv + "/secrets/api-key"}) + d.keys("enter", "o") + if _, _, s := d.selected(); s != "api-key" { + t.Errorf("ctrl+o: selected %q", s) + } + + r, _ := ref.Parse("lazysecrets://azure/" + kv + "/secrets/api-key") + d2 := newDriverOpts(t, Options{Open: &r}) + if d2.m.prompt == nil || d2.m.prompt.title != "Open link" { + t.Fatal("open at startup didn't ask what to do") + } + d2.keys("o") + if _, _, s := d2.selected(); s != "api-key" { + t.Errorf("open at startup: selected %q", s) + } +} + +func TestShareLinks(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "s") // s: the plain link, straight away + if want := "lazysecrets://azure/" + kv + "/secrets/db-password"; d.clip().content != want { + t.Errorf("s = %q", d.clip().content) + } + if strings.Contains(d.screen(), "copies its value") { + t.Error("share menu still offers action links") + } + for k, want := range map[string]string{ + "o": "lazysecrets://azure/" + kv + "/secrets/db-password", + "u": "https://" + kv + "/secrets/db-password", + "y": "@Microsoft.KeyVault(SecretUri=https://" + kv + "/secrets/db-password)", + } { + d.keys("S", k) + if d.clip().content != want { + t.Errorf("S %s = %q, want %q", k, d.clip().content, want) + } + } + d.keys("2", "S", "u") // vault menu: plain vault URL + if want := "https://" + kv + "/"; d.clip().content != want { + t.Errorf("vault URL = %q", d.clip().content) + } + d.keys("]", "S", "t") // link that opens on the Keys tab + if want := "lazysecrets://azure/" + kv + "/keys"; d.clip().content != want { + t.Errorf("tab link = %q", d.clip().content) + } + d.keys("[", "S", "b") + if !strings.HasPrefix(d.clip().content, "https://portal.azure.com/") { + t.Errorf("portal link = %q", d.clip().content) + } + d.keys("2", "s") + if want := "lazysecrets://azure/" + kv; d.clip().content != want { + t.Errorf("vault link = %q", d.clip().content) + } + d.keys("3", "v", "j", "s") // from the versions view: that version + if want := "lazysecrets://azure/" + kv + "/secrets/db-password/91d3e0"; d.clip().content != want { + t.Errorf("version link = %q", d.clip().content) + } + d.keys("esc", "1", "j", "j", "j", "3", "S") + if strings.Contains(d.screen(), "Key Vault URL") { + t.Error("Databricks offered a Key Vault URL") + } + d.keys("o") + if want := "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key"; d.clip().content != want { + t.Errorf("databricks link = %q", d.clip().content) + } + + // Every shared link opens what was shared. + d2 := newDriver(t) + d2.open(d.clip().content) + if src, c, s := d2.selected(); src != "prod" || c != "app" || s != "storage-key" { + t.Errorf("round trip: %s/%s/%s", src, c, s) + } +} + +// blind sees vaults (Locate) but may not read them, like an account with +// Reader on the subscription but no Key Vault data-plane role. +type blind struct{ *fake.Provider } + +func (blind) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) { + return nil, fake.ErrForbidden +} + +func TestOpenAsksWhichAccount(t *testing.T) { + t.Parallel() + // IDs like the real ones: they contain a "/". + alice := blind{fake.Azure(0).As("Azure:alice@example.com/t1", "Azure · alice")} + bob := fake.Azure(0).As("Azure:bob@example.com/t1", "Azure · bob") + d := newDriver(t, alice, bob) + d.open("lazysecrets://azure/" + kv + "/secrets/api-key") + screen := d.screen() + for _, want := range []string{"Several accounts can see kv-app-prod", + "1 Open with Azure · alice / platform-prod", "2 Open with Azure · bob / platform-prod", "x Dismiss"} { + if !strings.Contains(screen, want) { + t.Fatalf("missing %q:\n%s", want, screen) + } + } + d.keys("2") + c, _ := d.m.containers.Selected() + if _, _, s := d.selected(); s != "api-key" || c.Source.Provider != "Azure:bob@example.com/t1" { + t.Errorf("opened %q via %q, want api-key via bob", s, c.Source.Provider) + } + + // j/k and enter work too; x dismisses. + d.open("lazysecrets://azure/" + kv + "/secrets/api-key") + d.keys("x") + if d.m.prompt != nil || d.m.nav != nil { + t.Error("x didn't dismiss") + } + d.open("lazysecrets://azure/" + kv + "/secrets/legacy-token") + d.keys("j", "enter") + if c, _ := d.m.containers.Selected(); c.Source.Provider != "Azure:bob@example.com/t1" { + t.Errorf("j enter picked %q", c.Source.Provider) + } + + // Opening into a folded account unfolds it. + d.keys("1", "g") // alice's header + d.keys("enter") // fold it + d.open("lazysecrets://azure/" + kv + "/secrets/api-key") + d.keys("1") + if c, _ := d.m.containers.Selected(); c.Source.Provider != "Azure:alice@example.com/t1" { + t.Errorf("folded account not opened: %q", c.Source.Provider) + } +} + +// offered reports whether a "Link on clipboard" popup is showing. +func (d *driver) offered() bool { + return d.m.prompt != nil && strings.HasPrefix(d.m.prompt.title, "Link on clipboard") +} + +func TestLinkOnClipboardPopsUp(t *testing.T) { + t.Parallel() + link := "lazysecrets://azure/" + kv + "/secrets/api-key" + d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: link}}) + screen := d.screen() + for _, want := range []string{"Link on clipboard", "platform-prod / kv-app-prod / api-key", "o Go to it", "c Go to it and copy its value", "x Dismiss"} { + if !strings.Contains(screen, want) { + t.Fatalf("popup missing %q:\n%s", want, screen) + } + } + d.keys("o") + if _, _, s := d.selected(); s != "api-key" || d.offered() { + t.Errorf("o opened %q, popup still shown: %v", s, d.offered()) + } + d.send(tea.FocusMsg{}) // same link: offered once only + if d.offered() { + t.Error("offered again after opening") + } +} + +func TestClipboardLinkOfferedOnce(t *testing.T) { + t.Parallel() + d := newDriver(t) + if d.offered() { + t.Fatal("empty clipboard offered a link") + } + d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key" // copied in another app + d.send(tea.FocusMsg{}) + if !d.offered() { + t.Fatal("not offered on focus") + } + d.keys("esc") + d.clip().content = "something else" + d.send(tea.FocusMsg{}) + d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key" // copied again later + d.send(tea.FocusMsg{}) + if d.offered() { + t.Error("link offered a second time") + } + d.clip().content = "lazysecrets://azure/" + kv + "/secrets/legacy-token" // a different one + d.send(tea.FocusMsg{}) + if !d.offered() { + t.Error("new link not offered") + } +} + +func TestClipboardIgnoresOtherText(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: "hunter2"}}) + if d.offered() || d.m.dismissedLink != "" { + t.Error("non-link clipboard text used") + } + // A Key Vault URL isn't offered: only lazysecrets:// links are. + d.clip().content = "https://" + kv + "/secrets/api-key" + d.send(tea.FocusMsg{}) + if d.offered() { + t.Error("Key Vault URL offered") + } +} + +func TestLinkToSelectionNotOffered(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "s") // share db-password: it's what's selected + d.send(tea.FocusMsg{}) + if d.offered() { + t.Fatal("offered a link to the selected secret") + } + d.keys("1", "j") // elsewhere now: the same link is worth offering + d.send(tea.FocusMsg{}) + if !d.offered() || !strings.Contains(d.screen(), "platform-prod / kv-app-prod / db-password") { + t.Errorf("own link not offered after moving away:\n%s", d.screen()) + } +} + +func TestNoClipboardPopupOverAnotherPopup(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "S") // share menu open + d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key" + d.send(tea.FocusMsg{}) + if d.offered() || !strings.HasPrefix(d.m.prompt.title, "Share") { + t.Error("clipboard popup replaced the share menu") + } + d.keys("esc") + d.send(tea.FocusMsg{}) // offered once the other popup is gone + if !d.offered() { + t.Error("link not offered after the share menu closed") + } +} + +func TestInvalidClipboardLinkIsReported(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: "lazysecrets://azure/" + kv + "/secret/api-key"}}) + if !strings.Contains(d.screen(), "Link on clipboard can't be read") || !strings.Contains(d.screen(), `unknown object type "secret"`) { + t.Fatalf("typo not reported:\n%s", d.screen()) + } + d.keys("x") + if d.m.prompt != nil { + t.Error("x didn't dismiss the report") + } +} + +func TestOpenPromptPrefilledFromClipboard(t *testing.T) { + t.Parallel() + link := "lazysecrets://azure/" + kv + "/secrets/api-key" + d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: link}}) + d.keys("x") // the clipboard popup comes first + d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl}) + d.keys("enter", "o") + if _, _, s := d.selected(); s != "api-key" { + t.Errorf("prefilled ctrl+o opened %q", s) + } +} + +func TestOpenPromptReadsClipboardFresh(t *testing.T) { + t.Parallel() + d := newDriver(t) + // Copied in another app, no focus event: not offered, but ctrl+o + // still picks it up, and so it does for a plain Key Vault URL. + d.clip().content = "https://" + kv + "/secrets/api-key" + d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl}) + d.keys("enter", "o") + if _, _, s := d.selected(); s != "api-key" { + t.Errorf("ctrl+o didn't use the clipboard: %q", s) + } + + // Not prefilled with text that isn't a reference. + d.clip().content = "hunter2" + d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl}) + if v := d.m.prompt.input.Value(); v != "" { + t.Errorf("prefilled with %q", v) + } +} + +func TestClipboardCheckedOnKeysWithoutFocusEvents(t *testing.T) { + t.Parallel() + d := newDriver(t) + clock := time.Now() + d.m.now = func() time.Time { return clock } + d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key" + + clock = clock.Add(clipCheckEvery + time.Second) + d.keys("j") // any key, once the throttle allows + if !d.offered() { + t.Fatal("not offered after a key press") + } + d.keys("x") + d.clip().content = "lazysecrets://azure/" + kv + "/secrets/legacy-token" + d.keys("k") // within the throttle window: not checked yet + if d.offered() { + t.Error("checked again within the throttle window") + } + clock = clock.Add(clipCheckEvery) + d.keys("j") + if !d.offered() { + t.Error("new link not offered after the window") + } +} + +func TestOpenSourceLinks(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.send(tea.PasteMsg{Content: "lazysecrets://azure/subscriptions/sub-dev"}) + if src, _, _ := d.selected(); src != "platform-dev" || d.m.focus != paneContainers { + t.Errorf("subscription link: %q, focus %d", src, d.m.focus) + } + d.send(tea.PasteMsg{Content: "lazysecrets://databricks/adb-2222222222222222.2.azuredatabricks.net"}) + if src, _, _ := d.selected(); src != "dev" { + t.Errorf("workspace link: %q", src) + } + + two := newDriver(t, fake.Azure(0).As("Azure:alice", "Azure · alice"), fake.Azure(0).As("Azure:bob", "Azure · bob")) + two.send(tea.PasteMsg{Content: "lazysecrets://azure/subscriptions/sub-dev"}) + if !strings.Contains(two.screen(), "2 Open with Azure · bob / platform-dev") { + t.Fatalf("no account choice:\n%s", two.screen()) + } + two.keys("2") + if r, _ := two.m.sources.Selected(); r.provider != "Azure:bob" || r.source.Name != "platform-dev" { + t.Errorf("chose %s/%s", r.provider, r.source.Name) + } +} + +func TestHandedOverLinkAndActivity(t *testing.T) { + t.Parallel() + touched := 0 + d := newDriverOpts(t, Options{OnActive: func() { touched++ }}) + d.keys("j") + d.send(tea.FocusMsg{}) + if touched != 2 { + t.Errorf("OnActive called %d times, want 2", touched) + } + r, _ := ref.Parse("lazysecrets://azure/kv-shared-prod.vault.azure.net/secrets/smtp-password") + d.send(OpenLink(r)) + if d.m.prompt == nil || !strings.Contains(d.screen(), "Open link") { + t.Fatalf("handed-over link not offered:\n%s", d.screen()) + } + d.keys("o") + if _, c, s := d.selected(); c != "kv-shared-prod" || s != "smtp-password" { + t.Errorf("went to %s/%s", c, s) + } +} + +func TestRefLabel(t *testing.T) { + t.Parallel() + d := newDriver(t) + for link, want := range map[string]string{ + // The subscription from lists already loaded. + "lazysecrets://azure/kv-app-prod.vault.azure.net/keys/signing/abc123": "platform-prod / kv-app-prod / signing · key · version abc123", + "lazysecrets://azure/kv-app-prod.vault.azure.net/certificates": "platform-prod / kv-app-prod · certificates tab", + // Not loaded: the vault alone. + "lazysecrets://azure/kv-elsewhere.vault.azure.net/secrets/x": "kv-elsewhere / x", + } { + r, err := ref.Parse(link) + if err != nil { + t.Fatal(err) + } + if got := ansi.Strip(d.m.refLabel(r)); !strings.HasSuffix(got, want) { + t.Errorf("%s: %q, want …%q", link, got, want) + } + } +} + +func TestCopyFromLinkStaysPut(t *testing.T) { + t.Parallel() + alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")} + bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")} + d := newDriverOpts(t, Options{}, denyingValues{alice}, bob, fake.Databricks(0)) + d.keys("j") // platform-dev + before := d.m.sources.SelectedID() + + r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password") + d.send(OpenLink(r)) + d.keys("y") + if got := d.clip().content; got != "correct-horse-battery-staple" { + t.Fatalf("clipboard = %q (alice denies, bob reads)\n%s", got, d.screen()) + } + if d.m.sources.SelectedID() != before || d.m.nav != nil || strings.Contains(d.screen(), "correct-horse") { + t.Errorf("moved or revealed:\n%s", d.screen()) + } + + // A version, and a Databricks secret. + r, _ = ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0") + d.send(OpenLink(r)) + d.keys("y") + if got := d.clip().content; got != "previous-password" { + t.Errorf("version: clipboard = %q", got) + } + r, _ = ref.Parse("lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key") + d.send(OpenLink(r)) + d.keys("y") + if got := d.clip().content; got == "" || got == "previous-password" { + t.Errorf("databricks: clipboard = %q\n%s", got, d.screen()) + } +} + +// denyingValues refuses to read values, like an account without access. +type denyingValues struct{ *counting } + +func (d denyingValues) Value(context.Context, provider.Object) (provider.SecretValue, error) { + return provider.SecretValue{}, fake.ErrForbidden +} + +func TestShareWebPages(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("S") // a subscription + if !strings.Contains(d.screen(), "o Lazysecrets link") || !strings.Contains(d.screen(), "b Web page (Azure portal)") { + t.Fatalf("subscription share menu:\n%s", d.screen()) + } + d.keys("o") + if got := d.clip().content; got != "lazysecrets://azure/subscriptions/sub-prod" { + t.Errorf("subscription link %q", got) + } + d.keys("3", "S", "b") // a secret's web page + if got := d.clip().content; !strings.HasPrefix(got, "https://portal.azure.com/") { + t.Errorf("secret web page %q", got) + } +} diff --git a/internal/ui/app/modal.go b/internal/ui/app/modal.go new file mode 100644 index 0000000..8b27d91 --- /dev/null +++ b/internal/ui/app/modal.go @@ -0,0 +1,408 @@ +package app + +import ( + "fmt" + "slices" + "strings" + "unicode/utf8" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/match" + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/ui/keys" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +// A modal is a centred popup that takes the keyboard, like lazygit's menus: +// a list of options, each with a shortcut key, or a text input. + +type option struct { + key string + label string + tip string // shown under the list while the option is selected + note string // dimmed on the right of the row; the filter matches it + // star is the store key ctrl+f stars or unstars (favourites, search); + // starred shows a ★ before the label. + star string + starAccount string // the account the star is for + starred bool + // mark, if set, goes first: how much is loaded (search). reload, + // if set, runs on ctrl+r. + mark string + reload func(m *Model) tea.Cmd + id string // identifies the entry across rebuilds (search) + group string // the source row it's in (search) + kind provider.Kind // the kind of provider it's from (search) + hits []int // characters of the label the filter matched (Go to) + run func(m *Model) tea.Cmd + // header makes this a section title: not selectable, and hidden + // while a filter narrows the list. + header bool +} + +type modal struct { + title string + lines []string // explanation above the options + about *ref.Ref // drawn above lines, redrawn as lists load (refLabel) + options []option + cursor int + + // A text input instead of options; submit runs on enter. + input *textinput.Model + submit func(m *Model, text string) tea.Cmd + + // A filter over the options: typing narrows them (all holds them + // all), so moving uses arrows or ctrl+j/k. + filter *textinput.Model + all []option + + footer string // overrides the default footer + onClose func(m *Model) // runs when it closes, picked or not +} + +// showFilterMenu opens a menu with a filter input above the options. +func (m *Model) showFilterMenu(title string, options []option) tea.Cmd { + in := textinput.New() + in.Prompt = "/" + in.Placeholder = "type to filter" + in.SetWidth(40) + m.prompt = &modal{title: title, options: options, all: options, filter: &in} + m.prompt.move(0) + m.flash = flash{} + return m.prompt.filter.Focus() +} + +// move moves the cursor by delta, skipping section headers; with nothing +// selectable that way it stays put. move(0) settles on a selectable option. +func (p *modal) move(delta int) { + ok := func(i int) bool { return i >= 0 && i < len(p.options) && !p.options[i].header } + step := 1 + if delta < 0 { + step = -1 + } + i := max(0, min(p.cursor+delta, len(p.options)-1)) + for i >= 0 && i < len(p.options) && !ok(i) { + i += step + } + switch { + case ok(i): + p.cursor = i + case !ok(p.cursor): // e.g. after filtering: first selectable + for p.cursor = 0; p.cursor < len(p.options) && !ok(p.cursor); p.cursor++ { + } + } +} + +// counter is "3 of 12" over the selectable options. +func (p *modal) counter() string { + n, at := 0, 0 + for i, o := range p.options { + if o.header { + continue + } + n++ + if i == p.cursor { + at = n + } + } + if n == 0 { + return "" + } + return fmt.Sprintf("%d of %d", at, n) +} + +// refilter narrows the options to fuzzy matches of the filter. Matches +// stay under their section headers, best first within each section, and +// sections without matches drop out. +func (p *modal) refilter() { + q := p.filter.Value() + if q == "" { + p.options = p.all + p.cursor = max(0, min(p.cursor, len(p.options)-1)) + p.move(0) + return + } + items := make([]match.Item, len(p.all)) + for i, o := range p.all { + label := ansi.Strip(o.label) + items[i] = match.Item{Text: strings.TrimSpace(label + " " + o.note), NameEnd: utf8.RuneCountInString(label)} + } + rank := map[int]int{} // matching options: their place in the ranking + for n, h := range match.Rank(match.Parse(q), items) { + if !p.all[h.Index].header { + rank[h.Index] = n + } + } + + p.options = nil + var section []int + header := -1 + flush := func() { + if len(section) == 0 { + return + } + slices.SortStableFunc(section, func(a, b int) int { return rank[a] - rank[b] }) + if header >= 0 { + p.options = append(p.options, p.all[header]) + } + for _, i := range section { + p.options = append(p.options, p.all[i]) + } + } + for i, o := range p.all { + if o.header { + flush() + section, header = nil, i + continue + } + if _, ok := rank[i]; ok { + section = append(section, i) + } + } + flush() + p.cursor = max(0, min(p.cursor, len(p.options)-1)) + p.move(0) +} + +// showMenu opens a menu. Every menu ends with an x Dismiss row. +func (m *Model) showMenu(title string, lines []string, options []option) { + options = append(options, option{key: "x", label: "Dismiss", run: func(*Model) tea.Cmd { return nil }}) + m.prompt = &modal{title: title, lines: lines, options: options} + m.flash = flash{} +} + +// handleModal routes keys while a modal is shown. +func (m *Model) handleModal(msg tea.KeyPressMsg) tea.Cmd { + p := m.prompt + km := m.keys + textField := p.input != nil || p.filter != nil + closeIt := func() { + m.prompt = nil + if p.onClose != nil { + p.onClose(m) + } + } + if (textField && key.Matches(msg, km.PopupClose)) || (!textField && key.Matches(msg, km.MenuClose)) { + closeIt() + return nil + } + if p.input != nil { + if key.Matches(msg, km.PopupSelect) { + m.prompt = nil + return p.submit(m, p.input.Value()) + } + in, cmd := p.input.Update(msg) + p.input = &in + return cmd + } + if p.filter != nil { + switch { + case key.Matches(msg, km.PopupDown): + p.move(1) + case key.Matches(msg, km.PopupUp): + p.move(-1) + case key.Matches(msg, km.PopupSelect): + if len(p.options) > 0 && !p.options[p.cursor].header { + closeIt() + return p.options[p.cursor].run(m) + } + default: + in, cmd := p.filter.Update(msg) + p.filter = &in + p.refilter() + return cmd + } + return nil + } + // A menu: each entry's letter picks it (fixed, part of its label), + // before the moving keys. + for _, o := range p.options { + if o.key != "" && o.key == msg.String() { + m.prompt = nil + return o.run(m) + } + } + switch { + case key.Matches(msg, km.Help): + return m.showKeybindingsFor(keys.Menu) + case key.Matches(msg, km.MenuDown): + p.cursor = min(p.cursor+1, len(p.options)-1) + case key.Matches(msg, km.MenuUp): + p.cursor = max(p.cursor-1, 0) + case key.Matches(msg, km.MenuSelect): + m.prompt = nil + return p.options[p.cursor].run(m) + } + return nil +} + +// modalView draws the modal as a box sized to its content. +func (m Model) modalView() string { + p := m.prompt + var body []string + if p.about != nil { + body = append(body, " "+m.refLabel(*p.about)) + } + for _, l := range p.lines { + body = append(body, " "+l) + } + if (len(p.lines) > 0 || p.about != nil) && (len(p.options) > 0 || p.input != nil) { + body = append(body, "") + } + km := m.keys + foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss")) + if p.input != nil { + body = append(body, " "+p.input.View()) + foot = footer(hint(km.PopupSelect, "open"), hint(km.PopupClose, "cancel")) + } + + // Size from all options, not the ones shown or the cursor's, so the + // popup doesn't change shape while moving or filtering. + all := p.all + if all == nil { + all = p.options + } + optText := func(o option) string { + pre := "" + if o.mark != "" { + pre = o.mark + " " + } + if o.star != "" { + mark := " " + if o.starred { + mark = m.st.Favourite.Render(star) + } + return pre + mark + o.label + } + if o.key == "" { + return o.label + } + return m.st.Label.Render(o.key) + " " + o.label + } + optW := 0 + for _, o := range all { + if !o.header { + w := ansi.StringWidth(optText(o)) + 2 // "▸ " or " " + if o.note != "" { + w += 2 + ansi.StringWidth(o.note) + } + optW = max(optW, w) + } + } + optW = min(optW, max(0, m.w-8)) // notes give way to the screen + row := func(o option) string { + return withNote(m.st, optText(o), o.note, optW-2) + } + + const window = 15 // rows of a filtered list + options, first, rows := p.options, 0, len(p.options) + if p.filter != nil { + body = append(body, " "+p.filter.View(), "") + foot = footer(move(km.PopupUp, km.PopupDown), hint(km.PopupSelect, "jump"), hint(km.PopupClose, "close")) + rows = min(len(all), window) + first = max(0, min(p.cursor-window/2, len(options)-window)) + options = options[first:min(len(options), first+window)] + if len(options) == 0 { + body = append(body, " "+m.st.Muted.Render("no matches")) + rows-- + } + } + var headers []int // body lines that are section headers, drawn once w is known + for j, o := range options { + if o.header { + if j > 0 { + body = append(body, "") + } + headers = append(headers, len(body)) + body = append(body, o.label) + continue + } + if i := first + j; i == p.cursor { + sel := m.st.Selected + if m.termBlurred { + sel = m.st.SelectedBlurred + } + body = append(body, " "+panes.Highlight("▸ "+row(o), sel, optW)) + } else { + body = append(body, " "+" "+row(o)) + } + } + for range rows - len(options) { + body = append(body, "") // keep the height while filtering + } + + if p.footer != "" { + foot = p.footer + } + w := max(ansi.StringWidth(p.title)+6, optW+4, ansi.StringWidth(foot)+6) + for _, l := range body { + w = max(w, ansi.StringWidth(l)+3) + } + if p.input != nil || p.filter != nil { + w = max(w, min(m.w-4, 72)) + } + w = min(w, m.w-2) + + // Section headers as rules: "─── Secrets pane ─────────". + for _, i := range headers { + title := body[i] + fill := max(1, w-2-2-3-2-ansi.StringWidth(title)) // borders, margins, "───", spaces + body[i] = " " + m.st.Muted.Render("─── ") + panes.TitleStyle(m.st, !m.termBlurred).Render(title) + + m.st.Muted.Render(" "+strings.Repeat("─", fill)) + } + + // The selected option's tip, wrapped; room for the longest one is kept + // so the popup doesn't change height while moving. + wrapTip := func(tip string) []string { + if tip == "" { + return nil + } + return strings.Split(ansi.Wordwrap(tip, w-4, " "), "\n") + } + tipRows := 0 + for _, o := range all { + tipRows = max(tipRows, len(wrapTip(o.tip))) + } + if tipRows > 0 { + var tip []string + if p.cursor < len(p.options) { + tip = wrapTip(p.options[p.cursor].tip) + } + body = append(body, "") + for i := range tipRows { + line := "" + if i < len(tip) { + line = " " + m.st.Muted.Render(tip[i]) + } + body = append(body, line) + } + } + counter := "" + if p.filter != nil { + counter = p.counter() + } + focused := !m.termBlurred // like the panes, no highlight in an unfocused window + return panes.BoxCounted(m.st, panes.TitleStyle(m.st, focused).Render(p.title), foot, counter, body, w, len(body)+2, focused) +} + +// overlay draws the modal centred over the screen. +func (m Model) overlay(base string) string { + var box string + if m.search != nil { + box = m.searchViewBox() + } else { + box = m.modalView() + } + x := max(0, (m.w-lipgloss.Width(box))/2) + y := max(0, (strings.Count(base, "\n")+1-lipgloss.Height(box))/3) + return lipgloss.NewCompositor( + lipgloss.NewLayer(base), + lipgloss.NewLayer(box).X(x).Y(y).Z(1), + ).Render() +} diff --git a/internal/ui/app/notes.go b/internal/ui/app/notes.go new file mode 100644 index 0000000..6b2d074 --- /dev/null +++ b/internal/ui/app/notes.go @@ -0,0 +1,93 @@ +package app + +import ( + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/store" + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +// Notes: n writes a short note on the selected subscription, vault or +// secret, kept in its store record. Notes show dimmed on the right of rows +// and in the detail pane, and the filter matches them. + +// withNote right-aligns a dimmed note in a row of the given width. The +// note gives way first when space is short. +func withNote(st styles.Styles, left, note string, width int) string { + if note == "" { + return left + } + room := width - ansi.StringWidth(left) - 2 + if room < 4 { + return left + } + note = ansi.Truncate(note, room, "…") + pad := width - ansi.StringWidth(left) - ansi.StringWidth(note) + return left + strings.Repeat(" ", pad) + st.Muted.Render(note) +} + +// editNote opens a popup to write the focused item's note. +func (m *Model) editNote() tea.Cmd { + var key, name string + var path []string + var src provider.Source + switch m.focus { + case paneSources: + s, ok := m.selectedSource() + if !ok { + m.flash = warning("select a subscription or workspace to add a note") + return nil + } + key, name, src = sourceKey(s), s.Name, s + case paneContainers: + c, ok := m.containers.Selected() + if !ok { + return nil + } + key, name, path, src = storeKey(c), c.Name, []string{c.Source.Name}, c.Source + default: + o, ok := m.detail.Secret() + if !ok { + return nil + } + c := o.Container + key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source + } + in := textinput.New() + in.Prompt = "note: " + in.Placeholder = "e.g. prod SQL admin" + in.CharLimit = 80 + in.SetWidth(48) + in.SetValue(m.store.Node(key).Note) + in.CursorEnd() + m.prompt = &modal{ + title: "Note on " + name, input: &in, + lines: []string{m.st.Muted.Render("Shown on the right of the row. Empty removes it.")}, + submit: func(m *Model, text string) tea.Cmd { + note := strings.Join(strings.Fields(text), " ") // one line + if err := m.remember(key, name, path, src, func(n *store.Node) { n.Note = note }); err != nil { + m.flash = flash{text: "saving: " + err.Error(), isErr: true} + return nil + } + m.refreshDetailNote() + return nil + }, + footer: footer(hint(m.keys.PopupSelect, "save"), hint(m.keys.PopupClose, "cancel")), + } + m.flash = flash{} + return m.prompt.input.Focus() +} + +// refreshDetailNote shows the note of whatever the detail pane shows. +func (m *Model) refreshDetailNote() { + if o, ok := m.detail.Secret(); ok { + m.detail.SetNote(m.store.Node(objectKey(o)).Note) + } else if c, ok := m.detail.Container(); ok { + m.detail.SetNote(m.store.Node(storeKey(c)).Note) + } +} diff --git a/internal/ui/app/notes_test.go b/internal/ui/app/notes_test.go new file mode 100644 index 0000000..e80f2ad --- /dev/null +++ b/internal/ui/app/notes_test.go @@ -0,0 +1,93 @@ +package app + +import ( + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/store" + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +func (d *driver) note(text string) { + d.t.Helper() + d.keys("n") + if d.m.prompt == nil || !strings.HasPrefix(d.m.prompt.title, "Note on ") { + d.t.Fatalf("n didn't open the note popup:\n%s", d.screen()) + } + for range 40 { // clear what's there + d.send(tea.KeyPressMsg{Code: tea.KeyBackspace}) + } + for _, r := range text { + d.send(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + d.keys("enter") +} + +func TestNotesOnRows(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "state.json") + mem, _ := store.Load(path) + d := newDriverOpts(t, Options{Store: mem}) + + d.keys("3", "j") + d.note("stripe live") + d.keys("2") + d.note("app secrets") + d.keys("1") + d.note("main prod") + + screen := d.screen() + for _, want := range []string{"stripe live", "app secrets", "main prod", "note stripe live"} { + if !strings.Contains(screen, want) { + t.Errorf("missing %q:\n%s", want, screen) + } + } + + // The filter matches notes. + d.keys("3", "/") + d.typeText("stripe") + if _, _, s := d.selected(); s != "api-key" || d.m.secrets.Len() != 1 { + t.Errorf("filter by note: %q (%d)", s, d.m.secrets.Len()) + } + d.keys("esc") + + // Saved, and an empty note removes it. + again, _ := store.Load(path) + notes := 0 + for _, n := range again.Nodes() { + if n.Note != "" { + notes++ + } + } + if notes != 3 { + t.Errorf("saved notes = %v", again.Nodes()) + } + d.keys("3") + d.note("") + if strings.Contains(d.screen(), "stripe live") { + t.Error("empty note didn't remove it") + } +} + +func TestWithNoteGivesWay(t *testing.T) { + t.Parallel() + st := styles.Default() + if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 || + !strings.HasSuffix(stripANSI(got), "prod SQL admin") { + t.Errorf("fits: %q", stripANSI(got)) + } + if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") || + !strings.HasSuffix(got, "…") { + t.Errorf("tight: %q", got) + } + if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" { + t.Errorf("no room: %q", got) + } +} + +func stripANSI(s string) string { return ansi.Strip(s) } diff --git a/internal/ui/app/repo_test.go b/internal/ui/app/repo_test.go new file mode 100644 index 0000000..167919b --- /dev/null +++ b/internal/ui/app/repo_test.go @@ -0,0 +1,240 @@ +package app + +import ( + tea "charm.land/bubbletea/v2" + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" + "git.kebcloud.no/keb/lazysecrets/internal/provider/repo" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +const sopsFile = `db: + password: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str] +sops: + age: + - recipient: age1xxxx + lastmodified: "2026-10-09T10:05:29Z" + mac: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str] +` + +// gitRepoWith makes a git repository holding a SOPS file, with a remote. +func gitRepoWith(t *testing.T, remote string) string { + t.Helper() + root, _ := filepath.EvalSymlinks(t.TempDir()) + os.MkdirAll(filepath.Join(root, "secrets"), 0o755) + os.WriteFile(filepath.Join(root, "secrets", "app.yaml"), []byte(sopsFile), 0o644) + for _, args := range [][]string{{"init", "-q"}, {"add", "."}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"}, + {"remote", "add", "origin", remote}} { + if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("%v %s", err, out) + } + } + return root +} + +func TestCurrentRepoFirst(t *testing.T) { + // Not parallel: git, run by the repo provider, is slow under load. + if _, err := exec.LookPath("git"); err != nil { + t.Skip("no git") + } + here := gitRepoWith(t, "https://git.example.com/team/here.git") + other := gitRepoWith(t, "https://git.example.com/team/aaa-other.git") + mem, _ := store.Load("") + mem.Update("lazysecrets://repo/git.example.com%2Fteam%2Faaa-other", func(n *store.Node) { + n.Source, n.StarredBy = other, []string{"Repos"} + }) + d := newDriverOpts(t, Options{Store: mem}, repo.New(repo.Settings{Dir: here, Starred: []string{other}})) + // Grouped: the repo it started in on a row of its own on top, + // selected, then the other repos under Repos, without it. + var rows []string + for _, r := range d.m.sources.Items() { + switch r.kind { + case rowGroup: + rows = append(rows, "group "+r.label) + case rowSource: + rows = append(rows, r.source.ID) + } + } + if want := []string{here, "group Repos", other}; !slices.Equal(rows, want) { + t.Errorf("rows %v, want %v", rows, want) + } + if !strings.Contains(d.screen(), "Repo · "+filepath.Base(here)) { + t.Errorf("no Repo · name row:\n%s", d.screen()) + } + if src, ok := d.m.selectedSource(); !ok || src.ID != here { + t.Errorf("selected %v, want the repo it started in", src.ID) + } + + // Flat: it sorts first, labelled. + d.keys("`") + if !strings.Contains(d.screen(), filepath.Base(here)+" · here") { + t.Errorf("no here label in the flat list:\n%s", d.screen()) + } +} + +func TestRepoSource(t *testing.T) { + // Not parallel: git, run by the repo provider, is slow under load. + if _, err := exec.LookPath("git"); err != nil { + t.Skip("no git") + } + root, _ := filepath.EvalSymlinks(t.TempDir()) + os.MkdirAll(filepath.Join(root, "secrets"), 0o755) + os.WriteFile(filepath.Join(root, "secrets", "app.yaml"), []byte(sopsFile), 0o644) + for _, args := range [][]string{{"init", "-q"}, {"add", "."}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"}, + {"remote", "add", "origin", "https://git.example.com/team/infra.git"}} { + if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("%v %s", err, out) + } + } + + d := newDriverOpts(t, Options{}, []provider.Provider{repo.New(repo.Settings{Dir: root})}...) + scr := d.screen() + for _, want := range []string{"Repos", filepath.Base(root), "[2] Files", "secrets/app.yaml sops · age", "db.password"} { + if !strings.Contains(scr, want) { + t.Fatalf("missing %q:\n%s", want, scr) + } + } + d.keys("3", "f") + key := "lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password" + if !d.m.store.Node(key).StarredFor("Repos") { + t.Errorf("star not stored under the repo link; nodes: %v", d.m.store.Nodes()) + } + d.keys("s") + if got := d.clip().content; got != key { + t.Errorf("shared link %q", got) + } + + // Opening the link finds the repo by its remote and goes there. + d.keys("1") + r, _ := ref.Parse(key) + d.send(OpenLink(r)) + d.keys("o") + if src, c, s := d.selected(); src != filepath.Base(root) || c != "secrets/app.yaml" || s != "db.password" { + t.Errorf("opened %s/%s/%s\n%s", src, c, s, d.screen()) + } +} + +func TestReposLoadThemselves(t *testing.T) { + // Not parallel: git, run by the repo provider, is slow under load. + if _, err := exec.LookPath("git"); err != nil { + t.Skip("no git") + } + root := gitRepoWith(t, "https://git.example.com/team/x.git") + // Started elsewhere, so the repo isn't the selected source. + d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Dir: t.TempDir(), Paths: []string{root}})) + d.openSearch() + d.searchAll() + d.typeText("db.password") + if !strings.Contains(d.screen(), "secrets/app.yaml / db.password") { + t.Errorf("repo keys not searchable without loading them:\n%s", d.screen()) + } +} + +func TestGroupsStartCollapsed(t *testing.T) { + // Not parallel: git, run by the repo provider, is slow under load. + if _, err := exec.LookPath("git"); err != nil { + t.Skip("no git") + } + here := gitRepoWith(t, "https://git.example.com/team/here.git") + d := newDriverOpts(t, Options{CollapseGroups: true}, + fake.Azure(0), fake.Databricks(0), repo.New(repo.Settings{Dir: here})) + var rows []string + for _, r := range d.m.sources.Items() { + switch r.kind { + case rowGroup: + rows = append(rows, "group "+r.label) + case rowSource: + rows = append(rows, r.source.Name) + } + } + // Folded groups, and the repo it started in on top, selected, so its + // files show. + if want := []string{filepath.Base(here), "group Azure", "group Databricks"}; !slices.Equal(rows, want) { + t.Errorf("rows %v, want %v", rows, want) + } + if src, ok := d.m.selectedSource(); !ok || src.ID != here { + t.Errorf("selected %v, want the repo it started in", src.ID) + } + d.keys("j", "enter") // unfold Azure + if d.sourceCount() < 2 { + t.Errorf("enter on a folded group didn't unfold it:\n%s", d.screen()) + } +} + +func TestCurrentRepoBeforeTheRest(t *testing.T) { + // Not parallel: git, run by the repo provider, is slow under load. + if _, err := exec.LookPath("git"); err != nil { + t.Skip("no git") + } + here := gitRepoWith(t, "https://git.example.com/team/here.git") + p := repo.New(repo.Settings{Dir: here}) + d := newDriverOpts(t, Options{}, fake.Azure(0), p) + // Sources asked again, nothing answered yet but the started-in repo + // on its own (provider.Current): it shows on top already. + d.m.loadSources() + src, ok := p.Current(context.Background()) + if !ok { + t.Fatal("no current repo") + } + src.Provider, src.ProviderLabel = p.ID(), p.Label() + d.send(currentMsg{gen: d.m.sourcesGen, source: src}) + if !strings.Contains(d.screen(), "Repo · "+filepath.Base(here)) { + t.Errorf("the started-in repo waits for the others:\n%s", d.screen()) + } +} + +func TestSearchAllRepos(t *testing.T) { + // Not parallel: git, run by the repo provider, is slow under load. + if _, err := exec.LookPath("git"); err != nil { + t.Skip("no git") + } + a := gitRepoWith(t, "https://git.example.com/team/alpha.git") + b := gitRepoWith(t, "https://git.example.com/team/beta.git") + d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Paths: []string{a, b}})) + d.openSearch() + d.send(ctrl('h')) + for d.m.search.src > 0 { + d.send(tea.KeyPressMsg{Code: tea.KeyUp}) + } + for d.m.search.scope().id != kindScope+string(provider.KindRepo) { + if d.m.search.src == len(d.m.search.scopes)-1 { + t.Fatalf("no All repos scope: %v", d.m.search.scopes) + } + d.send(tea.KeyPressMsg{Code: tea.KeyDown}) + } + azure := func() (n int) { + for row := range d.m.cache.containers { + if p, _ := splitSourceRow(row); p != "Repos" { + n++ + } + } + return n + } + azureBefore := azure() + d.typeText("password") + var got []string + for _, o := range d.m.search.shown { + if o.kind != provider.KindRepo { + t.Errorf("not a repo: %s", ansiStrip(o.label)) + } + got = append(got, ansiStrip(o.label)) + } + if len(got) != 2 { + t.Errorf("want db.password in both repos: %v", got) + } + if azure() != azureBefore || d.m.crawl.running { + t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure()) + } + if !strings.Contains(d.screen(), "Results in all repos") { + t.Errorf("title:\n%s", d.screen()) + } +} diff --git a/internal/ui/app/reveal.go b/internal/ui/app/reveal.go new file mode 100644 index 0000000..1c314bf --- /dev/null +++ b/internal/ui/app/reveal.go @@ -0,0 +1,61 @@ +package app + +import ( + "fmt" + "math" + "time" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +// A revealed value doesn't stay on screen: it hides after the configured +// timeout, counting down in the detail pane, and when the terminal loses +// focus (switching windows, screen sharing another app). + +// startRevealTimer starts the countdown for a value just revealed. +func (m *Model) startRevealTimer() tea.Cmd { + m.revealToken++ + if m.revealTimeout <= 0 { + m.revealDeadline = time.Time{} + return nil + } + m.revealDeadline = m.now().Add(m.revealTimeout) + m.showHidesIn() + return m.revealTickCmd() +} + +// revealTickCmd fires when the displayed second next changes. +func (m *Model) revealTickCmd() tea.Cmd { + wait := m.revealDeadline.Sub(m.now()) % time.Second + if wait <= 0 { + wait = time.Second + } + token := m.revealToken + return tea.Tick(wait, func(time.Time) tea.Msg { return revealTickMsg{token} }) +} + +func (m *Model) revealTick(msg revealTickMsg) tea.Cmd { + if msg.token != m.revealToken || m.revealDeadline.IsZero() || m.detail.State() != panes.ValueShown { + return nil // hidden meanwhile, or another value shown + } + if !m.now().Before(m.revealDeadline) { + m.hideValue() + return nil + } + m.showHidesIn() + return m.revealTickCmd() +} + +func (m *Model) showHidesIn() { + left := max(0, int(math.Ceil(m.revealDeadline.Sub(m.now()).Seconds()))) + m.detail.SetHidesIn(fmt.Sprintf("%ds", left)) +} + +// hideValue hides the revealed value and stops its countdown. +func (m *Model) hideValue() { + m.detail.Hide() + m.revealToken++ + m.revealDeadline = time.Time{} +} diff --git a/internal/ui/app/route.go b/internal/ui/app/route.go new file mode 100644 index 0000000..a57ff89 --- /dev/null +++ b/internal/ui/app/route.go @@ -0,0 +1,95 @@ +package app + +import ( + "context" + "fmt" + "slices" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// containerKey identifies a container in the UI and caches. The same vault +// seen through two accounts must not share cached lists, since each +// account may see different things. +func containerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID } + +// providerFor finds the provider instance a source came from. Sources +// remembered from an earlier run (favourites) may name an account that +// isn't signed in any more, or predate accounts; they fall back to the +// first provider of the same kind. +func (m Model) providerFor(src provider.Source) provider.Provider { + if p, ok := m.byID[src.Provider]; ok { + return p + } + for _, p := range m.providers { + if p.Kind() == src.Kind { + return p + } + } + return missingProvider{src} +} + +// candidates lists the providers that may serve src, in configured order: +// its own provider when that is known, otherwise every provider of its kind +// that lists the source (all of that kind if their sources haven't loaded). +// Favourites saved before accounts existed, or by an account that is no +// longer signed in, take the second path. +func (m Model) candidates(src provider.Source) []provider.Provider { + if p, ok := m.byID[src.Provider]; ok { + return []provider.Provider{p} + } + var seeing, ofKind []provider.Provider + for _, p := range m.providers { + if p.Kind() != src.Kind { + continue + } + ofKind = append(ofKind, p) + r, loaded := m.sourceResults[p.ID()] + if !loaded || slices.ContainsFunc(r.sources, func(s provider.Source) bool { return s.ID == src.ID }) { + seeing = append(seeing, p) + } + } + switch { + case len(seeing) > 0: + return seeing + case len(ofKind) > 0: + return ofKind + } + return []provider.Provider{missingProvider{src}} +} + +// boundTo returns src as served by p. +func boundTo(src provider.Source, p provider.Provider) provider.Source { + src.Provider, src.ProviderLabel = p.ID(), p.Label() + return src +} + +// missingProvider answers for a provider that isn't configured, e.g. a +// favourite from a provider disabled in the config. +type missingProvider struct{ src provider.Source } + +func (p missingProvider) err() error { + return fmt.Errorf("%s isn't available (disabled in the config or not signed in)", p.src.Kind) +} + +func (p missingProvider) Kind() provider.Kind { return p.src.Kind } +func (p missingProvider) ID() string { return p.src.Provider } +func (p missingProvider) Label() string { return string(p.src.Kind) } +func (p missingProvider) Sources(context.Context) ([]provider.Source, error) { + return nil, p.err() +} +func (p missingProvider) Containers(context.Context, provider.Source) ([]provider.Container, error) { + return nil, p.err() +} +func (p missingProvider) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) { + return nil, p.err() +} +func (p missingProvider) Describe(_ context.Context, o provider.Object) (provider.Object, error) { + return o, p.err() +} +func (p missingProvider) Versions(context.Context, provider.Object) ([]provider.Object, error) { + return nil, p.err() +} +func (p missingProvider) Value(context.Context, provider.Object) (provider.SecretValue, error) { + return provider.SecretValue{}, p.err() +} diff --git a/internal/ui/app/search.go b/internal/ui/app/search.go new file mode 100644 index 0000000..708c4e5 --- /dev/null +++ b/internal/ui/app/search.go @@ -0,0 +1,384 @@ +package app + +import ( + "cmp" + "context" + "fmt" + "slices" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" +) + +// ctrl+s searches everything loaded: subscriptions and workspaces, vaults +// and scopes, and the secrets, keys and certificates of every vault opened +// this session. Azure has no search across vaults, so ctrl+a in the popup +// loads the rest in the background, a few requests at a time; results +// appear as lists arrive, and the panes use them too. + +const crawlWorkers = 4 + +// crawl loads every vault's secrets for search. +type crawl struct { + running bool + queue []crawlJob + active int + pending map[string]bool // cache keys of lists queued or loading + failed map[string]bool // cache keys of lists that couldn't be read +} + +type crawlJob struct { + src *provider.Source // list its vaults or scopes + container *provider.Container // list its secrets + row string // the source's row key (containers cache key) + force bool // reload what's loaded already +} + +type crawlMsg struct { + job crawlJob + containers []provider.Container + objects []provider.Object + err error +} + +func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), d) +} + +// searchEntries lists everything loaded: the sources (with how much of +// each is loaded) and the vaults, scopes and secrets in them, each with +// its source's row key as group. +func (m *Model) searchEntries() (sources, items []option) { + type entry struct { + depth int + sort string + opt option + } + var es []entry + // key is the store key, so ctrl+f stars the entry; mark shows how + // much is loaded, reload (ctrl+r) loads it again. + add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) { + n := m.store.Node(key) + sort := strings.ToLower(strings.Join(path, "/")) + " " + account + es = append(es, entry{depth, sort, + option{label: m.pathLabel(kind, path, account), note: n.Note, star: key, + starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)), + mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}}) + } + for _, p := range m.providers { + for _, src := range m.sourceResults[p.ID()].sources { + src.Provider, src.ProviderLabel = p.ID(), p.Label() + row := sourceRowKey(p.ID(), src.ID) + add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd { + m.nav = &nav{sourceRow: row, sourceOnly: true} + return nil + }, func(m *Model) tea.Cmd { return m.loadSource(src) }) + for _, c := range m.cache.containers[row].value { + r, err := ref.ForContainer(c, provider.TypeSecret) + if err != nil { + continue + } + reload := func(m *Model) tea.Cmd { return m.loadContainer(c) } + add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd { + m.startNav(r, actGo, row) + return nil + }, reload) + for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} { + for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value { + r, err := ref.ForObject(o, "") + if err != nil { + continue + } + suffix := accountOf(src) + if t != provider.TypeSecret { + typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key" + suffix = strings.Trim(typ+" · "+suffix, " ·") + } + add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd { + m.startNav(r, actGo, row) + return nil + }, reload) + } + } + } + } + } + slices.SortStableFunc(es, func(a, b entry) int { return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort)) }) + for _, e := range es { + if e.depth == 0 { + sources = append(sources, e.opt) + } else { + items = append(items, e.opt) + } + } + return sources, items +} + +// coverage is how much of everything search can see: sources whose +// vaults are listed, vaults whose secrets are loaded, and secrets. Lists +// that couldn't be read count as done. +type coverage struct { + sources, sourcesListed int + vaults, vaultsLoaded int + secrets, unreadable int +} + +func (c coverage) complete() bool { + return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults +} + +func (m *Model) searchCoverage() coverage { return m.coverageOf("") } + +// coverageOf is searchCoverage for one kind of provider ("" for all). +func (m *Model) coverageOf(kind provider.Kind) coverage { + var c coverage + for _, p := range m.providers { + if kind != "" && p.Kind() != kind { + continue + } + for _, src := range m.sourceResults[p.ID()].sources { + row := sourceRowKey(p.ID(), src.ID) + c.sources++ + e, ok := m.cache.containers[row] + if ok || m.crawl.failed[row] { + c.sourcesListed++ + } + if m.crawl.failed[row] { + c.unreadable++ + } + for _, v := range e.value { + c.vaults++ + key := containerKey(v) + "|" + string(provider.TypeSecret) + if objs, ok := m.cache.objects[key]; ok { + c.vaultsLoaded++ + c.secrets += len(objs.value) + } else if m.crawl.failed[key] { + c.vaultsLoaded++ + c.unreadable++ + } + } + } + } + return c +} + +// searchLines says what the search covers. +func (m *Model) searchLines() []string { + c := m.searchCoverage() + switch { + case m.crawl.running: + return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.", + len(m.crawl.queue)+m.crawl.active))} + case c.complete(): + s := fmt.Sprintf("Searching everything: %s, %s, %s", + plural(c.sources, "source"), plural(c.vaults, "vault"), plural(c.secrets, "secret")) + if c.unreadable > 0 { + s += fmt.Sprintf(" (%s unreadable)", plural(c.unreadable, "list")) + } + return []string{m.st.Success.Render("✓") + " " + m.st.Notice.Render(s)} + } + s := fmt.Sprintf("Only what's loaded: %d/%d vaults", c.vaultsLoaded, c.vaults) + if c.sourcesListed < c.sources { + s += fmt.Sprintf(", %d/%d sources listed", c.sourcesListed, c.sources) + } + return []string{m.st.Warning.Render("⚠ " + s + "." + withKey(m.loadAllKey(), " %s loads the rest."))} +} + +func plural(n int, word string) string { + if n == 1 { + return "1 " + word + } + return fmt.Sprintf("%d %ss", n, word) +} + +// startCrawl loads every unlisted subscription's vaults and every vault's +// secrets not loaded yet. +func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf("") } + +// startCrawlOf is startCrawl for one kind of provider ("" for all). +func (m *Model) startCrawlOf(kind provider.Kind) tea.Cmd { + for _, p := range m.providers { + if kind != "" && p.Kind() != kind { + continue + } + for _, src := range m.sourceResults[p.ID()].sources { + src.Provider, src.ProviderLabel = p.ID(), p.Label() + m.queueSource(src, false) + } + } + return m.crawlStart() +} + +// loadSource loads (again) a subscription's vaults and their secrets. +func (m *Model) loadSource(src provider.Source) tea.Cmd { + m.queueSource(src, true) + return m.crawlStart() +} + +// loadContainer loads (again) a vault's secrets. +func (m *Model) loadContainer(c provider.Container) tea.Cmd { + m.queueContainer(c, sourceRowKey(c.Source.Provider, c.Source.ID), true) + return m.crawlStart() +} + +// crawlStart runs what's queued, and shows it in the popup. +func (m *Model) crawlStart() tea.Cmd { + cmd := m.crawlNext() + m.refreshSearch() + return cmd +} + +// queueSource queues listing src's vaults or, with them listed and not +// force, loading their secrets. +func (m *Model) queueSource(src provider.Source, force bool) { + row := sourceRowKey(src.Provider, src.ID) + if e, ok := m.cache.containers[row]; ok && !force { + for _, c := range e.value { + m.queueContainer(c, row, false) + } + return + } + m.enqueue(crawlJob{src: &src, row: row, force: force}, row) +} + +// queueContainer queues loading c's secrets, unless loaded and not force. +func (m *Model) queueContainer(c provider.Container, row string, force bool) { + key := containerKey(c) + "|" + string(provider.TypeSecret) + if _, ok := m.cache.objects[key]; ok && !force { + return + } + m.enqueue(crawlJob{container: &c, row: row, force: force}, key) +} + +func (m *Model) enqueue(job crawlJob, key string) { + if m.crawl.pending == nil { + m.crawl.pending, m.crawl.failed = map[string]bool{}, map[string]bool{} + } + if m.crawl.pending[key] { + return // queued or loading already + } + m.crawl.pending[key] = true + m.crawl.queue = append(m.crawl.queue, job) +} + +// crawlNext starts jobs up to crawlWorkers at a time. +func (m *Model) crawlNext() tea.Cmd { + var cmds []tea.Cmd + for m.crawl.active < crawlWorkers && len(m.crawl.queue) > 0 { + job := m.crawl.queue[0] + m.crawl.queue = m.crawl.queue[1:] + m.crawl.active++ + cmds = append(cmds, m.crawlCmd(job)) + } + m.crawl.running = m.crawl.active > 0 || len(m.crawl.queue) > 0 + return tea.Batch(cmds...) +} + +func (m *Model) crawlCmd(job crawlJob) tea.Cmd { + timeout := m.timeout + if job.src != nil { + p := m.providerFor(*job.src) + src := *job.src + return func() tea.Msg { + ctx, cancel := contextWithTimeout(timeout) + defer cancel() + items, err := p.Containers(ctx, src) + for i := range items { + items[i].Source = src + } + return crawlMsg{job: job, containers: items, err: err} + } + } + c := *job.container + p := m.providerFor(c.Source) + return func() tea.Msg { + ctx, cancel := contextWithTimeout(timeout) + defer cancel() + items, err := p.Objects(ctx, c, provider.TypeSecret) + for i := range items { + items[i].Container = c + } + return crawlMsg{job: job, objects: items, err: err} + } +} + +func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd { + m.crawl.active-- + key := msg.job.row + if msg.job.container != nil { + key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret) + } + delete(m.crawl.pending, key) + switch { + case msg.err != nil: + m.crawl.failed[key] = true + case msg.job.src != nil: + delete(m.crawl.failed, key) + m.cache.containers[key] = entry[[]provider.Container]{msg.containers, m.now()} + for _, c := range msg.containers { + m.queueContainer(c, key, msg.job.force) + } + default: + delete(m.crawl.failed, key) + m.cache.objects[key] = entry[[]provider.Object]{msg.objects, m.now()} + } + cmd := m.crawlNext() + m.refreshSearch() + return cmd +} + +// sourceMark shows how much of a subscription or workspace is loaded: +// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable. +func (m *Model) sourceMark(row string) string { + if m.crawl.pending[row] { + return m.st.Muted.Render("◌") + } + if m.crawl.failed[row] { + return m.st.Error.Render("✗") + } + e, ok := m.cache.containers[row] + if !ok { + return m.st.Muted.Render("○") + } + loaded, loading := 0, false + for _, c := range e.value { + key := containerKey(c) + "|" + string(provider.TypeSecret) + loading = loading || m.crawl.pending[key] + if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] { + loaded++ + } + } + switch { + case loading: + return m.st.Muted.Render("◌") + case loaded == len(e.value): + return m.st.Success.Render("●") + case loaded > 0: + return m.st.Warning.Render("◐") + } + return m.st.Muted.Render("○") +} + +// vaultMark marks a vault in search only when its secrets can't be read +// (✗) or are loading (◌): whether a subscription is loaded says whether +// its vaults are searchable. +func (m *Model) vaultMark(c provider.Container) string { + key := containerKey(c) + "|" + string(provider.TypeSecret) + switch { + case m.crawl.pending[key]: + return m.st.Muted.Render("◌") + case m.crawl.failed[key]: + return m.st.Error.Render("✗") + } + return " " +} + +// providerOf is the provider ID in a source row key. +func providerOf(row string) string { + p, _ := splitSourceRow(row) + return p +} diff --git a/internal/ui/app/search_test.go b/internal/ui/app/search_test.go new file mode 100644 index 0000000..26d02cf --- /dev/null +++ b/internal/ui/app/search_test.go @@ -0,0 +1,406 @@ +package app + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" + "github.com/charmbracelet/x/ansi" +) + +func (d *driver) openSearch() { + d.t.Helper() + d.send(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + if d.m.search == nil { + d.t.Fatalf("no search popup:\n%s", d.screen()) + } +} + +func ctrl(r rune) tea.KeyPressMsg { return tea.KeyPressMsg{Code: r, Mod: tea.ModCtrl} } + +// searchAll picks All on the left and goes back to the results. +func (d *driver) searchAll() { + d.send(ctrl('h')) + for d.m.search.src > 0 { + d.send(tea.KeyPressMsg{Code: tea.KeyUp}) + } + for d.m.search.scope().id != allSources { + d.send(tea.KeyPressMsg{Code: tea.KeyDown}) + } + d.send(ctrl('l')) +} + +func TestSearchLoaded(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("j") // platform-dev: away from what's loaded + d.openSearch() + for _, want := range []string{"Sources", "Results", "All", "platform-dev"} { + if !strings.Contains(d.screen(), want) { + t.Fatalf("missing %q:\n%s", want, d.screen()) + } + } + d.typeText("api-key") + if !strings.Contains(d.screen(), "no matches here · All searches everything") { + t.Errorf("no hint to search everything:\n%s", d.screen()) + } + d.searchAll() + if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") { + t.Fatalf("loaded secret not found:\n%s", d.screen()) + } + d.keys("enter") + if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" { + t.Errorf("jumped to %s/%s/%s", src, c, s) + } + if d.m.search != nil { + t.Error("popup still open") + } +} + +func TestSearchLoadsAsYouType(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.openSearch() // on the source the cursor is on: platform-prod + // Typing loads the scope: that source, without ctrl+a. + d.typeText("smtp") + if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") { + t.Fatalf("not found in a vault that wasn't loaded:\n%s", d.screen()) + } + if _, ok := d.m.cache.objects["Azure|sub-prod/kv-shared-prod|Secret"]; !ok { + t.Error("loaded list not in the panes' cache") + } + if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; ok { + t.Error("another source loaded too") + } + + // On All: everything. + d.keys("tab") // to the scopes + for d.m.search.src > 0 { + d.send(tea.KeyPressMsg{Code: tea.KeyUp}) + } + d.typeText("-") + if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok { + t.Errorf("typing on All didn't load the other sources:\n%s", d.screen()) + } + if d.m.search.input.Value() != "smtp-" { + t.Error("query lost while loading") + } + if !strings.Contains(d.screen(), "unreadable") { // kv-restricted refuses + t.Errorf("unreadable vaults not counted:\n%s", d.screen()) + } + // Once: the next key doesn't ask for the list that failed again; + // ctrl+a still does, on purpose. + d.typeText("p") + if d.m.crawl.running { + t.Error("typing on reloads what failed") + } +} + +func TestSearchSourceNarrowsAndLoads(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.openSearch() + d.keys("tab") // to Sources + sel := func() string { return d.m.search.scopes[d.m.search.src].id } + for d.m.search.src > 0 { + d.send(tea.KeyPressMsg{Code: tea.KeyUp}) + } + for !strings.HasPrefix(sel(), "platform-dev") { + d.send(tea.KeyPressMsg{Code: tea.KeyDown}) + if d.m.search.src == len(d.m.search.scopes)-1 { + t.Fatal("platform-dev not in sources") + } + } + if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "○") { + t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark) + } + if len(d.m.search.shown) != 0 { + t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown)) + } + d.send(ctrl('r')) + if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "●") { + t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark) + } + for _, o := range d.m.search.shown { + if !strings.Contains(ansiStrip(o.label), "platform-dev") { + t.Errorf("result from elsewhere: %q", ansiStrip(o.label)) + } + } + if len(d.m.search.shown) == 0 { + t.Error("nothing shown after loading platform-dev") + } + if _, ok := d.m.cache.objects["Azure|sub-prod/kv-shared-prod|Secret"]; ok { + t.Error("loaded another subscription too") + } +} + +func TestSearchResultsStayPut(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.openSearch() + d.send(tea.KeyPressMsg{Code: tea.KeyDown}) + ids := func() []string { + var out []string + for _, o := range d.m.search.shown { + out = append(out, o.id) + } + return out + } + before, at := ids(), d.m.search.shown[d.m.search.res].id + d.send(ctrl('a')) + after := ids() + if len(after) <= len(before) { + t.Fatalf("nothing loaded: %d → %d", len(before), len(after)) + } + for i, id := range before { + if after[i] != id { + t.Errorf("row %d changed from %q to %q", i, id, after[i]) + } + } + if got := d.m.search.shown[d.m.search.res].id; got != at { + t.Errorf("cursor moved from %q to %q", at, got) + } +} + +func TestStarFromSearch(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.openSearch() + d.typeText("api-key") + d.send(ctrl('f')) + if !d.m.store.Node(kvKey + "/secrets/api-key").StarredFor("Azure") { + t.Errorf("ctrl+f in search didn't star:\n%s", d.screen()) + } +} + +func ansiStrip(s string) string { return ansi.Strip(s) } + +func TestSearchStartsWhereYouAre(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("2", "j") // platform-prod / kv-shared-prod + d.openSearch() + v := d.m.search + if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-prod") { + t.Errorf("source not preselected: %q", got) + } + if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") { + t.Errorf("vault not under the cursor:\n%s", d.screen()) + } + + // From the Sources pane: just the source. + d.keys("esc", "esc", "1", "j") // stop typing, close; platform-dev + d.openSearch() + v = d.m.search + if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-dev") { + t.Errorf("source not preselected: %q", got) + } +} + +func TestSearchHint(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.openSearch() + d.searchAll() + if !strings.Contains(d.screen(), "vaults loaded · ctrl+a loads everything") { + t.Errorf("no hint on All:\n%s", d.screen()) + } + d.send(ctrl('a')) + if strings.Contains(d.screen(), "ctrl+a loads everything") { + t.Errorf("hint after loading everything:\n%s", d.screen()) + } +} + +func TestGoToTabsAndModes(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("2", "f", "j", "f") // two favourites + d.keys("F") + v := d.m.search + if v.tab != tabFavs || v.typing { + t.Fatalf("F: tab %d, typing %v", v.tab, v.typing) + } + d.keys("j") // browsing: j moves, doesn't type + if v.res != 1 || v.input.Value() != "" { + t.Errorf("j: cursor %d, input %q", v.res, v.input.Value()) + } + d.keys("]") + if v.tab != tabRecent { + t.Errorf("] → tab %d, want Recent", v.tab) + } + d.keys("]", "]") + if v.tab != tabFavs { + t.Errorf("] wraps to tab %d, want Favourites", v.tab) + } + d.keys("[") + if v.tab != tabSearch || !strings.Contains(d.screen(), "Scope") { + t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen()) + } + + d.keys("/") + d.typeText("shared") + if !v.typing || len(v.shown) == 0 { + t.Fatalf("typing: %v, %d results", v.typing, len(v.shown)) + } + d.keys("esc") // clears, back to browsing + if v.typing || v.input.Value() != "" || d.m.search == nil { + t.Errorf("esc while typing: typing %v, input %q, open %v", v.typing, v.input.Value(), d.m.search != nil) + } + d.keys("esc") // closes + if d.m.search != nil { + t.Error("esc while browsing didn't close") + } +} + +func TestSearchHighlightsMatches(t *testing.T) { + t.Parallel() + // Icons: labels start with a multi-byte glyph, where byte offsets and + // characters differ. + d := newDriverOpts(t, Options{Icons: true}) + d.openSearch() + d.typeText("dbpw") + if len(d.m.search.shown) == 0 { + t.Fatal("no results") + } + o := d.m.search.shown[0] + plain := []rune(ansiStrip(o.label)) + var got string + for _, h := range o.hits { + got += string(plain[h]) + } + if got != "dbpw" { + t.Errorf("hits %v spell %q in %q", o.hits, got, string(plain)) + } + with := d.m.searchViewBox() + for i := range d.m.search.shown { + d.m.search.shown[i].hits = nil + } + if with == d.m.searchViewBox() { + t.Error("matched characters not styled") + } +} + +func TestHelpBarFollowsPopup(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("F") + bar := lastLine(d.screen()) + for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} { + if !strings.Contains(bar, want) { + t.Errorf("browsing: help bar %q lacks %q", bar, want) + } + } + d.keys("/") + if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") { + t.Errorf("typing: help bar %q", bar) + } +} + +func TestHelpFromPopups(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("F", "?") + if d.m.prompt == nil || d.m.prompt.title != "Keybindings" || d.m.prompt.options[0].label != "Go to" { + t.Fatalf("? in Go to:\n%s", d.screen()) + } + if !strings.Contains(d.screen(), "switch list") { // tab: not on the help bar + t.Errorf("Go to's keys missing:\n%s", d.screen()) + } + var sections []string + for _, o := range d.m.prompt.options { + if o.header { + sections = append(sections, o.label) + } + if strings.Contains(ansiStrip(o.label), "copy value") { + t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label)) + } + } + if strings.Join(sections, ",") != "Go to,While typing, after /" { + t.Errorf("sections %v", sections) + } + d.keys("esc") // back to Go to, where it was + if d.m.search == nil || d.m.search.tab != tabFavs || d.m.prompt != nil { + t.Errorf("not back in Go to: search %v, prompt %v", d.m.search != nil, d.m.prompt != nil) + } + + // Picking a key in the list runs it in Go to. + d.keys("?") + d.typeText("next tab") + d.keys("enter") + if d.m.search == nil || d.m.search.tab != tabRecent { + t.Errorf("running ] from ? didn't switch Go to's tab") + } + + // Menus too. + d.keys("esc", "3", "S", "?") + if d.m.prompt == nil || d.m.prompt.options[0].label != "This menu" { + t.Fatalf("? in a menu:\n%s", d.screen()) + } + for _, o := range d.m.prompt.options { + if o.header && o.label != "This menu" { + t.Errorf("menu help has section %q", o.label) + } + } + d.keys("esc") + if d.m.prompt == nil || !strings.HasPrefix(d.m.prompt.title, "Share") { + t.Errorf("not back in the share menu:\n%s", d.screen()) + } +} + +func TestHelpRunsTypingKeysInGoTo(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("ctrl+s", "esc") // Go to on Search, browsing + before := d.m.search.results + d.keys("?") + d.typeText("ctrl+h") // "sources", a typing key + d.keys("enter") + if d.m.search == nil || !d.m.search.typing || d.m.search.results || !before { + t.Errorf("typing key from ? didn't run: typing %v, results %v", d.m.search != nil && d.m.search.typing, d.m.search.results) + } +} + +func TestGoToHelpBarEndsWithHelp(t *testing.T) { + t.Parallel() + d := newDriver(t) // 100 columns: Go to's keys don't all fit + d.keys("ctrl+s", "esc") + bar := strings.TrimSpace(lastLine(d.screen())) + if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") { + t.Errorf("help bar %q", bar) + } +} + +func TestLoadWithEmptyResults(t *testing.T) { + t.Parallel() + c := &counting{Provider: fake.Azure(0)} + d := newDriverOpts(t, Options{}, c) + d.openSearch() + d.send(ctrl('h')) // pick platform-dev, never opened, in the scopes + for d.m.search.src > 0 { + d.send(tea.KeyPressMsg{Code: tea.KeyUp}) + } + for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ { + if i > len(d.m.search.scopes) { + var labels []string + for _, o := range d.m.search.scopes { + labels = append(labels, ansiStrip(o.label)) + } + t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results) + } + d.send(tea.KeyPressMsg{Code: tea.KeyDown}) + } + d.send(ctrl('l')) + if len(d.m.search.shown) != 0 || !d.m.search.results { + t.Fatalf("want empty results with the cursor there: %d shown", len(d.m.search.shown)) + } + before := c.containers + d.send(ctrl('r')) // in the results, nothing selected: the source + if c.containers == before { + t.Error("ctrl+r with empty results didn't load the source") + } + if len(d.m.search.shown) == 0 { + t.Errorf("nothing shown after loading:\n%s", d.screen()) + } +} diff --git a/internal/ui/app/searchview.go b/internal/ui/app/searchview.go new file mode 100644 index 0000000..5342713 --- /dev/null +++ b/internal/ui/app/searchview.go @@ -0,0 +1,701 @@ +package app + +import ( + "fmt" + "slices" + "strings" + "unicode/utf8" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/match" + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/store" + "git.kebcloud.no/keb/lazysecrets/internal/ui/keys" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +// Go to is one popup for finding a place and going there. Tabs at the +// top: Favourites and Recent (from the store, so they work before +// anything loads) are one list each; Search has two lists side by side, +// the scope on the left (All, or one source with how much of it is +// loaded: ○ ◐ ● ◌ ✗) and what's in it on the right. +// +// Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f +// star, / to type) or typing a filter (ctrl+ letters). esc while typing +// clears the text and goes back to browsing; esc while browsing closes. +// F opens it on Favourites and R on Recent, browsing; search on Search, +// typing. +// +// The results don't move by themselves: as lists load, rows already shown +// keep their order and new ones come after them, with the cursor on the +// same entry. Starring keeps an entry where it is, its ★ showing the +// change. Only typing or picking another tab or scope re-sorts them. + +type searchView struct { + input textinput.Model + typing bool // the text field has the keyboard + tab int // tabFavs, tabRecent or tabSearch + results bool // in Search, the results have the cursor (else the scopes) + + scopes []option // All, then the sources (Search) + items []option // everything loaded, unfiltered + favs []option // starred, as when the popup opened + recent []option // recently used, as when the popup opened + shown []option // the tab's (or scope's) entries matching the input + src int // cursor in scopes + res int // cursor in shown + + // loaded are the scopes a search has loaded since the popup opened: + // typing loads the scope once, so lists that fail aren't asked for + // again on every key. + loaded map[string]bool +} + +const ( + tabFavs = iota + tabRecent + tabSearch + tabCount +) + +var tabNames = [tabCount]string{"Favourites", "Recent", "Search"} + +const ( + scopeFavs = "#favs" + scopeRecent = "#recent" + allSources = "#all" + kindScope = "#kind:" // + a provider kind: all of Azure, Databricks or repos +) + +const goToTitle = "Go to" + +// showSearch opens Go to on Search, typing, in the source you're in +// (else All), with the selected vault under the cursor. +func (m *Model) showSearch() tea.Cmd { + cmd := m.openGoTo(tabSearch, true) + m.preselect() + return cmd +} + +// showFavourites opens Go to on Favourites; showRecent on Recent. Both +// start browsing: they're short lists. +func (m *Model) showFavourites() tea.Cmd { return m.openGoTo(tabFavs, false) } +func (m *Model) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) } + +func (m *Model) openGoTo(tab int, typing bool) tea.Cmd { + in := textinput.New() + in.Prompt = "/" + in.SetWidth(searchWidth(m.w) - 6) // as wide as the popup + v := &searchView{input: in, tab: tab, results: true} + v.favs, v.recent = m.storeEntries() + m.search = v + m.fillSearch(false) + m.flash = flash{} + return m.setTyping(typing) +} + +// setTyping gives the keyboard to the text field, or takes it back. +func (m *Model) setTyping(on bool) tea.Cmd { + v := m.search + v.typing = on + if on { + v.input.Prompt, v.input.Placeholder = "/", "type to filter" + return v.input.Focus() + } + v.input.Blur() + v.input.Prompt = "" // the placeholder names the key + if v.input.Value() != "" { + v.input.Prompt = "/" + } + v.input.Placeholder = withKey(m.keys.GoFilter, "%s to filter") + return nil +} + +// scope is what the results come from: the tab, or in Search the scope +// picked on the left. +func (v *searchView) scope() option { + switch v.tab { + case tabFavs: + return option{id: scopeFavs} + case tabRecent: + return option{id: scopeRecent} + } + return v.scopes[v.src] +} + +// onSource reports whether the results come from one source. +func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" } + +// preselect starts where the user is: the selected source picked on the +// left, and the selected vault or scope under the cursor on the right. +func (m *Model) preselect() { + v := m.search + row := m.sources.SelectedID() + i := slices.IndexFunc(v.scopes, func(o option) bool { return o.group != "" && o.group == row }) + if i < 0 { + return + } + v.src = i + m.fillSearch(false) + if m.focus == paneSources { + return + } + if c, ok := m.containers.Selected(); ok { + key := storeKey(c) + if j := slices.IndexFunc(v.shown, func(o option) bool { return o.star == key }); j >= 0 { + v.res = j + } + } +} + +// fillSearch (re)builds the lists from what's loaded. keep keeps the +// order of rows already shown (lists arrived); otherwise the results are +// ranked afresh (the query, tab or scope changed). +func (m *Model) fillSearch(keep bool) { + v := m.search + atSrc, atRes := "", "" + if v.src < len(v.scopes) { + atSrc = v.scopes[v.src].id + } + if v.res < len(v.shown) { + atRes = v.shown[v.res].id + } + prev := map[string]int{} + for i, o := range v.shown { + prev[o.id] = i + } + + sources, items := m.searchEntries() + all := option{label: "All", id: allSources, mark: m.coverageMark("")} + scopes := []option{all} + // All of one kind, so a search can stay on repos (local, quick) + // without asking Azure for every vault; only with several kinds. + var kinds []provider.Kind + for _, p := range m.providers { + if len(m.sourceResults[p.ID()].sources) > 0 && !slices.Contains(kinds, p.Kind()) { + kinds = append(kinds, p.Kind()) + } + } + if len(kinds) > 1 { + for _, k := range kinds { + scopes = append(scopes, option{ + label: m.st.Kind(k).Render(m.icons.Prefix(k) + kindScopeLabel(k)), + id: kindScope + string(k), kind: k, mark: m.coverageMark(k), + }) + } + } + v.scopes, v.items = append(scopes, sources...), items + v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc })) + + v.shown = v.match() + if keep { + v.shown = stableOrder(v.shown, prev) + if i := slices.IndexFunc(v.shown, func(o option) bool { return o.id == atRes }); i >= 0 { + v.res = i + } + } + v.res = max(0, min(v.res, len(v.shown)-1)) +} + +// kindScopeLabel names the scope of all of a kind's sources. +func kindScopeLabel(k provider.Kind) string { + if k == provider.KindRepo { + return "All repos" + } + return "All " + string(k) +} + +// coverageMark is ○ ◐ ● for how much of a kind ("" for all) is loaded. +func (m *Model) coverageMark(kind provider.Kind) string { + switch c := m.coverageOf(kind); { + case c.complete(): + return m.st.Success.Render("●") + case c.vaultsLoaded > 0: + return m.st.Warning.Render("◐") + } + return m.st.Muted.Render("○") +} + +// scopeKind is the kind a scope covers all of, if it's one of those. +func (v *searchView) scopeKind() (provider.Kind, bool) { + k, ok := strings.CutPrefix(v.scope().id, kindScope) + if !ok || v.tab != tabSearch { + return "", false + } + return provider.Kind(k), true +} + +// match lists the scope's entries matching the input, best first. +func (v *searchView) match() []option { + var cand []option + switch sc := v.scope(); sc.id { + case scopeFavs: + cand = v.favs + case scopeRecent: + cand = v.recent + default: + kind, ofKind := v.scopeKind() + for _, o := range v.items { + if sc.id == allSources || ofKind && o.kind == kind || sc.group != "" && o.group == sc.group { + cand = append(cand, o) + } + } + } + q := v.input.Value() + if q == "" { + return slices.Clone(cand) + } + items := make([]match.Item, len(cand)) + for i, o := range cand { + label := ansi.Strip(o.label) + start, end := nameRange(label) + items[i] = match.Item{Text: strings.TrimSpace(label + " " + o.note), NameStart: start, NameEnd: end} + } + var out []option + for _, h := range match.Rank(match.Parse(q), items) { + o := cand[h.Index] + // Hits in the label are highlighted; ones in the note just count. + n := utf8.RuneCountInString(ansi.Strip(o.label)) + o.hits = nil + for _, p := range h.Pos { + if p < n { + o.hits = append(o.hits, p) + } + } + out = append(out, o) + } + return out +} + +// nameRange is where the item's own name is in a Go to label, in runes: +// the last part of "sub / vault / secret · account", which matches count +// most for. +func nameRange(label string) (int, int) { + end := len(label) + if i := strings.LastIndex(label, " · "); i >= 0 { + end = i + } + start := 0 // a source: all of it (an icon doesn't match letters) + if i := strings.LastIndex(label[:end], " / "); i >= 0 { + start = i + len(" / ") + } + return utf8.RuneCountInString(label[:start]), utf8.RuneCountInString(label[:end]) +} + +// refreshSearch updates an open search as lists arrive. +func (m *Model) refreshSearch() { + if m.search != nil { + m.fillSearch(true) + } +} + +// stableOrder puts entries shown before (prev) in their earlier order, +// new ones after them in the order given. +func stableOrder(opts []option, prev map[string]int) []option { + out := slices.Clone(opts) + slices.SortStableFunc(out, func(a, b option) int { + ia, okA := prev[a.id] + ib, okB := prev[b.id] + switch { + case okA && okB: + return ia - ib + case okA: + return -1 + case okB: + return 1 + } + return 0 + }) + return out +} + +func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd { + v := m.search + km := m.keys + // The actions shared by both modes, by their keys in the mode. + type modeKeys struct{ down, up, sources, results, switchList, star, load, loadAll, sel key.Binding } + k := modeKeys{km.GoDown, km.GoUp, km.GoSources, km.GoResults, km.GoSwitch, km.GoStar, km.GoLoad, km.GoLoadAll, km.GoSelect} + if v.typing { + k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect} + if key.Matches(msg, km.PopupClose) { + // Back to browsing, the filter cleared. + if v.input.Value() != "" { + v.input.SetValue("") + v.res = 0 + m.fillSearch(false) + } + return m.setTyping(false) + } + } else { + switch { + case key.Matches(msg, km.GoClose): + m.search = nil + return nil + case key.Matches(msg, km.GoFilter): + return m.setTyping(true) + case key.Matches(msg, km.Help): + return m.showKeybindingsFor(keys.GoTo) + case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab): + d := 1 + if key.Matches(msg, km.GoPrevTab) { + d = tabCount - 1 + } + v.tab = (v.tab + d) % tabCount + v.res, v.results = 0, true + m.fillSearch(false) + return nil + } + } + inSearch := v.tab == tabSearch + moveSrc := func(d int) { + v.src = max(0, min(v.src+d, len(v.scopes)-1)) + v.res = 0 + m.fillSearch(false) + } + switch { + case inSearch && key.Matches(msg, k.switchList): + v.results = !v.results + return nil + case inSearch && key.Matches(msg, k.sources): + v.results = false + return nil + case inSearch && key.Matches(msg, k.results): + v.results = true + return nil + case key.Matches(msg, k.up): + if v.results { + v.res = max(0, v.res-1) + } else { + moveSrc(-1) + } + return nil + case key.Matches(msg, k.down): + if v.results { + v.res = max(0, min(v.res+1, len(v.shown)-1)) + } else { + moveSrc(1) + } + return nil + case key.Matches(msg, k.sel): + switch { + case v.results && v.res < len(v.shown): + m.search = nil + return v.shown[v.res].run(m) + case !v.results && v.onSource(): + m.search = nil + return v.scope().run(m) + case !v.results: + v.results = true // All: on to the results + } + return nil + case inSearch && key.Matches(msg, k.load): + if o, ok := v.selected(); ok && o.reload != nil { + return o.reload(m) // the selected source, or a result's vault + } + // Nothing selected (no results yet, or All): the scope. + if v.onSource() { + return v.scope().reload(m) + } + kind, _ := v.scopeKind() + return m.startCrawlOf(kind) + case inSearch && key.Matches(msg, k.loadAll): + kind, _ := v.scopeKind() + return m.startCrawlOf(kind) + case key.Matches(msg, k.star): + if o, ok := v.selected(); ok && o.star != "" { + m.toggleStarKey(o.star, o.starAccount) + // The entry stays where it is, its ★ showing the change. + on := m.store.Node(o.star).StarredFor(o.starAccount) + for _, list := range [][]option{v.favs, v.recent} { + for i := range list { + if list[i].star == o.star && list[i].starAccount == o.starAccount { + list[i].starred = on + } + } + } + m.fillSearch(true) + } + return nil + } + if !v.typing { + return nil + } + before := v.input.Value() + in, cmd := v.input.Update(msg) + v.input = in + if v.input.Value() != before { + v.res = 0 + m.fillSearch(false) + return tea.Batch(cmd, m.searchLoads()) + } + return cmd +} + +// searchLoads loads what a search in the Search tab covers, like the +// load-all key: everything on All, else the source picked. Only what +// isn't loaded yet, once a popup; repos load by themselves anyway. +func (m *Model) searchLoads() tea.Cmd { + v := m.search + if v.tab != tabSearch || strings.TrimSpace(v.input.Value()) == "" { + return nil + } + sc := v.scope() + if v.loaded[sc.id] { + return nil + } + if v.loaded == nil { + v.loaded = map[string]bool{} + } + v.loaded[sc.id] = true + if kind, ok := v.scopeKind(); ok { + return m.startCrawlOf(kind) + } + if !v.onSource() { + return m.startCrawl() + } + providerID, sourceID := splitSourceRow(sc.group) + for _, src := range m.sourceResults[providerID].sources { + if src.ID == sourceID { + src.Provider, src.ProviderLabel = providerID, m.byID[providerID].Label() + m.queueSource(src, false) + return m.crawlStart() + } + } + return nil +} + +// selected is the entry under the cursor in the focused list (a scope +// that isn't one source is none). +func (v *searchView) selected() (option, bool) { + if v.results { + if v.res < len(v.shown) { + return v.shown[v.res], true + } + return option{}, false + } + if v.onSource() { + return v.scope(), true + } + return option{}, false +} + +// toggleStarKey stars or unstars a store record for an account and +// re-sorts the panes. +func (m *Model) toggleStarKey(key, account string) { + if err := m.store.Update(key, func(n *store.Node) { n.ToggleStar(account) }); err != nil { + m.flash = flash{text: "saving: " + err.Error(), isErr: true} + return + } + m.resortStarred() +} + +// searchHint tops the results when what they come from isn't all +// loaded, saying how to load it. +func (m Model) searchHint() string { + v := m.search + if m.crawl.running { + return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active)) + } + if v.tab != tabSearch { + return "" // from the store: nothing to load + } + if kind, ok := v.scopeKind(); ok || v.scope().id == allSources { + c := m.coverageOf(kind) + if c.complete() { + return "" + } + return m.st.Warning.Render(fmt.Sprintf("⚠ %d/%d vaults loaded", c.vaultsLoaded, c.vaults) + withKey(m.loadAllKey(), " · %s loads everything")) + } + src := v.scopes[v.src] + e, listed := m.cache.containers[src.group] + name := ansi.Strip(src.label) + if !listed { + if m.crawl.failed[src.group] { + return "" + } + return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name)) + } + loaded := 0 + for _, c := range e.value { + key := containerKey(c) + "|" + string(provider.TypeSecret) + if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] { + loaded++ + } + } + if loaded == len(e.value) { + return "" + } + return m.st.Warning.Render(fmt.Sprintf("⚠ %d/%d vaults loaded", loaded, len(e.value)) + withKey(m.loadKey(), " · %s loads "+name)) +} + +// loadKey and loadAllKey are the keys for loading in the current mode. +func (m Model) loadKey() key.Binding { + if m.search.typing { + return m.keys.SearchLoad + } + return m.keys.GoLoad +} + +func (m Model) loadAllKey() key.Binding { + if m.search.typing { + return m.keys.SearchLoadAll + } + return m.keys.GoLoadAll +} + +// searchWidth is most of the screen: paths are long. +func searchWidth(screen int) int { + return max(40, min(screen-4, screen*9/10)) +} + +// searchViewBox draws the popup. +func (m Model) searchViewBox() string { + v := m.search + focused := !m.termBlurred + w := searchWidth(m.w) + listH := max(5, min(m.h-10, 14)) + + srcW := 10 + for _, o := range v.scopes { + srcW = max(srcW, ansi.StringWidth(ansi.Strip(o.label))+ansi.StringWidth(o.note)+12) // marks, gap, borders + } + srcW = min(srcW, (w-4)*2/5) + resW := w - 4 - srcW + if v.tab != tabSearch { + resW = w - 4 // one list + } + + row := func(o option, width int, cursor, active bool) string { + mark := o.mark + if mark == "" { + mark = " " + } + star := " " + if o.starred { + star = m.st.Favourite.Render("★ ") + } + label := panes.MarkMatches(o.label, o.hits, m.st.Match) + text := withNote(m.st, mark+" "+star+label, o.note, width-4) // borders, cursor column + if !cursor { + return " " + text + } + sel := m.st.Selected + if !active || !focused { + sel = m.st.SelectedBlurred + } + return " " + panes.Highlight("▸"+text, sel, width-3) // inside the borders + } + window := func(n, cursor, h int) (int, int) { + first := max(0, min(cursor-h/2, n-h)) + return first, min(n, first+h) + } + inner := listH - 2 + + // The picked source is a choice that stays, like a tab: it keeps the + // full highlight while the results have the cursor. + var left []string + first, last := window(len(v.scopes), v.src, inner) + for i := first; i < last; i++ { + left = append(left, row(v.scopes[i], srcW, i == v.src, true)) + } + var right []string + resRows := inner + if hint := m.searchHint(); hint != "" { + right = append(right, " "+hint) + resRows-- + } + first, last = window(len(v.shown), v.res, resRows) + for i := first; i < last; i++ { + right = append(right, row(v.shown[i], resW, i == v.res, v.results)) + } + if len(v.shown) == 0 { + msg := "no matches" + switch { + case v.input.Value() == "" && v.scope().id == scopeFavs: + msg = "nothing starred yet" + withKey(m.keys.Favourite, ": %s in the panes stars the selected item") + case v.input.Value() == "" && v.scope().id == scopeRecent: + msg = "nothing used yet" + case v.input.Value() == "": + msg = "nothing loaded here yet" + case v.tab == tabSearch && v.scope().id != allSources: + msg += " here · All searches everything" + case v.tab != tabSearch: + msg += withKey(m.keys.GoNextTab, " here · Search (%s) looks in everything loaded") + } + right = append(right, " "+m.st.Muted.Render(msg)) + } + counter := func(i, n int) string { + if n == 0 { + return "" + } + return fmt.Sprintf("%d of %d", i+1, n) + } + title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) } + resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id] + if v.onSource() { + resultsTitle = "Results in " + ansi.Strip(v.scope().label) + } + if kind, ok := v.scopeKind(); ok { + resultsTitle = "Results in " + string(kind) + if kind == provider.KindRepo { + resultsTitle = "Results in all repos" + } + } + var lists string + if v.tab == tabSearch { + lists = lipgloss.JoinHorizontal(lipgloss.Top, + panes.BoxCounted(m.st, title("Scope", !v.results), "", counter(v.src, len(v.scopes)), left, srcW, listH, !v.results && focused), + panes.BoxCounted(m.st, title(resultsTitle, v.results), "", counter(v.res, len(v.shown)), right, resW, listH, v.results && focused), + ) + } else { + lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused) + } + + body := []string{" " + v.input.View(), ""} + for _, l := range strings.Split(lists, "\n") { + body = append(body, " "+l) + } + body = append(body, "") + if v.tab == tabSearch { + for _, l := range m.searchLines() { + body = append(body, " "+l) + } + } else { + body = append(body, "") // the same height on every tab + } + km := m.keys + var foot string + switch { + case v.typing: + hints := []keyHint{move(km.PopupUp, km.PopupDown)} + if v.tab == tabSearch { + hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load")) + } + foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, "go"), hint(km.PopupClose, "stop typing"))...) + default: + hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}} + if v.tab == tabSearch { + hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all")) + } + foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, "go"), hint(km.GoClose, "close"))...) + } + // Tabs in the title, the current one underlined, like the panes. + ts := panes.TitleStyle(m.st, focused) + head := ts.Render(goToTitle + " ") + for i, name := range tabNames { + if i > 0 { + head += ts.Render(" - ") + } + if i == v.tab { + head += ts.Underline(true).Render(name) + } else { + head += m.st.Muted.Render(name) + } + } + return panes.Box(m.st, head, foot, body, w, len(body)+2, focused) +} diff --git a/internal/ui/app/sources.go b/internal/ui/app/sources.go new file mode 100644 index 0000000..70ac125 --- /dev/null +++ b/internal/ui/app/sources.go @@ -0,0 +1,288 @@ +package app + +import ( + "cmp" + "fmt" + "slices" + "strings" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/store" + "git.kebcloud.no/keb/lazysecrets/internal/ui/icons" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +// The Sources pane either groups sources under a foldable header per +// provider, or lists them flat with the provider on each row. + +type rowKind int + +const ( + rowGroup rowKind = iota + rowSource + rowLinks // vaults opened from links that no account could locate +) + +type sourceRow struct { + kind rowKind + group provider.Kind // colour and icon + provider string // provider ID: one group per instance (account) + label string // group: provider label, e.g. "Azure · keb" + account string // flat source: account part of the label, if any + status string // group: source count, "…" while loading, "!" on error + collapsed bool // group only + flat bool // source only: shown without a group header + top bool // source only: the repo lazysecrets started in, on top + source provider.Source +} + +// sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in +// either; provider IDs contain "/" (Azure:user/tenant). +func sourceRowKey(providerID, sourceID string) string { return providerID + "|" + sourceID } + +func splitSourceRow(row string) (providerID, sourceID string) { + providerID, sourceID, _ = strings.Cut(row, "|") + return providerID, sourceID +} + +func sourceRowID(r sourceRow) string { + switch r.kind { + case rowGroup: + return "group:" + r.provider + case rowLinks: + return linksRowID + } + return sourceRowKey(r.provider, r.source.ID) +} + +func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] { + return panes.NewList("[1] Sources", panes.Item[sourceRow]{ + ID: sourceRowID, + // Headers have no filter text, so filtering shows just sources. + Filter: func(r sourceRow) string { + if r.kind != rowSource { + return "" + } + return r.source.Name + }, + // So "data" finds Databricks workspaces and an account name finds + // its subscriptions. + FilterExtra: func(r sourceRow) string { + if r.kind != rowSource { + return "" + } + return cmp.Or(r.source.ProviderLabel, string(r.group)) + " " + mem.Node(sourceKey(r.source)).Note + }, + Render: func(st styles.Styles, r sourceRow, width int) string { + node := mem.Node(sourceKey(r.source)) + kind := st.Kind(r.group) + starred := "" + if r.kind == rowSource && node.StarredFor(r.source.Provider) { + starred = st.Favourite.Render(star) + } + switch { + case r.kind == rowLinks: + return st.Notice.Render("↗ Opened links") + " " + st.Muted.Render(fmt.Sprintf("(%s)", r.status)) + case r.kind == rowSource && r.top: + return withNote(st, kind.Render(iconSet.Prefix(r.group)+"Repo · ")+starred+kind.Render(r.source.Name), node.Note, width) + case r.kind == rowSource && r.flat: + line := kind.Render(iconSet.Prefix(r.group)+fmt.Sprintf("%-10s", r.group)) + " " + starred + r.source.Name + if r.account != "" { + line += st.Muted.Render(" · " + r.account) + } + return withNote(st, line+here(st, r.source), node.Note, width) + case r.kind == rowSource: + // The provider's colour, so long groups stay recognisable. + return withNote(st, " "+starred+kind.Render(r.source.Name), node.Note, width) + } + arrow := "▾" + if r.collapsed { + arrow = "▸" + } + status := st.Muted.Render(fmt.Sprintf("(%s)", r.status)) + if r.status == "!" { + status = st.Error.Render("(!)") + } + return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status + }, + }) +} + +// rebuildSources lays out the Sources pane from the per-provider results. +func (m *Model) rebuildSources() { + // While filtering, headers drop out, so rows show their provider and + // account like the flat view (and folded groups are searched too). + flat := m.flatSources || m.sources.HasFilter() || m.sourcesFavs + m.sourcesFiltered = m.sources.HasFilter() + // Tabs: everything, or just the starred ones ([ and ]). + m.sources.Title, m.sources.Tabs = "[1]", []string{"Sources", "Favourites"} + m.sources.Tab, m.sources.Empty = 0, "no sources found" + if m.sourcesFavs { + m.sources.Tab, m.sources.Empty = 1, "nothing starred"+withKey(m.keys.Favourite, ": %s on a subscription or workspace stars it") + } + var rows []sourceRow + if n := len(m.linked); n > 0 && !m.sourcesFavs { + rows = append(rows, sourceRow{kind: rowLinks, status: fmt.Sprint(n)}) + } + // The repo lazysecrets started in gets a row of its own on top, so it + // opens on it; flat, it sorts first instead. + if src, ok := m.currentSource(); ok && !flat { + rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src}) + } + for _, p := range m.providers { + kind, id := p.Kind(), p.ID() + account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ") + if account == p.Label() { + account = "" + } + r, done := m.sourceResults[id] + sources := r.sources + if !flat { + sources = slices.DeleteFunc(slices.Clone(sources), func(s provider.Source) bool { return s.Here }) + } + if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) { + continue // repos show once some are found to have secret files + } + status := "…" + switch { + case done && r.err != nil: + status = "!" + case done: + status = fmt.Sprint(len(sources)) + } + if !flat { + rows = append(rows, sourceRow{ + kind: rowGroup, group: kind, provider: id, label: p.Label(), + status: status, collapsed: m.collapsed[id], + }) + if m.collapsed[id] { + continue + } + } + for _, s := range m.starredSourcesFirst(sources) { + if m.sourcesFavs && !m.store.Node(sourceKey(s)).StarredFor(s.Provider) { + continue + } + rows = append(rows, sourceRow{ + kind: rowSource, group: kind, provider: id, account: account, + flat: flat, source: s, + }) + } + } + if flat { + // Flat: starred sources go to the very top, across providers. + slices.SortStableFunc(rows, func(a, b sourceRow) int { + return starRank(m.store, a) - starRank(m.store, b) + }) + } + m.sources.SetItems(rows) + + // Until the user moves, keep the cursor on the first source in + // provider order rather than a header, so the other panes fill in on + // their own. Providers answer in any order, so this can change as + // results arrive. + if !m.sourcesTouched { + m.sources.Top() // scroll back up so the first header shows + for _, r := range rows { + if r.kind == rowSource { + m.sources.Select(sourceRowID(r)) + break + } + } + } +} + +// currentSource is the source lazysecrets started in: from its +// provider's list once that's in, else as found on its own beforehand. +func (m Model) currentSource() (provider.Source, bool) { + for _, p := range m.providers { + r, done := m.sourceResults[p.ID()] + if !done { + if m.current != nil && m.current.Provider == p.ID() { + return *m.current, true + } + continue + } + for _, src := range r.sources { + if src.Here { + return src, true + } + } + } + return provider.Source{}, false +} + +// starredSourcesFirst moves starred sources to the front, keeping the +// order otherwise. It doesn't modify sources. +func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source { + out := slices.Clone(sources) + rank := func(s provider.Source) int { + return boolRank(m.store.Node(sourceKey(s)).StarredFor(s.Provider)) + } + slices.SortStableFunc(out, func(a, b provider.Source) int { return rank(a) - rank(b) }) + return out +} + +// starRank orders the Favourites row, then starred sources, then the rest. +func starRank(mem *store.Store, r sourceRow) int { + switch { + case r.kind == rowLinks: + return 0 + case r.kind == rowSource && r.source.Here: + return 1 + case r.kind == rowSource && mem.Node(sourceKey(r.source)).StarredFor(r.source.Provider): + return 2 + } + return 3 +} + +func boolRank(starred bool) int { + if starred { + return 0 + } + return 1 +} + +// selectedSource is the source under the cursor; false on a header. +func (m Model) selectedSource() (provider.Source, bool) { + r, ok := m.sources.Selected() + if !ok || r.kind != rowSource { + return provider.Source{}, false + } + return r.source, true +} + +// onLinks reports whether the cursor is on the Opened links row. +func (m Model) onLinks() bool { + return m.sources.SelectedID() == linksRowID +} + +// toggleSourcesView switches between grouped and flat, keeping the cursor +// on the same source. +func (m *Model) toggleSourcesView() { + m.flatSources = !m.flatSources + m.rebuildSources() +} + +// toggleGroup folds or unfolds the group under the cursor. It reports +// whether the cursor was on a group header. +func (m *Model) toggleGroup() bool { + r, ok := m.sources.Selected() + if !ok || r.kind != rowGroup { + return false + } + m.collapsed[r.provider] = !m.collapsed[r.provider] + m.rebuildSources() + m.sources.Select(sourceRowID(r)) + return true +} + +// here labels the source lazysecrets was started in, in the flat list +// (grouped, it has the "Repo · name" row on top). +func here(st styles.Styles, s provider.Source) string { + if s.Here { + return st.Muted.Render(" · here") + } + return "" +} diff --git a/internal/ui/app/sources_test.go b/internal/ui/app/sources_test.go new file mode 100644 index 0000000..415818f --- /dev/null +++ b/internal/ui/app/sources_test.go @@ -0,0 +1,200 @@ +package app + +import ( + "context" + "slices" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" +) + +func TestGroupedSources(t *testing.T) { + t.Parallel() + d := newDriver(t) + screen := d.screen() + for _, want := range []string{"▾ Azure (2)", " platform-prod", "▾ Databricks (2)", " prod"} { + if !strings.Contains(screen, want) { + t.Errorf("missing %q:\n%s", want, screen) + } + } + if src, _, _ := d.selected(); src != "platform-prod" { + t.Errorf("startup cursor on %q, want the first source", src) + } +} + +func TestFoldGroup(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("k", "enter") // Azure header: fold + if !strings.Contains(d.screen(), "▸ Azure (2)") || strings.Contains(d.screen(), "platform-prod") { + t.Fatalf("Azure not folded:\n%s", d.screen()) + } + if d.m.focus != paneSources { + t.Error("enter on a header moved focus") + } + d.keys("space") // unfold + if !strings.Contains(d.screen(), "platform-prod") { + t.Fatalf("Azure not unfolded:\n%s", d.screen()) + } + // enter on a source still drills in. + d.keys("j", "enter") + if d.m.focus != paneContainers { + t.Error("enter on a source didn't move to vaults") + } +} + +func TestToggleFlatKeepsSelection(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("j", "j", "j") // Databricks prod + d.keys("`") + if !d.m.flatSources || strings.Contains(d.screen(), "▾") { + t.Fatalf("not flat:\n%s", d.screen()) + } + if !strings.Contains(d.screen(), "Databricks prod") { + t.Errorf("flat rows should show the provider:\n%s", d.screen()) + } + if src, _, _ := d.selected(); src != "prod" { + t.Errorf("selection lost: %q", src) + } + d.keys("`") + if src, _, _ := d.selected(); d.m.flatSources || src != "prod" { + t.Errorf("back to grouped: flat=%v selected=%q", d.m.flatSources, src) + } +} + +func TestFlatSourcesOption(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{FlatSources: true}) + if strings.Contains(d.screen(), "▾") || d.m.sources.Len() != 4 { + t.Errorf("FlatSources ignored:\n%s", d.screen()) + } +} + +func TestGroupHeaderShowsProviderError(t *testing.T) { + t.Parallel() + d := newDriver(t, failingSources{fake.Azure(0)}, fake.Databricks(0)) + if !strings.Contains(d.screen(), "▾ Azure (!)") { + t.Errorf("no error marker:\n%s", d.screen()) + } + if src, _, _ := d.selected(); src != "prod" { + t.Errorf("cursor should skip to the first working source, got %q", src) + } +} + +// slowSources delivers Azure's sources after Databricks', like the real +// Azure provider (network) versus Databricks (a local file). +func TestStartupPrefersFirstProviderWhenItAnswersLast(t *testing.T) { + t.Parallel() + m := New([]provider.Provider{fake.Azure(0), fake.Databricks(0)}, Options{Clipboard: &fakeClipboard{}}) + d := &driver{t: t, m: m} + d.send(tea.WindowSizeMsg{Width: 100, Height: 30}) + d.send(sourcesMsg{gen: m.sourcesGen, provider: "Databricks", sources: mustSources(t, fake.Databricks(0))}) + if src, _, _ := d.selected(); src != "prod" { + t.Fatalf("before Azure answers: %q", src) + } + d.send(sourcesMsg{gen: m.sourcesGen, provider: "Azure", sources: mustSources(t, fake.Azure(0))}) + if src, _, _ := d.selected(); src != "platform-prod" { + t.Errorf("cursor not moved to the first Azure source: %q", src) + } + + // Once the user has moved, late results don't move the cursor. + d2 := &driver{t: t, m: New([]provider.Provider{fake.Azure(0), fake.Databricks(0)}, Options{Clipboard: &fakeClipboard{}})} + d2.send(tea.WindowSizeMsg{Width: 100, Height: 30}) + d2.send(sourcesMsg{gen: d2.m.sourcesGen, provider: "Databricks", sources: mustSources(t, fake.Databricks(0))}) + d2.keys("j") // dev + d2.send(sourcesMsg{gen: d2.m.sourcesGen, provider: "Azure", sources: mustSources(t, fake.Azure(0))}) + if src, _, _ := d2.selected(); src != "dev" { + t.Errorf("cursor moved after the user navigated: %q", src) + } +} + +func mustSources(t *testing.T, p provider.Provider) []provider.Source { + t.Helper() + s, err := p.Sources(context.Background()) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestTwoAccounts(t *testing.T) { + t.Parallel() + alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")} + bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")} + d := newDriver(t, alice, bob) + + screen := d.screen() + if !strings.Contains(screen, "▾ Azure · alice (2)") || !strings.Contains(screen, "▾ Azure · bob (2)") { + t.Fatalf("one group per account expected:\n%s", screen) + } + + // Both accounts see platform-prod; bob's is a different row, and + // browsing it goes through bob's provider. + d.keys("j", "j", "j") // alice dev, bob header, bob prod + if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "db-password" { + t.Fatalf("bob's platform-prod: %s/%s/%s", src, c, s) + } + c, _ := d.m.containers.Selected() + if c.Source.Provider != "Azure:bob" || d.m.containers.SelectedID() != "Azure:bob|"+c.ID { + t.Errorf("vault not tied to bob: provider %q, key %q", c.Source.Provider, d.m.containers.SelectedID()) + } + if bob.objects != 1 { + t.Errorf("bob listed objects %d times, want 1", bob.objects) + } + before := alice.objects + d.keys("space") // reveal goes through bob too + if alice.values != 0 || bob.values != 1 || alice.objects != before { + t.Errorf("reveal routed wrong: alice values %d, bob values %d", alice.values, bob.values) + } + + if !strings.Contains(d.screen(), "source Azure · bob / platform-prod") { + t.Errorf("detail should name the account:\n%s", d.screen()) + } + + // Flat: the account is shown on each row. + d.keys("`") + if !strings.Contains(d.screen(), "platform-prod · bob") { + t.Errorf("flat rows should name the account:\n%s", d.screen()) + } +} + +func TestFilterSourcesByProvider(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("/") + d.typeText("databr") + d.m.sources.Top() + var names []string + for range d.m.sources.Len() { + r, _ := d.m.sources.Selected() + names = append(names, r.source.Name) + d.m.sources.Move(1) + } + slices.Sort(names) + if strings.Join(names, ",") != "dev,prod" { + t.Errorf("\"databr\" matched %v, want the Databricks workspaces", names) + } +} + +func TestFilteringShowsAccounts(t *testing.T) { + t.Parallel() + alice := fake.Azure(0).As("Azure:alice", "Azure · alice") + bob := fake.Azure(0).As("Azure:bob", "Azure · bob") + d := newDriver(t, alice, bob) + d.keys("k", "enter") // fold alice: filtering still finds her sources + d.keys("/") + d.typeText("dev") + screen := d.screen() + if !strings.Contains(screen, "platform-dev · ali") || !strings.Contains(screen, "platform-dev · bob") { + t.Errorf("filtered rows should name the account:\n%s", screen) + } + d.keys("esc") // clear: back to groups + if !strings.Contains(d.screen(), "▸ Azure · alice") || strings.Contains(d.screen(), "platform-dev · bob") { + t.Errorf("not grouped again after clearing:\n%s", d.screen()) + } +} diff --git a/internal/ui/app/testdata/TestGolden.golden b/internal/ui/app/testdata/TestGolden.golden new file mode 100644 index 0000000..cfbd552 --- /dev/null +++ b/internal/ui/app/testdata/TestGolden.golden @@ -0,0 +1,30 @@ +╭─ [1] Sources - Favourites ────╮╭─ Secret ────────────────────────────────────────────────────────╮ +│ ▾ Azure (2) ││ name db-password │ +│ platform-prod ││ vault kv-app-prod │ +│ platform-dev ││ source Azure / platform-prod │ +│ ▾ Databricks (2) ││ version 3f9c2a │ +│ prod ││ enabled yes │ +╰────────────────────── 2 of 6 ─╯│ updated 2026-09-01 09:30 │ +╭─ [2] Vaults ──────────────────╮│ expires 2027-09-01 09:30 │ +│ kv-app-prod ││ content text/plain │ +│ kv-shared-prod ││ tags env=prod, owner=data │ +│ kv-restricted ││ ref @Microsoft.KeyVault(SecretUri=https://kv-app-prod.vau│ +│ ││ lt.azure.net/secrets/db-password) │ +│ ││ │ +│ ││ value •••••••• (space to reveal) │ +│ ││ │ +╰────────────────────── 1 of 3 ─╯│ versions 3 · v to browse │ +╭─ [3] Secrets - Keys - Certs ──╮│ ▸ 3f9c2a 2026-09-01 current │ +│ db-password ││ 91d3e0 2026-03-01 expired │ +│ api-key ││ 0a77c4 2025-09-01 disabled │ +│ legacy-token (disabled) ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰────────────────────── 1 of 3 ─╯╰─────────────────────────────────────────────────────────────────╯ + enter open • s copy link • S share… • f star • F favourites • n note • … • ? help • q quit \ No newline at end of file diff --git a/internal/ui/app/update.go b/internal/ui/app/update.go new file mode 100644 index 0000000..7379e37 --- /dev/null +++ b/internal/ui/app/update.go @@ -0,0 +1,169 @@ +package app + +import ( + "context" + "errors" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/browser" + "git.kebcloud.no/keb/lazysecrets/internal/store" + "git.kebcloud.no/keb/lazysecrets/internal/update" +) + +// Updater finds and installs newer releases (internal/update). +type Updater struct { + Latest func(context.Context) (update.Release, error) + Install func(context.Context, update.Release) error +} + +// updateEvery is how often lazysecrets looks for a newer release on its +// own; within that, the last answer (in the state file) is used. +const updateEvery = 24 * time.Hour + +type updateMsg struct { + release update.Release + err error + asked bool // the user asked (U), rather than the daily check +} + +type installedMsg struct { + release update.Release + err error +} + +// startUpdateCheck sets up the startup look for a newer release: the +// state file's answer when it looked less than a day ago, else a quiet +// look at the server (fetchOnStart, for Init). +func (m *Model) startUpdateCheck() { + if m.updater == nil || !m.updateCheck { + return + } + last := m.store.UpdateCheck() + if m.now().Sub(last.At) < updateEvery { + m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL}) + return + } + m.fetchOnStart = true +} + +func (m Model) fetchLatest(asked bool) tea.Cmd { + latest := m.updater.Latest + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + r, err := latest(ctx) + return updateMsg{release: r, err: err, asked: asked} + } +} + +// offer remembers r as the update to offer, if it's newer. +func (m *Model) offer(r update.Release) { + m.newer = nil + if update.Newer(m.version, r.Tag) { + m.newer = &r + } +} + +func (m *Model) handleUpdate(msg updateMsg) tea.Cmd { + if msg.err != nil { + if msg.asked { + m.flash = flash{text: "couldn't check for a newer lazysecrets: " + msg.err.Error(), isErr: true} + } + return nil // the daily check stays quiet: offline, or the server's down + } + m.store.SetUpdateCheck(store.UpdateCheck{ + At: m.now(), Tag: msg.release.Tag, Notes: msg.release.Body, URL: msg.release.URL, + }) + m.offer(msg.release) + if msg.asked { + if m.newer == nil { + m.flash = done("lazysecrets " + m.versionLabel() + " is the latest (" + msg.release.Tag + ")") + return nil + } + m.showUpdate() + } + return nil +} + +// update is the update key: the newer release, or a look for one. +func (m *Model) update() tea.Cmd { + switch { + case m.updater == nil: + m.flash = warning("updates aren't available in this build") + return nil + case m.newer != nil: + m.showUpdate() + return nil + case !update.Newer(m.version, "v999.0.0"): // dev builds aren't versions + m.flash = warning("this is a development build (" + m.versionLabel() + "): update it with go install or install.sh") + return nil + } + m.flash = flash{text: "looking for a newer lazysecrets…"} + return m.fetchLatest(true) +} + +// showUpdate offers the newer release: its changelog, and updating. +func (m *Model) showUpdate() { + r := *m.newer + lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")} + if notes := r.Notes(); notes != "" { + lines = append(lines, "") + for _, l := range strings.Split(notes, "\n") { + if h, ok := strings.CutPrefix(l, "### "); ok { + l = m.st.Label.Render(h) + } + lines = append(lines, l) + } + } + opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.", + run: func(m *Model) tea.Cmd { return m.install(r) }}} + if r.URL != "" { + opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd { + switch err := m.openURL(r.URL); { + case errors.Is(err, browser.ErrRemote): + cmd := m.copyText("link to the release", r.URL, false) + m.flash.text += " (can't open a browser over SSH)" + return cmd + case err != nil: + m.flash = flash{text: "open: " + err.Error(), isErr: true} + } + return nil + }}) + } + m.showMenu("lazysecrets "+r.Tag, lines, opts) +} + +func (m *Model) install(r update.Release) tea.Cmd { + m.flash = flash{text: "downloading lazysecrets " + r.Tag + "…"} + install := m.updater.Install + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + return installedMsg{release: r, err: install(ctx, r)} + } +} + +func (m *Model) handleInstalled(msg installedMsg) { + if msg.err != nil { + m.flash = flash{text: "update failed: " + msg.err.Error(), isErr: true} + return + } + m.newer = nil + m.updated = msg.release.Tag + m.flash = done("updated to lazysecrets " + msg.release.Tag + " · restart lazysecrets to use it") +} + +// updateLabel is the version's suffix in the footer: a newer release to +// update to, or the one installed and waiting for a restart. +func (m Model) updateLabel() string { + switch { + case m.updated != "": + return " → " + m.updated + " (restart)" + case m.newer != nil: + return " ↑ " + m.newer.Tag + withKey(m.keys.Update, " %s") + } + return "" +} diff --git a/internal/ui/app/update_test.go b/internal/ui/app/update_test.go new file mode 100644 index 0000000..6f75ced --- /dev/null +++ b/internal/ui/app/update_test.go @@ -0,0 +1,118 @@ +package app + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/store" + "git.kebcloud.no/keb/lazysecrets/internal/update" +) + +// fakeUpdater offers latest and records what it installs. +type fakeUpdater struct { + latest update.Release + err error + asked int + installed string +} + +func (f *fakeUpdater) updater() *Updater { + return &Updater{ + Latest: func(context.Context) (update.Release, error) { + f.asked++ + return f.latest, f.err + }, + Install: func(_ context.Context, r update.Release) error { + f.installed = r.Tag + return nil + }, + } +} + +var v020 = update.Release{ + Tag: "v0.2.0", + Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\ncurl …", + URL: "https://git.example.com/keb/lazysecrets/releases/tag/v0.2.0", +} + +func TestUpdate(t *testing.T) { + t.Parallel() + f := &fakeUpdater{latest: v020} + mem, _ := store.Load(filepath.Join(t.TempDir(), "state.json")) + d := newDriverOpts(t, Options{Version: "0.1.1", Updater: f.updater(), UpdateCheck: true, Store: mem}) + if f.asked != 1 { + t.Fatalf("asked %d times at startup", f.asked) + } + if !strings.Contains(d.screen(), "v0.1.1 ↑ v0.2.0 U") { + t.Errorf("no update in the footer:\n%s", d.screen()) + } + + // U: the changelog, without the install notes; u installs. + d.keys("U") + scr := d.screen() + for _, want := range []string{"lazysecrets v0.2.0", "You have v0.1.1.", "Install with a one-liner (#7)", "Update now", "Open the release page"} { + if !strings.Contains(scr, want) { + t.Errorf("missing %q:\n%s", want, scr) + } + } + if strings.Contains(scr, "curl") { + t.Errorf("install notes shown:\n%s", scr) + } + d.keys("u") + if f.installed != "v0.2.0" { + t.Fatalf("installed %q", f.installed) + } + if !strings.Contains(d.screen(), "updated to lazysecrets v0.2.0 · restart lazysecrets to use it") || + !strings.Contains(d.screen(), "→ v0.2.0 (restart)") { + t.Errorf("after updating:\n%s", d.screen()) + } + + // Within a day, the next start uses what it found: no request. + f2 := &fakeUpdater{latest: v020} + d2 := newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, Store: mem}) + if f2.asked != 0 || !strings.Contains(d2.screen(), "↑ v0.2.0") { + t.Errorf("asked %d times within a day:\n%s", f2.asked, d2.screen()) + } + // A day later it asks again. + mem.SetUpdateCheck(store.UpdateCheck{At: time.Now().Add(-25 * time.Hour), Tag: "v0.2.0"}) + newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, Store: mem}) + if f2.asked != 1 { + t.Errorf("asked %d times a day later", f2.asked) + } +} + +func TestUpdateAsked(t *testing.T) { + t.Parallel() + mem, _ := store.Load(filepath.Join(t.TempDir(), "state.json")) + // Not looking on its own: U looks. + f := &fakeUpdater{latest: update.Release{Tag: "v0.1.1"}} + d := newDriverOpts(t, Options{Version: "0.1.1", Updater: f.updater(), Store: mem}) + if f.asked != 0 { + t.Fatal("looked without updateCheck") + } + d.keys("U") + if f.asked != 1 || !strings.Contains(d.screen(), "lazysecrets v0.1.1 is the latest (v0.1.1)") { + t.Errorf("asked %d:\n%s", f.asked, d.screen()) + } + + // The daily check stays quiet when offline; U says why. + f.err = errors.New("no network") + d.keys("U") + if !strings.Contains(d.screen(), "couldn't check for a newer lazysecrets: no network") { + t.Errorf("no error:\n%s", d.screen()) + } + + // A development build isn't offered releases. + dev := newDriverOpts(t, Options{Version: "dev-2ba762c", Updater: (&fakeUpdater{latest: v020}).updater(), UpdateCheck: true, Store: mem}) + if strings.Contains(dev.screen(), "↑") { + t.Errorf("dev build offered an update:\n%s", dev.screen()) + } + dev.keys("U") + if !strings.Contains(dev.screen(), "development build") { + t.Errorf("U on a dev build:\n%s", dev.screen()) + } +} diff --git a/internal/ui/app/usage.go b/internal/ui/app/usage.go new file mode 100644 index 0000000..71c226e --- /dev/null +++ b/internal/ui/app/usage.go @@ -0,0 +1,40 @@ +package app + +import ( + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +// Usage: revealing, copying, opening in the browser, sharing and jumping +// to an item count as using it. The store keeps when each item was last +// used and how often, for the Recent section of the jump list. The config's +// history: false turns this off. + +// used counts a use of the remembered item key, keeping its context. +func (m *Model) used(key string) { + if !m.history { + return + } + _ = m.store.Use(key, m.now(), func(*store.Node) {}) // best effort: never in the way +} + +// usedItem counts a use of an item, recording its context. +func (m *Model) usedItem(key, name string, path []string, src provider.Source) { + if !m.history { + return + } + _ = m.store.Use(key, m.now(), withContext(name, path, src)) +} + +func (m *Model) usedSource(s provider.Source) { + m.usedItem(sourceKey(s), s.Name, nil, s) +} + +func (m *Model) usedContainer(c provider.Container) { + m.usedItem(storeKey(c), c.Name, []string{c.Source.Name}, c.Source) +} + +func (m *Model) usedObject(o provider.Object) { + c := o.Container + m.usedItem(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source) +} diff --git a/internal/ui/app/usage_test.go b/internal/ui/app/usage_test.go new file mode 100644 index 0000000..dbe931f --- /dev/null +++ b/internal/ui/app/usage_test.go @@ -0,0 +1,47 @@ +package app + +import ( + "strings" + "testing" + + "git.kebcloud.no/keb/lazysecrets/internal/store" +) + +func TestRecentScope(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3", "j", "space") // reveal api-key + d.keys("j", "s") // share legacy-token: a use too + d.keys("2", "j", "f") // star kv-shared-prod + + key := "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key" + if n := d.m.store.Node(key); n.Uses != 1 || n.LastUsed.IsZero() || n.Name != "api-key" { + t.Fatalf("reveal not counted: %+v", n) + } + d.keys("R") + var recent []string + for _, o := range d.m.search.shown { + recent = append(recent, ansiStrip(o.label)) + } + if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") { + t.Fatalf("want newest first: %q", recent) + } + d.keys("enter") // jump to legacy-token + if _, _, s := d.selected(); s != "legacy-token" { + t.Errorf("jumped to %q", s) + } + d.keys("F") + if len(d.m.search.shown) != 1 || !strings.Contains(ansiStrip(d.m.search.shown[0].label), "kv-shared-prod") { + t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen()) + } +} + +func TestNoHistory(t *testing.T) { + t.Parallel() + mem, _ := store.Load("") + d := newDriverOpts(t, Options{Store: mem, NoHistory: true}) + d.keys("3", "j", "space", "y") + if len(mem.Nodes()) != 0 { + t.Errorf("recorded with history off: %v", mem.Nodes()) + } +} diff --git a/internal/ui/icons/icons.go b/internal/ui/icons/icons.go new file mode 100644 index 0000000..c726da4 --- /dev/null +++ b/internal/ui/icons/icons.go @@ -0,0 +1,45 @@ +// Package icons maps backends to Nerd Font glyphs. They are on by default +// and can be turned off, since they render as boxes without a Nerd Font +// (v3+) installed. +package icons + +import "git.kebcloud.no/keb/lazysecrets/internal/provider" + +type Set struct { + Azure string + Databricks string + Repo string + Encrypted string // an encrypted file (SOPS) + PlainFile string // a plain text secrets file (.env) +} + +// None renders no icons. +var None = Set{} + +// NerdFont uses Nerd Fonts v3 Material Design glyphs. There is no Databricks +// logo glyph, so it uses stacked layers, which resembles it. +var NerdFont = Set{ + Azure: "\U000F0805", // md-microsoft_azure + Databricks: "\U000F0F58", // md-layers_triple + Repo: "\U000F02A2", // md-git + Encrypted: "\U000F033E", // md-lock + PlainFile: "\U000F0214", // md-file +} + +// Prefix returns the icon for kind followed by a space, or "" if the set has +// no icon for it. +func (s Set) Prefix(kind provider.Kind) string { + var icon string + switch kind { + case provider.KindAzure: + icon = s.Azure + case provider.KindDatabricks: + icon = s.Databricks + case provider.KindRepo: + icon = s.Repo + } + if icon == "" { + return "" + } + return icon + " " +} diff --git a/internal/ui/keys/keys.go b/internal/ui/keys/keys.go new file mode 100644 index 0000000..2b558d9 --- /dev/null +++ b/internal/ui/keys/keys.go @@ -0,0 +1,376 @@ +// Package keys defines the keymap: named actions in contexts, their +// default keys, and overrides from the config: +// +// keybinding: +// lists: +// star: [f] +// versions: [] # unbound +// filter: +// star: [ctrl+f] +// +// An action set in the config gets exactly the keys given, replacing its +// defaults; an empty list unbinds it. Everything that shows a key (the +// help bar, ?, footers, hints) reads it from here. +package keys + +import ( + "fmt" + "slices" + "sort" + "strings" + + "charm.land/bubbles/v2/key" +) + +// Contexts group the actions by where they apply. The search popup takes +// search's actions and popup's. +const ( + Lists = "lists" // the panes, when not typing + Filter = "filter" // typing a filter in a pane + Menu = "menu" // popups without a text field + Popup = "popup" // popups with a text field: favourites, help, search, notes, open link + Search = "search" // Go to's own actions while typing + GoTo = "goto" // Go to while browsing (no text field active) +) + +type Map struct { + // lists + Up, Down, Top, Bottom key.Binding + NextPane, PrevPane key.Binding + Pane1, Pane2, Pane3, Pane4 key.Binding + NextTab, PrevTab key.Binding + Enter, Back, Filter key.Binding + Reveal, Copy, CopyRef, Open key.Binding + Versions, Favourite, Favourites key.Binding + Recent key.Binding + Note, Share, ShareMenu, OpenRef key.Binding + Search, SourcesView, Commands key.Binding + SplitLeft, SplitRight, SplitReset key.Binding + Taller, Shorter, ZoomIn, ZoomOut key.Binding + Login, Refresh, Help, Quit key.Binding + Update key.Binding + // filter + AcceptFilter, ClearFilter key.Binding + FilterUp, FilterDown key.Binding + FilterCopy, FilterReveal, FilterStar key.Binding + // menu + MenuUp, MenuDown, MenuSelect, MenuClose key.Binding + // popup + PopupUp, PopupDown, PopupSelect, PopupClose, PopupStar key.Binding + // search + SearchSources, SearchResults, SearchSwitch key.Binding + SearchLoad, SearchLoadAll key.Binding + // goto + GoDown, GoUp, GoPrevTab, GoNextTab key.Binding + GoSources, GoResults, GoSwitch key.Binding + GoStar, GoLoad, GoLoadAll, GoFilter key.Binding + GoSelect, GoClose key.Binding +} + +// Action is a binding with its name in the config and what it does. +type Action struct { + Context, Name string + Binding *key.Binding + Desc string // short, for the help bar + Group string // section in the ? menu + Tip string // a sentence or two for the ? menu +} + +// Actions lists every action, in the ? menu's order. +func (m *Map) Actions() []Action { + a := func(ctx, name string, b *key.Binding, desc, group, tip string) Action { + return Action{ctx, name, b, desc, group, tip} + } + return []Action{ + a(Lists, "up", &m.Up, "up", "move", "Move up in the focused list, or scroll the detail pane."), + a(Lists, "down", &m.Down, "down", "move", "Move down in the focused list, or scroll the detail pane."), + a(Lists, "top", &m.Top, "top", "move", "Jump to the top of the focused list."), + a(Lists, "bottom", &m.Bottom, "bottom", "move", "Jump to the bottom of the focused list."), + a(Lists, "nextPane", &m.NextPane, "next pane", "move", "Focus the next pane: Sources, Vaults, Secrets, detail."), + a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane."), + a(Lists, "pane1", &m.Pane1, "Sources pane", "move", "Focus the Sources pane."), + a(Lists, "pane2", &m.Pane2, "Vaults pane", "move", "Focus the Vaults (or Scopes) pane."), + a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."), + a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."), + a(Lists, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."), + a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."), + + a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."), + a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."), + a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."), + a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."), + a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."), + a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one, starting in the source you're in. On the left, the scope: Favourites, Recent, All, or one source (○ ◐ ● for how much of it is loaded)."), + a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."), + a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."), + a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."), + a(Lists, "note", &m.Note, "note", "lists", "Write a short note on the selected subscription, vault or secret, shown on the right of its row. Filtering matches notes too."), + + a(Lists, "reveal", &m.Reveal, "reveal/hide", "secrets", "Show or hide the selected value. For keys and certificates: the public key or certificate."), + a(Lists, "copyValue", &m.Copy, "copy value", "secrets", "Copy the selected value without showing it. It is cleared from the clipboard after a countdown."), + a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."), + a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."), + a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."), + + a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."), + a(Lists, "shareMenu", &m.ShareMenu, "share…", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."), + a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."), + + a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."), + a(Lists, "dividerRight", &m.SplitRight, "divider right", "layout", "Move the divider between the left panes and the detail pane to the right."), + a(Lists, "taller", &m.Taller, "taller", "layout", "Make the focused left pane taller."), + a(Lists, "shorter", &m.Shorter, "shorter", "layout", "Make the focused left pane shorter."), + a(Lists, "zoomIn", &m.ZoomIn, "zoom", "layout", "Zoom the focused pane: normal, half screen, full screen."), + a(Lists, "zoomOut", &m.ZoomOut, "unzoom", "layout", "Zoom back out."), + a(Lists, "resetLayout", &m.SplitReset, "reset layout", "layout", "Reset the divider, pane heights and zoom."), + + a(Lists, "refresh", &m.Refresh, "refresh", "other", "Reload the focused pane, bypassing the cache."), + a(Lists, "login", &m.Login, "sign in", "other", "Sign in again (az login or databricks auth login) when a login has expired."), + a(Lists, "update", &m.Update, "update", "other", "Check for a newer lazysecrets: its changelog, and update to it."), + a(Lists, "help", &m.Help, "help", "other", "Show this list. Also works in Go to while browsing, and in menus, showing their keys first."), + a(Lists, "quit", &m.Quit, "quit", "other", "Quit lazysecrets. ctrl+c always quits too."), + + a(Filter, "down", &m.FilterDown, "down", "while filtering", "Move down while typing a filter."), + a(Filter, "up", &m.FilterUp, "up", "while filtering", "Move up while typing a filter."), + a(Filter, "copyValue", &m.FilterCopy, "copy value", "while filtering", "Copy the selected value while typing a filter."), + a(Filter, "reveal", &m.FilterReveal, "reveal/hide", "while filtering", "Reveal or hide the selected value while typing a filter."), + a(Filter, "star", &m.FilterStar, "star", "while filtering", "Star or unstar the selected item while typing a filter."), + a(Filter, "accept", &m.AcceptFilter, "apply filter", "while filtering", "Stop typing and keep the filter."), + a(Filter, "clear", &m.ClearFilter, "clear filter", "while filtering", "Clear the filter."), + + a(Menu, "down", &m.MenuDown, "down", "menus", "Move down in a menu."), + a(Menu, "up", &m.MenuUp, "up", "menus", "Move up in a menu."), + a(Menu, "select", &m.MenuSelect, "select", "menus", "Pick the selected entry. Each entry's letter picks it directly."), + a(Menu, "close", &m.MenuClose, "dismiss", "menus", "Close the menu."), + + a(Popup, "down", &m.PopupDown, "down", "popups", "Move down in a popup with a text field (favourites, help, search)."), + a(Popup, "up", &m.PopupUp, "up", "popups", "Move up in a popup with a text field."), + a(Popup, "select", &m.PopupSelect, "go", "popups", "Go to the selected entry, or confirm what you typed."), + a(Popup, "close", &m.PopupClose, "close", "popups", "Close the popup."), + a(Popup, "star", &m.PopupStar, "star", "popups", "Star or unstar the selected entry in Go to. It stays where it is until Go to opens again."), + + a(Search, "sources", &m.SearchSources, "sources", "search", "Move to the sources list."), + a(Search, "results", &m.SearchResults, "results", "search", "Move to the results list."), + a(Search, "switchList", &m.SearchSwitch, "switch list", "search", "Switch between the sources and the results."), + a(Search, "load", &m.SearchLoad, "load selected", "search", "Load (or reload) the selected source's vaults and secrets, or the selected vault's secrets, in the background."), + a(Search, "loadAll", &m.SearchLoadAll, "load all", "search", "Load every source's vaults and secrets in the background, to search them all."), + + a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."), + a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."), + a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Favourites, Recent, Search."), + a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."), + a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."), + a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."), + a(GoTo, "switchList", &m.GoSwitch, "switch list", "go to", "Switch between the sources and the results (Search tab)."), + a(GoTo, "star", &m.GoStar, "star", "go to", "Star or unstar the selected entry. It stays where it is until Go to opens again."), + a(GoTo, "load", &m.GoLoad, "load selected", "go to", "Load (or reload) the selected source or vault in the background (Search tab)."), + a(GoTo, "loadAll", &m.GoLoadAll, "load all", "go to", "Load everything in the background, to search it all (Search tab)."), + a(GoTo, "filter", &m.GoFilter, "filter", "go to", "Start typing to filter the list."), + a(GoTo, "select", &m.GoSelect, "go", "go to", "Go to the selected entry."), + a(GoTo, "close", &m.GoClose, "close", "go to", "Close Go to."), + } +} + +// defaults follow one rule: a letter in the lists, ctrl+ the same letter +// where you type. +var defaults = map[string][]string{ + "lists.up": {"k", "up", "ctrl+k", "ctrl+p"}, "lists.down": {"j", "down", "ctrl+j", "ctrl+n"}, + "lists.top": {"g", "home"}, "lists.bottom": {"G", "end"}, + "lists.nextPane": {"tab", "l", "right"}, "lists.prevPane": {"shift+tab", "h", "left"}, + "lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, + "lists.enter": {"enter"}, "lists.back": {"esc", "backspace"}, + "lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, + "lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"}, + "lists.commands": {"x"}, + "lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"}, + "lists.versions": {"v"}, "lists.openInBrowser": {"o"}, + "lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"}, + "lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"}, + "lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"}, + "lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="}, + "lists.refresh": {"r"}, "lists.login": {"L"}, "lists.update": {"U"}, "lists.help": {"?"}, "lists.quit": {"q"}, + + "filter.down": {"ctrl+j", "ctrl+n", "down"}, "filter.up": {"ctrl+k", "ctrl+p", "up"}, + "filter.copyValue": {"ctrl+y"}, "filter.reveal": {"ctrl+r"}, "filter.star": {"ctrl+f"}, + "filter.accept": {"enter"}, "filter.clear": {"esc"}, + + "menu.down": {"j", "down", "ctrl+j", "ctrl+n"}, "menu.up": {"k", "up", "ctrl+k", "ctrl+p"}, + "menu.select": {"enter"}, "menu.close": {"esc"}, + + "popup.down": {"ctrl+j", "ctrl+n", "down"}, "popup.up": {"ctrl+k", "ctrl+p", "up"}, + "popup.select": {"enter"}, "popup.close": {"esc"}, "popup.star": {"ctrl+f"}, + + "search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"}, + "search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"}, + + "goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"}, + "goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"}, + "goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"}, + "goto.select": {"enter"}, "goto.close": {"esc", "q"}, +} + +// Default is the keymap without overrides. +func Default() Map { + var m Map + for _, a := range m.Actions() { + *a.Binding = bind(defaults[a.Context+"."+a.Name], a.Desc) + } + m.Login.SetEnabled(false) // shown and active only once a login expires + return m +} + +func bind(keys []string, desc string) key.Binding { + if len(keys) == 0 { + return key.NewBinding(key.WithDisabled()) + } + return key.NewBinding(key.WithKeys(keys...), key.WithHelp(Label(keys), desc)) +} + +// Label shows keys in help: the first two, arrows as arrows, a shared +// ctrl+ written once ("k/↑", "ctrl+j/n"). +func Label(keys []string) string { + pretty := strings.NewReplacer("left", "←", "right", "→", "up", "↑", "down", "↓", "backspace", "⌫") + var out []string + for i, k := range keys[:min(2, len(keys))] { + k = pretty.Replace(k) + if rest, ok := strings.CutPrefix(k, "ctrl+"); ok && i > 0 && strings.HasPrefix(out[i-1], "ctrl+") { + k = rest + } + out = append(out, k) + } + return strings.Join(out, "/") +} + +// Name is the key to mention in a hint ("space to reveal"): the first one +// bound, or "" if the action is unbound. +func Name(b key.Binding) string { + if ks := b.Keys(); len(ks) > 0 { + return Label(ks[:1]) + } + return "" +} + +// Apply overrides the defaults from the config: each action given gets +// exactly the keys listed; none unbinds it. Unknown contexts or actions, +// and a key bound to two actions that apply together, are errors. +func (m *Map) Apply(cfg map[string]map[string][]string) error { + byName := map[string]Action{} + contexts := map[string]bool{} + for _, a := range m.Actions() { + byName[a.Context+"."+a.Name] = a + contexts[a.Context] = true + } + for _, ctx := range sortedKeys(cfg) { + if !contexts[ctx] { + return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", ")) + } + for _, name := range sortedKeys(cfg[ctx]) { + a, ok := byName[ctx+"."+name] + if !ok { + return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", ")) + } + *a.Binding = bind(cfg[ctx][name], a.Desc) + } + } + m.Login.SetEnabled(false) + return m.check() +} + +func (m *Map) names(ctx string) []string { + var out []string + for _, a := range m.Actions() { + if a.Context == ctx { + out = append(out, a.Name) + } + } + sort.Strings(out) + return out +} + +func sortedKeys[V any](set map[string]V) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// check finds a key bound to two actions that apply together: within a +// context, and in the search popup (search and popup). +func (m *Map) check() error { + for _, group := range [][]string{{Lists}, {Filter}, {Menu}, {Popup}, {Search, Popup}, {GoTo}} { + seen := map[string]string{} + for _, a := range m.Actions() { + if !slices.Contains(group, a.Context) { + continue + } + name := a.Context + "." + a.Name + for _, k := range a.Binding.Keys() { + if other, ok := seen[k]; ok && other != name { + return fmt.Errorf("keybinding: %q is bound to both %s and %s", k, other, name) + } + seen[k] = name + } + } + } + return nil +} + +// Section is a titled group of actions for the ? menu. +type Section struct { + Title string + Entries []Action +} + +// Sections groups the actions for the ? menu. +func (m *Map) Sections() []Section { + var out []Section + for _, a := range m.Actions() { + if n := len(out); n == 0 || out[n-1].Title != a.Group { + out = append(out, Section{Title: a.Group}) + } + out[len(out)-1].Entries = append(out[len(out)-1].Entries, a) + } + return out +} + +// FilterHelp is shown while typing a filter. +type FilterHelp struct{ Map } + +func (f FilterHelp) ShortHelp() []key.Binding { + return []key.Binding{f.FilterDown, f.FilterUp, f.FilterCopy, f.FilterReveal, f.FilterStar, f.AcceptFilter, f.ClearFilter} +} + +func (f FilterHelp) FullHelp() [][]key.Binding { return [][]key.Binding{f.ShortHelp()} } + +// YAML is the keymap as a config section, every action with its keys and +// what it does, to copy into the config and edit. +func (m *Map) YAML() string { + var b strings.Builder + b.WriteString("keybinding:\n") + ctx := "" + for _, a := range m.Actions() { + if a.Context != ctx { + ctx = a.Context + fmt.Fprintf(&b, " %s:\n", ctx) + } + quoted := make([]string, len(a.Binding.Keys())) + for i, k := range a.Binding.Keys() { + quoted[i] = fmt.Sprintf("%q", k) + } + fmt.Fprintf(&b, " %-15s [%s] # %s\n", a.Name+":", strings.Join(quoted, ", "), a.Desc) + } + return b.String() +} + +// CheckExtra reports a key some other binding in the lists takes, for keys +// added outside the keymap (custom commands). name says whose key it is. +func (m *Map) CheckExtra(k, name string) error { + for _, a := range m.Actions() { + if a.Context == Lists && slices.Contains(a.Binding.Keys(), k) { + return fmt.Errorf("%s: %q is bound to lists.%s", name, k, a.Name) + } + } + return nil +} diff --git a/internal/ui/keys/keys_test.go b/internal/ui/keys/keys_test.go new file mode 100644 index 0000000..aeec9e2 --- /dev/null +++ b/internal/ui/keys/keys_test.go @@ -0,0 +1,81 @@ +package keys + +import ( + "strings" + "testing" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +func press(s string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: rune(s[0]), Text: s} } + +func TestDefaultsHaveNoConflicts(t *testing.T) { + m := Default() + if err := m.check(); err != nil { + t.Fatal(err) + } + for _, a := range m.Actions() { + if len(a.Binding.Keys()) == 0 { + t.Errorf("%s.%s has no default keys", a.Context, a.Name) + } + } +} + +func TestApply(t *testing.T) { + m := Default() + err := m.Apply(map[string]map[string][]string{ + "lists": {"star": {"z"}, "versions": {}}, + "filter": {"star": {"ctrl+x"}}, + }) + if err != nil { + t.Fatal(err) + } + if !key.Matches(press("z"), m.Favourite) || key.Matches(press("f"), m.Favourite) { + t.Error("star not rebound from f to z") + } + if m.Favourite.Help().Key != "z" { + t.Errorf("help shows %q", m.Favourite.Help().Key) + } + if m.Versions.Enabled() || Name(m.Versions) != "" { + t.Error("versions not unbound") + } + if Name(m.FilterStar) != "ctrl+x" || Name(m.PopupStar) != "ctrl+f" { + t.Errorf("filter star %q, popup star %q", Name(m.FilterStar), Name(m.PopupStar)) + } +} + +func TestApplyErrors(t *testing.T) { + for _, c := range []struct { + cfg map[string]map[string][]string + want string + }{ + {map[string]map[string][]string{"panes": {"star": {"x"}}}, `unknown context "panes"`}, + {map[string]map[string][]string{"lists": {"starr": {"x"}}}, `unknown action "starr"`}, + {map[string]map[string][]string{"lists": {"star": {"s"}}}, `"s" is bound to both`}, // share + {map[string]map[string][]string{"search": {"load": {"ctrl+f"}}}, `"ctrl+f" is bound to both`}, // popup star + } { + m := Default() + if err := m.Apply(c.cfg); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%v: %v, want %q", c.cfg, err, c.want) + } + } + // The same key in contexts that never apply together is fine. + m := Default() + if err := m.Apply(map[string]map[string][]string{"filter": {"star": {"ctrl+r"}, "reveal": {"ctrl+e"}}}); err != nil { + t.Errorf("filter ctrl+r and search ctrl+r clash: %v", err) + } +} + +func TestLabel(t *testing.T) { + for keys, want := range map[string]string{ + "k up": "k/↑", + "ctrl+j ctrl+n down": "ctrl+j/n", + "space": "space", + "ctrl+left <": "ctrl+←/<", + } { + if got := Label(strings.Fields(keys)); got != want { + t.Errorf("Label(%s) = %q, want %q", keys, got, want) + } + } +} diff --git a/internal/ui/panes/box.go b/internal/ui/panes/box.go new file mode 100644 index 0000000..c0b8226 --- /dev/null +++ b/internal/ui/panes/box.go @@ -0,0 +1,71 @@ +// Package panes contains the lazygit-style bordered panes. +package panes + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +// Box draws a rounded border of exactly w×h cells with the title embedded in +// the top edge and an optional footer in the bottom edge. The title is +// rendered as given, so callers style it (see TitleStyle). Body lines are +// truncated or padded to fit; extra lines are dropped. +func Box(st styles.Styles, title, footer string, body []string, w, h int, focused bool) string { + return BoxCounted(st, title, footer, "", body, w, h, focused) +} + +// BoxCounted is Box with a counter like "3 of 12" at the right end of the +// bottom edge, where lazygit puts it. +func BoxCounted(st styles.Styles, title, footer, counter string, body []string, w, h int, focused bool) string { + if w < 4 || h < 2 { + return "" + } + border := st.BorderBlurred + if focused { + border = st.BorderFocused + } + plain := func(s ...string) string { return strings.Join(s, "") } + inner := w - 2 + + edge := func(left, right, label string, styled func(...string) string) string { + if label == "" { + return border.Render(left + strings.Repeat("─", inner) + right) + } + label = ansi.Truncate(" "+label+" ", inner-1, "…") + fill := inner - 1 - ansi.StringWidth(label) + return border.Render(left+"─") + styled(label) + border.Render(strings.Repeat("─", fill)+right) + } + + var b strings.Builder + b.WriteString(edge("╭", "╮", title, plain)) + side := border.Render("│") + for i := range h - 2 { + line := "" + if i < len(body) { + line = ansi.Truncate(body[i], inner, "…") + } + b.WriteString("\n" + side + line + strings.Repeat(" ", inner-ansi.StringWidth(line)) + side) + } + bottom := edge("╰", "╯", footer, border.Render) + if counter != "" { + // Overwrite the end of the bottom edge: "… 3 of 12 ─╯". + label := " " + counter + " " + if keep := w - 2 - ansi.StringWidth(label); keep > 2 { + bottom = ansi.Truncate(bottom, keep, "") + border.Render(label+"─╯") + } + } + b.WriteString("\n" + bottom) + return b.String() +} + +// TitleStyle returns the style for a pane title. +func TitleStyle(st styles.Styles, focused bool) lipgloss.Style { + if focused { + return st.TitleFocused + } + return st.TitleBlurred +} diff --git a/internal/ui/panes/detail.go b/internal/ui/panes/detail.go new file mode 100644 index 0000000..88ca4a4 --- /dev/null +++ b/internal/ui/panes/detail.go @@ -0,0 +1,374 @@ +package panes + +import ( + "cmp" + "fmt" + "maps" + "slices" + "strings" + "time" + + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ui/icons" + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +type ValueState int + +const ( + ValueHidden ValueState = iota + ValueLoading + ValueShown + ValueFailed +) + +// Detail shows metadata for the selected secret (or container) and, on +// request, its value. +type Detail struct { + Now func() time.Time + Icons icons.Set + // RevealKey and VersionsKey are the keys hints mention ("space to + // reveal"); a hint is left out when its key is "" (unbound). + RevealKey, VersionsKey string + + secret *provider.Object + container *provider.Container + + state ValueState + value provider.SecretValue + err error + hidesIn string // when a shown value hides by itself, if it does + + describing bool + describeErr error + + versions []provider.Object // newest first; nil while unknown + note string // the user's note on what's shown + + offset int + w, h int +} + +func NewDetail(iconSet icons.Set) Detail { return Detail{Now: time.Now, Icons: iconSet} } + +// ShowSecret selects a secret and forgets any previously fetched value. +func (d *Detail) ShowSecret(s provider.Object) { + d.Clear() + d.secret = &s + d.container = &s.Container +} + +// ShowContainer shows a container summary when no secret is selected. +func (d *Detail) ShowContainer(c provider.Container) { + d.Clear() + d.container = &c +} + +// Clear drops the selection and any fetched value. +func (d *Detail) Clear() { + *d = Detail{Now: d.Now, Icons: d.Icons, RevealKey: d.RevealKey, VersionsKey: d.VersionsKey, w: d.w, h: d.h} // drops the note too +} + +// SetDescribing marks extra metadata as being fetched. +func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil } + +// SetDescribed replaces the shown object's metadata, keeping the value +// state. It ignores objects other than the one shown. +func (d *Detail) SetDescribed(o provider.Object) { + if d.secret == nil || d.secret.ID != o.ID { + return + } + // Keep the container (and so the account) the object was shown with; + // value requests are routed by it. + o.Container = d.secret.Container + d.secret = &o + d.describing = false +} + +// SetNote shows the user's note on the shown object or container. +func (d *Detail) SetNote(note string) { d.note = note } + +// SetVersions records the shown object's versions, newest first. It +// ignores objects other than the one shown. +func (d *Detail) SetVersions(id string, versions []provider.Object) { + if d.secret != nil && d.secret.ID == id { + d.versions = versions + } +} + +func (d *Detail) SetDescribeError(err error) { d.describing, d.describeErr = false, err } + +func (d Detail) Secret() (provider.Object, bool) { + if d.secret == nil { + return provider.Object{}, false + } + return *d.secret, true +} + +// Container is the container shown, if any. +func (d Detail) Container() (provider.Container, bool) { + if d.container == nil { + return provider.Container{}, false + } + return *d.container, true +} + +// Value returns the value if it is shown. +func (d Detail) Value() (provider.SecretValue, bool) { + return d.value, d.state == ValueShown +} + +func (d Detail) State() ValueState { return d.state } + +func (d *Detail) SetLoading() { d.state, d.err = ValueLoading, nil } + +func (d *Detail) SetValue(v provider.SecretValue) { d.state, d.value, d.err = ValueShown, v, nil } + +func (d *Detail) SetValueError(err error) { d.state, d.err = ValueFailed, err } + +// Hide masks the value again and drops it from memory. +func (d *Detail) Hide() { + d.state, d.value, d.err, d.hidesIn = ValueHidden, provider.SecretValue{}, nil, "" +} + +// SetHidesIn shows when a revealed value hides by itself, e.g. "42s". +func (d *Detail) SetHidesIn(s string) { d.hidesIn = s } + +func (d *Detail) SetSize(w, h int) { + d.w, d.h = w, h + d.Scroll(0) +} + +func (d *Detail) Scroll(delta int) { + total := len(d.lines(styles.Styles{})) + d.offset = max(0, min(d.offset+delta, total-(d.h-2))) +} + +func (d *Detail) ScrollTop() { d.offset = 0 } +func (d *Detail) ScrollBottom() { d.Scroll(1 << 30) } + +func (d Detail) View(st styles.Styles, focused bool, spin string) string { + lines := d.lines(st) + if spin != "" && d.state == ValueLoading { + for i, l := range lines { + lines[i] = strings.Replace(l, spinnerPlaceholder, spin, 1) + } + } + rows := max(0, d.h-2) + footer := "" + if len(lines) > rows { + footer = fmt.Sprintf("%d-%d of %d", d.offset+1, min(d.offset+rows, len(lines)), len(lines)) + } + title := "Details" + if d.secret != nil { + title = string(d.secret.Type) + } + return Box(st, TitleStyle(st, focused).Render(title), footer, lines[min(d.offset, len(lines)):], d.w, d.h, focused) +} + +const spinnerPlaceholder = "\x00spin\x00" + +// lines renders the body, wrapped to the pane width. +func (d Detail) lines(st styles.Styles) []string { + inner := max(1, d.w-3) + var out []string + field := func(label, value string) { + prefix := " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " " + indent := strings.Repeat(" ", 12) + wrapped := strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n") + out = append(out, prefix+wrapped[0]) + for _, w := range wrapped[1:] { + out = append(out, indent+w) + } + } + muted := st.Muted.Render + + if d.container == nil { + return []string{muted(" Select a vault or scope to browse its secrets.")} + } + c := *d.container + containerLabel := map[provider.Kind]string{provider.KindDatabricks: "scope", provider.KindRepo: "file"}[c.Source.Kind] + if containerLabel == "" { + containerLabel = "vault" + } + + if d.secret == nil { + field(containerLabel, c.Name) + if d.note != "" { + field("note", st.Favourite.Render(d.note)) + } + if c.Detail != "" { + field("", muted(c.Detail)) + } + field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) + out = append(out, "", muted(" Select a secret to see its details.")) + return out + } + + s := *d.secret + field("name", s.Name) + if d.note != "" { + field("note", st.Favourite.Render(d.note)) + } + field(containerLabel, c.Name) + if c.Source.Kind != provider.KindAzure && c.Detail != "" { + field("", muted(c.Detail)) + } + field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) + if s.Version != "" { + field("version", s.Version) + } + + if s.Enabled != nil { + if *s.Enabled { + field("enabled", "yes") + } else { + field("enabled", st.Error.Render("no")) + } + } + if !s.Updated.IsZero() { + field("updated", formatTime(s.Updated)) + } + if !s.Expires.IsZero() { + exp := formatTime(s.Expires) + if s.Expires.Before(d.Now()) { + exp = st.Error.Render(exp + " (expired)") + } + field("expires", exp) + } + if s.ContentType != "" { + field("content", s.ContentType) + } + for _, a := range s.Attrs { + field(a.Label, a.Value) + } + if len(s.Tags) > 0 { + var tags []string + for _, k := range slices.Sorted(maps.Keys(s.Tags)) { + tags = append(tags, k+"="+s.Tags[k]) + } + field("tags", strings.Join(tags, ", ")) + } + if s.Reference != "" { + field("ref", muted(s.Reference)) + } + switch { + case d.describing: + out = append(out, muted(" loading details…")) + case d.describeErr != nil: + field("details", st.Error.Render(d.describeErr.Error())) + } + + // Secrets are masked; keys and certificates only expose public material. + keyHint := func(k, what string) string { + if k == "" { + return "" + } + return "(" + k + " to " + what + ")" + } + label, hidden := "value", st.Masked.Render("••••••••")+muted(" "+keyHint(d.RevealKey, "reveal")) + switch s.Type { + case provider.TypeKey: + label, hidden = "public", muted(keyHint(d.RevealKey, "show public key")) + case provider.TypeCertificate: + label, hidden = "cert", muted(keyHint(d.RevealKey, "show certificate")) + } + + out = append(out, "") + switch d.state { + case ValueHidden: + field(label, hidden) + case ValueLoading: + field(label, spinnerPlaceholder+" fetching…") + case ValueFailed: + // Errors can run over several lines (sops lists every key it + // tried): all of them, each wrapped at spaces and styled alone, + // so lines aren't padded to the longest. + for i, line := range strings.Split(strings.TrimRight(d.err.Error(), "\n"), "\n") { + // Keep a line's own indent, on each part it wraps into. + text := strings.TrimLeft(line, " ") + lead := strings.Repeat(" ", len(line)-len(text)) + for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") { + w = lead + w + if i == 0 && j == 0 { + out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w)) + } else { + out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w)) + } + } + } + case ValueShown: + var hints []string + if d.RevealKey != "" { + hints = append(hints, d.RevealKey+" to hide") + } + if d.hidesIn != "" { + hints = append(hints, "hides in "+d.hidesIn) + } + line := " " + st.Label.Render(label) + if len(hints) > 0 { + line += muted(" (" + strings.Join(hints, " · ") + ")") + } + out = append(out, line) + for _, line := range strings.Split(d.value.Reveal(), "\n") { + for _, w := range strings.Split(ansi.Hardwrap(line, inner, true), "\n") { + out = append(out, " "+st.Value.Render(w)) + } + } + } + return append(out, d.versionLines(st)...) +} + +// maxVersionLines caps the version history shown in the detail pane; the +// versions view (v) shows them all. +const maxVersionLines = 10 + +func (d Detail) versionLines(st styles.Styles) []string { + if len(d.versions) == 0 { + return nil + } + muted := st.Muted.Render + head := fmt.Sprintf("%d", len(d.versions)) + if len(d.versions) > 1 { + head = st.Notice.Render(head) + if d.VersionsKey != "" { + head += muted(" · " + d.VersionsKey + " to browse") + } + } + out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head} + current := d.versions[0].Version + for i, v := range d.versions { + if i == maxVersionLines { + out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i))) + break + } + marker := " " + if d.secret.Version != "" && v.Version == d.secret.Version { + marker = "▸ " // the version shown above + } + out = append(out, " "+marker+VersionLine(st, v, d.Now(), current)) + } + return out +} + +// VersionLine draws a version as "id date state". +func VersionLine(st styles.Styles, v provider.Object, now time.Time, current string) string { + id := v.Version + if len(id) > 8 { + id = id[:8] + } + line := fmt.Sprintf("%-8s %s", id, v.Updated.Local().Format("2006-01-02")) + switch { + case v.Enabled != nil && !*v.Enabled: + return st.Muted.Render(line + " disabled") + case !v.Expires.IsZero() && v.Expires.Before(now): + return st.Muted.Render(line + " expired") + case v.Version == current: + return line + st.Notice.Render(" current") + } + return line +} + +func formatTime(t time.Time) string { return t.Local().Format("2006-01-02 15:04") } diff --git a/internal/ui/panes/highlight.go b/internal/ui/panes/highlight.go new file mode 100644 index 0000000..af2f05e --- /dev/null +++ b/internal/ui/panes/highlight.go @@ -0,0 +1,53 @@ +package panes + +import ( + "image/color" + "regexp" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// sgr matches SGR escape sequences. +var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m") + +// Highlight draws a cursor row: line, padded to width, on style's +// background, keeping the colours line already has (a provider's colour, +// a dimmed note), like LazyVim's and lazygit's selected lines; bold +// carries over too. A reverse style is applied to the plain text instead, +// since reversing coloured text would scramble it. +func Highlight(line string, style lipgloss.Style, width int) string { + pad := strings.Repeat(" ", max(0, width-ansi.StringWidth(line))) + if style.GetReverse() { + return style.Render(ansi.Strip(line) + pad) + } + var s ansi.Style + if bg := style.GetBackground(); !isNoColor(bg) { // never nil: NoColor when unset + s = s.BackgroundColor(bg) + } + if style.GetBold() { + s = s.Bold() + } + if len(s) == 0 { + return line + pad + } + on := s.String() + // Every reset in line (a bare reset, one starting with 0, or a + // background reset) would end the highlight: put it back after each. + line = sgr.ReplaceAllStringFunc(line, func(seq string) string { + params := sgr.FindStringSubmatch(seq)[1] + for _, p := range strings.Split(params, ";") { + if p == "" || p == "0" || p == "49" { + return seq + on + } + } + return seq + }) + return on + line + pad + ansi.ResetStyle +} + +func isNoColor(c color.Color) bool { + _, ok := c.(lipgloss.NoColor) + return ok +} diff --git a/internal/ui/panes/list.go b/internal/ui/panes/list.go new file mode 100644 index 0000000..21252b2 --- /dev/null +++ b/internal/ui/panes/list.go @@ -0,0 +1,388 @@ +package panes + +import ( + "fmt" + "strings" + "unicode/utf8" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/match" + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +// Item adapts a value for display in a List. +type Item[T any] struct { + ID func(T) string // stable identity, used to detect selection changes + Filter func(T) string // text matched by the filter, and highlighted + // FilterExtra, if set, is matched too but not shown highlighted, for + // text the row doesn't show (e.g. the provider of a grouped source). + FilterExtra func(T) string + Render func(st styles.Styles, v T, width int) string +} + +// List is a scrollable, filterable list pane with loading and error states. +type List[T any] struct { + Title string + // Tabs, when there are several, are shown after the title; Tab is the + // active one. + Tabs []string + Tab int + Empty string // shown when loaded with no items + Loading bool + Err error + + item Item[T] + items []T + visible []int // indices into items that match the filter, best first + hits [][]int // per visible item: matched rune indices in its Filter text + cursor int // index into visible + offset int // first visible row + w, h int // outer size including border + + input textinput.Model + filtering bool +} + +func NewList[T any](title string, item Item[T]) List[T] { + in := textinput.New() + in.Prompt = "/" + return List[T]{Title: title, Empty: "nothing here", item: item, input: in} +} + +// SetRender changes how items are drawn, e.g. when the pane switches to +// showing versions. +func (l *List[T]) SetRender(render func(st styles.Styles, v T, width int) string) { + l.item.Render = render +} + +// SetItems replaces the contents, keeping the cursor on the same item if it +// is still present. +func (l *List[T]) SetItems(items []T) { + prev := l.SelectedID() + l.items = items + l.Loading = false + l.Err = nil + l.applyFilter(prev) +} + +// Reset clears items, error, filter and cursor. +func (l *List[T]) Reset() { + l.items, l.visible = nil, nil + l.cursor, l.offset = 0, 0 + l.Loading, l.Err = false, nil + l.filtering = false + l.input.Reset() +} + +func (l *List[T]) SetError(err error) { + l.Reset() + l.Err = err +} + +// Refresh marks the pane as loading while keeping the current items and +// selection on screen until SetItems replaces them. +func (l *List[T]) Refresh() { + l.Loading = true + l.Err = nil +} + +func (l *List[T]) SetLoading() { + l.Reset() + l.Loading = true +} + +func (l List[T]) Selected() (T, bool) { + if l.cursor < 0 || l.cursor >= len(l.visible) { + var zero T + return zero, false + } + return l.items[l.visible[l.cursor]], true +} + +// SelectedID is "" when nothing is selected. +func (l List[T]) SelectedID() string { + v, ok := l.Selected() + if !ok { + return "" + } + return l.item.ID(v) +} + +func (l List[T]) Len() int { return len(l.visible) } + +// Width is the pane's outer width. +func (l List[T]) Width() int { return l.w } + +// Items returns all items, ignoring the filter. +func (l List[T]) Items() []T { return l.items } + +// Select moves the cursor to the item with the given ID, if visible, and +// reports whether it was. +func (l *List[T]) Select(id string) bool { + return l.SelectWhere(func(v T) bool { return l.item.ID(v) == id }) +} + +// SelectWhere moves the cursor to the first visible item matching f. +func (l *List[T]) SelectWhere(f func(T) bool) bool { + for i, idx := range l.visible { + if f(l.items[idx]) { + l.cursor = i + l.scroll() + return true + } + } + return false +} + +func (l *List[T]) Move(delta int) { + l.cursor = max(0, min(l.cursor+delta, len(l.visible)-1)) + l.scroll() +} + +func (l *List[T]) Top() { l.Move(-len(l.visible)) } +func (l *List[T]) Bottom() { l.Move(len(l.visible)) } + +// SetSize sets the outer size of the pane, including its border. +func (l *List[T]) SetSize(w, h int) { + l.w, l.h = w, h + l.input.SetWidth(max(1, w-4)) + l.scroll() +} + +// rows is the number of item rows that fit in the pane. +func (l List[T]) rows() int { + r := l.h - 2 + if l.filtering { + r-- + } + return max(0, r) +} + +// Filtering reports whether the filter input has focus. +func (l List[T]) Filtering() bool { return l.filtering } + +// HasFilter reports whether a filter query is applied. +func (l List[T]) HasFilter() bool { return l.input.Value() != "" } + +func (l *List[T]) StartFilter() tea.Cmd { + l.filtering = true + l.scroll() + return l.input.Focus() +} + +// AcceptFilter leaves filter mode, keeping the query applied. +func (l *List[T]) AcceptFilter() { + l.filtering = false + l.input.Blur() +} + +// ClearFilter leaves filter mode and removes the query. +func (l *List[T]) ClearFilter() { + l.filtering = false + l.input.Blur() + prev := l.SelectedID() + l.input.Reset() + l.applyFilter(prev) +} + +// UpdateFilter forwards a message to the filter input and re-filters. +func (l *List[T]) UpdateFilter(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + prev := l.SelectedID() + l.input, cmd = l.input.Update(msg) + l.applyFilter(prev) + return cmd +} + +// applyFilter recomputes the visible items and puts the cursor back on the +// item with ID keep if it is still visible. +func (l *List[T]) applyFilter(keep string) { + l.visible, l.hits = l.visible[:0], nil + if q := l.input.Value(); q == "" { + for i := range l.items { + l.visible = append(l.visible, i) + } + } else { + // The row's own text is the name; FilterExtra (a note, a provider) + // is context, matched too but ranked lower. + items := make([]match.Item, len(l.items)) + for i, v := range l.items { + text := l.item.Filter(v) + items[i] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)} + if l.item.FilterExtra != nil { + if extra := l.item.FilterExtra(v); extra != "" { + items[i].Text += " " + extra + } + } + } + for _, h := range match.Rank(match.Parse(q), items) { + var hits []int // the ones in the text the row shows + for _, p := range h.Pos { + if p < items[h.Index].NameEnd { + hits = append(hits, p) + } + } + l.visible = append(l.visible, h.Index) + l.hits = append(l.hits, hits) + } + } + l.cursor = max(0, min(l.cursor, len(l.visible)-1)) + if keep != "" { + l.Select(keep) + } + l.scroll() +} + +// MarkMatches styles the characters at hits (rune indexes in the text +// shown, without styling) in a styled string, keeping its own colours. +func MarkMatches(styled string, hits []int, style lipgloss.Style) string { + if len(hits) == 0 { + return styled + } + return lipgloss.StyleRanges(styled, matchRanges(ansi.Strip(styled), hits, style)...) +} + +// matchRanges turns matched rune indexes in plain into ranges of screen +// cells, merging neighbours, for lipgloss.StyleRanges. +func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Range { + runes := []rune(plain) + cell := func(i int) int { return ansi.StringWidth(string(runes[:min(i, len(runes))])) } + var out []lipgloss.Range + for i := 0; i < len(hits); { + j := i + for j+1 < len(hits) && hits[j+1] == hits[j]+1 { + j++ + } + if hits[i] < len(runes) { + out = append(out, lipgloss.NewRange(cell(hits[i]), cell(hits[j]+1), style)) + } + i = j + 1 + } + return out +} + +// RuneIndexes converts byte offsets in s to rune offsets. +func RuneIndexes(s string, bytes []int) []int { + out := make([]int, 0, len(bytes)) + for _, b := range bytes { + out = append(out, utf8.RuneCountInString(s[:b])) + } + return out +} + +// highlight returns the rune indexes to highlight in a row's plain text: +// the filter hits, shifted to where the Filter text appears in the row. +// It returns nil if the row doesn't contain the Filter text verbatim. +func (l List[T]) highlight(i int, plain string) []int { + if i >= len(l.hits) { + return nil + } + off := strings.Index(plain, l.item.Filter(l.items[l.visible[i]])) + if off < 0 { + return nil + } + shift := utf8.RuneCountInString(plain[:off]) + out := make([]int, len(l.hits[i])) + for j, h := range l.hits[i] { + out[j] = h + shift + } + return out +} + +// View renders the pane at the size given to SetSize. spin is the current +// spinner frame. +func (l List[T]) View(st styles.Styles, focused bool, spin string) string { + rows := l.rows() + inner := max(0, l.w-2) + + ts := TitleStyle(st, focused) + title := ts.Render(l.Title) + if len(l.Tabs) > 1 { + for i, tab := range l.Tabs { + sep := " - " + if i == 0 { + sep = " " + } + if i == l.Tab { + title += ts.Render(sep) + ts.Underline(true).Render(tab) + } else { + title += ts.Render(sep) + st.Muted.Render(tab) + } + } + } + if q := l.input.Value(); q != "" && !l.filtering { + title += ts.Render(" /" + q) + } + if l.Loading && len(l.items) > 0 { + title += ts.Render(" " + spin) + } + + var body []string + if l.filtering { + body = append(body, l.input.View()) + } + + switch { + case l.Loading && len(l.items) == 0: + body = append(body, " "+spin+" loading…") + case l.Err != nil: + for _, line := range strings.Split(ansi.Wrap(l.Err.Error(), inner-1, " -/"), "\n") { + body = append(body, st.Error.Render(" "+line)) + } + case len(l.visible) == 0 && len(l.items) > 0: + body = append(body, st.Muted.Render(" no matches")) + case len(l.visible) == 0: + body = append(body, st.Muted.Render(" "+l.Empty)) + default: + for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ { + line := l.item.Render(st, l.items[l.visible[i]], inner-1) + plain := ansi.Strip(line) + hits := l.highlight(i, plain) + if i == l.cursor { + base := st.SelectedBlurred + if focused { + base = st.Selected + } + text := " " + line + if len(hits) > 0 { + for j := range hits { + hits[j]++ // leading space + } + text = lipgloss.StyleRanges(text, matchRanges(" "+plain, hits, st.Match)...) + } + line = Highlight(ansi.Truncate(text, inner, "…"), base, inner) + } else if len(hits) > 0 { + // Style just the matches, keeping the row's own colours. + line = " " + lipgloss.StyleRanges(line, matchRanges(plain, hits, st.Match)...) + } else { + line = " " + line + } + body = append(body, line) + } + } + + counter := "" + if len(l.visible) > 0 { + counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible)) + } + return BoxCounted(st, title, "", counter, body, l.w, l.h, focused) +} + +// scroll keeps the cursor within the visible window. +func (l *List[T]) scroll() { + rows := l.rows() + if rows <= 0 { + return + } + if l.cursor < l.offset { + l.offset = l.cursor + } + if l.cursor >= l.offset+rows { + l.offset = l.cursor - rows + 1 + } + l.offset = max(0, min(l.offset, len(l.visible)-rows)) +} diff --git a/internal/ui/panes/list_test.go b/internal/ui/panes/list_test.go new file mode 100644 index 0000000..2b2209d --- /dev/null +++ b/internal/ui/panes/list_test.go @@ -0,0 +1,179 @@ +package panes + +import ( + "fmt" + "reflect" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +func newStringList(n int) List[string] { + l := NewList("Items", Item[string]{ + ID: func(s string) string { return s }, + Filter: func(s string) string { return s }, + Render: func(_ styles.Styles, s string, _ int) string { return s }, + }) + var items []string + for i := range n { + items = append(items, fmt.Sprintf("item-%02d", i)) + } + l.SetItems(items) + l.SetSize(20, 7) // 5 rows + return l +} + +func TestListScrollKeepsCursorVisible(t *testing.T) { + l := newStringList(20) + l.Move(9) + view := ansi.Strip(l.View(styles.Default(), true, "")) + if !strings.Contains(view, "item-09") || strings.Contains(view, "item-04") { + t.Fatalf("cursor row not scrolled into view:\n%s", view) + } + // Moving up within the window must not scroll. + l.Move(-2) + view = ansi.Strip(l.View(styles.Default(), true, "")) + if !strings.Contains(view, "item-05") { + t.Fatalf("window jumped when moving up inside it:\n%s", view) + } + l.Top() + if l.SelectedID() != "item-00" || l.offset != 0 { + t.Fatalf("Top: selected %q offset %d", l.SelectedID(), l.offset) + } + l.Bottom() + if l.SelectedID() != "item-19" || l.offset != 15 { + t.Fatalf("Bottom: selected %q offset %d", l.SelectedID(), l.offset) + } +} + +func TestListSetItemsKeepsSelection(t *testing.T) { + l := newStringList(5) + l.Move(3) + l.SetItems([]string{"new", "item-03", "item-04"}) + if l.SelectedID() != "item-03" { + t.Fatalf("selected = %q", l.SelectedID()) + } + l.SetItems([]string{"other"}) + if l.SelectedID() != "other" { + t.Fatalf("selected = %q after item vanished", l.SelectedID()) + } +} + +func TestBoxExactSize(t *testing.T) { + out := Box(styles.Default(), "a very long title that will not fit", "footer", []string{"x", strings.Repeat("y", 50)}, 12, 5, true) + lines := strings.Split(out, "\n") + if len(lines) != 5 { + t.Fatalf("height = %d", len(lines)) + } + for _, line := range lines { + if w := ansi.StringWidth(line); w != 12 { + t.Errorf("width %d: %q", w, ansi.Strip(line)) + } + } +} + +func filterList(t *testing.T, items []string, query string) List[string] { + t.Helper() + l := NewList("Items", Item[string]{ + ID: func(s string) string { return s }, + Filter: func(s string) string { return s }, + Render: func(_ styles.Styles, s string, _ int) string { return s }, + }) + l.SetItems(items) + l.SetSize(40, 10) + l.StartFilter() + for _, r := range query { + l.UpdateFilter(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + return l +} + +func visibleNames(l List[string]) []string { + var out []string + for _, i := range l.visible { + out = append(out, l.items[i]) + } + return out +} + +func TestFuzzyFilterRanksAndSkips(t *testing.T) { + l := filterList(t, []string{"api-key", "db-password", "storage-key", "dbx-pat"}, "dbpw") + if got := visibleNames(l); !reflect.DeepEqual(got, []string{"db-password"}) { + t.Errorf("dbpw matched %v", got) + } + l = filterList(t, []string{"monkey", "key-vault", "api-key"}, "key") + if got := visibleNames(l); got[0] != "key-vault" || len(got) != 3 { + t.Errorf("prefix match not ranked first: %v", got) + } +} + +func TestFilterHighlightsMatches(t *testing.T) { + l := filterList(t, []string{"db-password", "api-key"}, "dpw") + l.AcceptFilter() + view := l.View(styles.Default(), false, "") + // d, p and w are styled; with ANSI stripped the text is unchanged. + if !strings.Contains(ansi.Strip(view), "db-password") { + t.Fatalf("row missing:\n%s", ansi.Strip(view)) + } + if hits := l.highlight(0, "db-password"); !reflect.DeepEqual(hits, []int{0, 3, 7}) { + t.Errorf("hits = %v", hits) + } + // Rows that show the Filter text at an offset get shifted hits. + if hits := l.highlight(0, " db-password (disabled)"); !reflect.DeepEqual(hits, []int{2, 5, 9}) { + t.Errorf("shifted hits = %v", hits) + } +} + +func TestFilterKeepsRowColours(t *testing.T) { + blue := lipgloss.NewStyle().Foreground(lipgloss.Blue) + match := styles.Default().Match + + if got := matchRanges("platform-dev", []int{9, 10, 11}, match); len(got) != 1 || got[0].Start != 9 || got[0].End != 12 { + t.Errorf("ranges = %+v, want one range 9-12", got) + } + + // Only the matches change style; the rest keeps the row's colour. + styled := " " + blue.Render("platform-dev") + out := lipgloss.StyleRanges(styled, matchRanges(" platform-dev", []int{11, 12, 13}, match)...) + if ansi.Strip(out) != " platform-dev" { + t.Fatalf("text changed: %q", ansi.Strip(out)) + } + if !strings.Contains(out, "\x1b[34m") { + t.Errorf("unmatched text lost its colour: %q", out) + } +} + +func TestCounterInBottomRight(t *testing.T) { + out := BoxCounted(styles.Default(), "T", "footer", "3 of 12", []string{"x"}, 30, 3, true) + bottom := ansi.Strip(strings.Split(out, "\n")[2]) + if !strings.HasSuffix(bottom, " 3 of 12 ─╯") || !strings.HasPrefix(bottom, "╰─ footer ") || ansi.StringWidth(bottom) != 30 { + t.Errorf("bottom edge = %q", bottom) + } +} + +func TestHighlightKeepsColours(t *testing.T) { + blue := lipgloss.NewStyle().Foreground(lipgloss.Blue) + sel := lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true) + row := " " + blue.Render("platform-prod") + lipgloss.NewStyle().Faint(true).Render(" note") + out := Highlight(row, sel, 30) + if ansi.StringWidth(out) != 30 || ansi.Strip(out) != ansi.Strip(row)+strings.Repeat(" ", 30-ansi.StringWidth(row)) { + t.Fatalf("text or width changed: %q", ansi.Strip(out)) + } + if !strings.Contains(out, "\x1b[34m") { + t.Error("row lost its blue") + } + // The background is re-applied after every reset, so it spans the row. + resets := strings.Count(out, "\x1b[m") + strings.Count(out, "\x1b[0m") + if bgs := strings.Count(out, "\x1b[100;1m"); bgs < resets { + t.Errorf("background not re-applied after each reset: %d resets, %d backgrounds\n%q", resets, bgs, out) + } + // Reverse styles fall back to plain text. + if got := Highlight(row, lipgloss.NewStyle().Reverse(true), 30); strings.Contains(got, "34m") { + t.Error("reverse kept the colours") + } +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go new file mode 100644 index 0000000..ba0200c --- /dev/null +++ b/internal/ui/styles/styles.go @@ -0,0 +1,76 @@ +// Package styles holds the lipgloss theme. It sticks to the 16 ANSI colours +// so it follows the user's terminal palette in both light and dark themes. +package styles + +import ( + "charm.land/lipgloss/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +type Styles struct { + BorderFocused lipgloss.Style + BorderBlurred lipgloss.Style + TitleFocused lipgloss.Style + TitleBlurred lipgloss.Style + Footer lipgloss.Style + + Selected lipgloss.Style // cursor row in the focused pane + SelectedBlurred lipgloss.Style // cursor row in other panes + Muted lipgloss.Style + Error lipgloss.Style + Notice lipgloss.Style + Success lipgloss.Style // the ✓ before confirmations + Warning lipgloss.Style // "can't do that here" messages + Match lipgloss.Style // filter matches + Favourite lipgloss.Style + Prompt lipgloss.Style // questions in the footer + Label lipgloss.Style + Masked lipgloss.Style + Value lipgloss.Style + + KindAzure lipgloss.Style + KindDatabricks lipgloss.Style + KindRepo lipgloss.Style +} + +func Default() Styles { + green := lipgloss.Green + return Styles{ + BorderFocused: lipgloss.NewStyle().Foreground(green), + BorderBlurred: lipgloss.NewStyle().Faint(true), + TitleFocused: lipgloss.NewStyle().Foreground(green).Bold(true), + TitleBlurred: lipgloss.NewStyle(), + Footer: lipgloss.NewStyle().Faint(true), + + // A background rather than reverse, so the row keeps its colours. + Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true), + SelectedBlurred: lipgloss.NewStyle().Bold(true), + Muted: lipgloss.NewStyle().Faint(true), + Error: lipgloss.NewStyle().Foreground(lipgloss.Red), + Notice: lipgloss.NewStyle().Foreground(lipgloss.Green), + Success: lipgloss.NewStyle().Foreground(lipgloss.Green).Bold(true), + Warning: lipgloss.NewStyle().Foreground(lipgloss.Yellow), + Match: lipgloss.NewStyle().Foreground(lipgloss.Yellow).Bold(true), + Favourite: lipgloss.NewStyle().Foreground(lipgloss.Yellow), + Prompt: lipgloss.NewStyle().Foreground(lipgloss.Yellow).Bold(true), + Label: lipgloss.NewStyle().Foreground(lipgloss.Blue).Bold(true), + Masked: lipgloss.NewStyle().Faint(true), + Value: lipgloss.NewStyle().Foreground(lipgloss.Yellow), + + KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue), + KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red), + KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta), + } +} + +// Kind is the colour of a provider's sources, vaults and paths. +func (s Styles) Kind(k provider.Kind) lipgloss.Style { + switch k { + case provider.KindDatabricks: + return s.KindDatabricks + case provider.KindRepo: + return s.KindRepo + } + return s.KindAzure +} diff --git a/internal/ui/styles/theme.go b/internal/ui/styles/theme.go new file mode 100644 index 0000000..0ca2ade --- /dev/null +++ b/internal/ui/styles/theme.go @@ -0,0 +1,198 @@ +package styles + +import ( + "fmt" + "image/color" + "slices" + "sort" + "strconv" + "strings" + + "charm.land/lipgloss/v2" +) + +// A theme maps roles to a colour and attributes, like lazygit's gui.theme: +// +// activeBorderColor: ["#e0af68", bold] +// selectedLineBgColor: ["#2d3f76"] +// +// Role names follow lazygit where the meaning is the same, so an editor +// plugin can map them from highlight groups the way snacks.nvim does for +// lazygit. Colours are hex (#rrggbb), a palette number (0-255) or a name +// (black, red, green, yellow, blue, magenta, cyan, white, their bright +// variants like brightred, or default). Attributes: bold, italic, +// underline, reverse, faint. + +// Roles lists the theme roles with what they colour. +var Roles = map[string]string{ + "activeBorderColor": "border and title of the focused pane", + "inactiveBorderColor": "borders of other panes", + "selectedLineBgColor": "background of the cursor row in the focused pane", + "inactiveViewSelectedLineBgColor": "background of the cursor row in other panes", + "defaultFgColor": "default text colour", + "optionsTextColor": "keys in the help bar and menus, and labels in the detail pane", + "searchMatchColor": "matched characters when filtering", + "errorColor": "✗ errors", + "noticeColor": "notices, the clipboard countdown and current versions", + "successColor": "the ✓ before confirmations (copied, opened…)", + "warningColor": "⚠ messages: what can't be done here, and why", + "mutedColor": "secondary text", + "favouriteColor": "stars and questions", + "valueColor": "revealed values", + "azureColor": "Azure sources and vaults", + "databricksColor": "Databricks workspaces and scopes", + "repoColor": "repos and their secret files (SOPS)", +} + +// FromTheme returns the default styles with the theme's roles applied, and +// the default text colour if the theme sets one. +func FromTheme(theme map[string][]string) (Styles, color.Color, error) { + st := Default() + var fg color.Color + roles := make([]string, 0, len(theme)) + for r := range theme { + roles = append(roles, r) + } + sort.Strings(roles) + for _, role := range roles { + if _, ok := Roles[role]; !ok { + return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", ")) + } + v, err := parse(theme[role]) + if err != nil { + return st, nil, fmt.Errorf("theme: %s: %w", role, err) + } + switch role { + case "activeBorderColor": + st.BorderFocused = v.fg(lipgloss.NewStyle()) + st.TitleFocused = v.fg(lipgloss.NewStyle()).Bold(true) + case "inactiveBorderColor": + st.BorderBlurred = v.fg(lipgloss.NewStyle()) + case "selectedLineBgColor": + st.Selected = v.bg(lipgloss.NewStyle()).Bold(true) + case "inactiveViewSelectedLineBgColor": + st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true) + case "defaultFgColor": + fg = v.color + case "optionsTextColor": + st.Label = v.fg(lipgloss.NewStyle()) + case "searchMatchColor": + st.Match = v.fg(lipgloss.NewStyle()) + case "errorColor": + st.Error = v.fg(lipgloss.NewStyle()) + case "noticeColor": + st.Notice = v.fg(lipgloss.NewStyle()) + case "warningColor": + st.Warning = v.fg(lipgloss.NewStyle()) + case "successColor": + st.Success = v.fg(lipgloss.NewStyle()).Bold(true) + case "mutedColor": + st.Muted = v.fg(lipgloss.NewStyle()) + st.Masked, st.Footer = st.Muted, st.Muted + case "favouriteColor": + st.Favourite = v.fg(lipgloss.NewStyle()) + st.Prompt = v.fg(lipgloss.NewStyle()).Bold(true) + case "valueColor": + st.Value = v.fg(lipgloss.NewStyle()) + case "azureColor": + st.KindAzure = v.fg(lipgloss.NewStyle()) + case "databricksColor": + st.KindDatabricks = v.fg(lipgloss.NewStyle()) + case "repoColor": + st.KindRepo = v.fg(lipgloss.NewStyle()) + } + } + return st, fg, nil +} + +func sortedRoles() []string { + var out []string + for r := range Roles { + out = append(out, r) + } + slices.Sort(out) + return out +} + +// value is a parsed role: a colour (nil for default) and attributes. +type value struct { + color color.Color + bold, italic, underline, reverse, faint bool +} + +// fg styles s with the value as foreground, bg as background. +func (v value) fg(s lipgloss.Style) lipgloss.Style { + if v.color != nil { + s = s.Foreground(v.color) + } + return v.attrs(s) +} + +func (v value) bg(s lipgloss.Style) lipgloss.Style { + if v.color != nil { + s = s.Background(v.color) + } + return v.attrs(s) +} + +func (v value) attrs(s lipgloss.Style) lipgloss.Style { + return s.Bold(v.bold).Italic(v.italic).Underline(v.underline).Reverse(v.reverse).Faint(v.faint) +} + +var names = map[string]color.Color{ + "black": lipgloss.Black, "red": lipgloss.Red, "green": lipgloss.Green, "yellow": lipgloss.Yellow, + "blue": lipgloss.Blue, "magenta": lipgloss.Magenta, "cyan": lipgloss.Cyan, "white": lipgloss.White, + "brightblack": lipgloss.BrightBlack, "brightred": lipgloss.BrightRed, "brightgreen": lipgloss.BrightGreen, + "brightyellow": lipgloss.BrightYellow, "brightblue": lipgloss.BrightBlue, + "brightmagenta": lipgloss.BrightMagenta, "brightcyan": lipgloss.BrightCyan, "brightwhite": lipgloss.BrightWhite, +} + +func parse(items []string) (value, error) { + var v value + for _, it := range items { + s := strings.ToLower(strings.TrimSpace(it)) + switch s { + case "bold": + v.bold = true + case "italic": + v.italic = true + case "underline": + v.underline = true + case "reverse": + v.reverse = true + case "faint", "dim": + v.faint = true + case "default", "": + // the terminal's own colour + default: + c, err := parseColor(s) + if err != nil { + return v, err + } + v.color = c + } + } + return v, nil +} + +func parseColor(s string) (color.Color, error) { + if c, ok := names[s]; ok { + return c, nil + } + if strings.HasPrefix(s, "#") { + if len(s) != 7 { + return nil, fmt.Errorf("%q: want #rrggbb", s) + } + if _, err := strconv.ParseUint(s[1:], 16, 32); err != nil { + return nil, fmt.Errorf("%q: want #rrggbb", s) + } + return lipgloss.Color(s), nil + } + if n, err := strconv.Atoi(s); err == nil { + if n < 0 || n > 255 { + return nil, fmt.Errorf("%q: palette numbers are 0-255", s) + } + return lipgloss.Color(s), nil + } + return nil, fmt.Errorf("%q isn't a colour or attribute", s) +} diff --git a/internal/ui/styles/theme_test.go b/internal/ui/styles/theme_test.go new file mode 100644 index 0000000..fe07e03 --- /dev/null +++ b/internal/ui/styles/theme_test.go @@ -0,0 +1,64 @@ +package styles + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" +) + +func TestFromTheme(t *testing.T) { + st, fg, err := FromTheme(map[string][]string{ + "activeBorderColor": {"#e0af68", "bold"}, + "selectedLineBgColor": {"#2d3f76"}, + "defaultFgColor": {"#c8d3f5"}, + "azureColor": {"75"}, + "databricksColor": {"brightred"}, + "searchMatchColor": {"default", "underline"}, + }) + if err != nil { + t.Fatal(err) + } + if fg == nil { + t.Error("defaultFgColor not returned") + } + if got := st.BorderFocused.GetForeground(); got != lipgloss.Color("#e0af68") || !st.BorderFocused.GetBold() { + t.Errorf("active border = %v bold=%v", got, st.BorderFocused.GetBold()) + } + // The theme's selected-line background replaces the default one. + if st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.Color("#2d3f76") { + t.Errorf("selected = reverse %v bg %v", st.Selected.GetReverse(), st.Selected.GetBackground()) + } + if st.KindAzure.GetForeground() != lipgloss.Color("75") || st.KindDatabricks.GetForeground() != lipgloss.BrightRed { + t.Error("provider colours not applied") + } + if !st.Match.GetUnderline() { + t.Error("attribute without colour not applied") + } + // Roles the theme leaves out keep their defaults. + if st.Error.GetForeground() != lipgloss.Red { + t.Error("default error colour changed") + } +} + +func TestFromThemeRejectsMistakes(t *testing.T) { + for name, theme := range map[string]map[string][]string{ + "role": {"activeBorder": {"red"}}, + "colour": {"errorColor": {"redish"}}, + "hex": {"errorColor": {"#fff"}}, + "palette": {"errorColor": {"300"}}, + } { + if _, _, err := FromTheme(theme); err == nil { + t.Errorf("%s: no error", name) + } else if name == "role" && !strings.Contains(err.Error(), "activeBorderColor") { + t.Errorf("unknown role error should list the known ones: %v", err) + } + } +} + +func TestEmptyThemeIsDefault(t *testing.T) { + st, fg, err := FromTheme(nil) + if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack { + t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground()) + } +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..8c62532 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,246 @@ +// Package update finds newer lazysecrets releases and installs one in +// place of the running binary: the archive for this OS and CPU, checked +// against the release's checksums.txt, tried (--version), then renamed +// over the binary. A running lazysecrets keeps its old binary until it +// restarts. +package update + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +// Where releases are published. +const ( + DefaultAPI = "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets" + DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets" +) + +// maxDownload bounds what's read from the server. +const maxDownload = 200 << 20 + +// Release is a published release. +type Release struct { + Tag string `json:"tag_name"` + Body string `json:"body"` // the changelog section, then install notes + URL string `json:"html_url"` +} + +// Notes is the release's changelog: its body without the install notes +// every release ends with. +func (r Release) Notes() string { + notes, _, _ := strings.Cut(r.Body, "## Install") + return strings.TrimSpace(notes) +} + +// Client talks to the release server. +type Client struct { + API string // the repository's API, DefaultAPI when empty + Repo string // the repository's web address, DefaultRepo when empty + HTTP *http.Client +} + +func (c *Client) api() string { return cmpOr(c.API, DefaultAPI) } +func (c *Client) repo() string { return cmpOr(c.Repo, DefaultRepo) } + +func (c *Client) client() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: 2 * time.Minute} +} + +func cmpOr(a, b string) string { + if a != "" { + return a + } + return b +} + +// Latest is the newest release (pre-releases aside). +func (c *Client) Latest(ctx context.Context) (Release, error) { + b, err := c.get(ctx, c.api()+"/releases/latest", 1<<20) + if err != nil { + return Release{}, err + } + var r Release + if err := json.Unmarshal(b, &r); err != nil { + return Release{}, fmt.Errorf("reading the latest release: %w", err) + } + if !semver.IsValid(r.Tag) { + return Release{}, fmt.Errorf("latest release %q isn't a version", r.Tag) + } + return r, nil +} + +// Newer reports whether latest is newer than current. A build that isn't +// a version (dev-…) is never offered one. +func Newer(current, latest string) bool { + current, latest = canonical(current), canonical(latest) + return semver.IsValid(current) && semver.IsValid(latest) && semver.Compare(latest, current) > 0 +} + +func canonical(v string) string { + if v != "" && !strings.HasPrefix(v, "v") { + v = "v" + v + } + return v +} + +// Managed says which package manager owns exe, if one does: it updates +// lazysecrets there, not lazysecrets itself. +func Managed(exe string) string { + switch { + case strings.Contains(exe, "/Cellar/") || strings.HasPrefix(exe, "/opt/homebrew/") || strings.Contains(exe, "/linuxbrew/"): + return "Homebrew (brew upgrade lazysecrets)" + case strings.HasPrefix(exe, "/nix/store/"): + return "Nix" + case strings.HasPrefix(exe, "/usr/bin/") || strings.HasPrefix(exe, "/bin/"): + return "your system's package manager" + } + return "" +} + +// Executable is the running binary's real path. +func Executable() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + return filepath.EvalSymlinks(exe) +} + +// Install replaces the binary at exe with r's, for this OS and CPU. +func (c *Client) Install(ctx context.Context, r Release, exe string) error { + if m := Managed(exe); m != "" { + return fmt.Errorf("%s is managed by %s: update it there", exe, m) + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" || runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + return fmt.Errorf("no release build for %s/%s", runtime.GOOS, runtime.GOARCH) + } + version := strings.TrimPrefix(r.Tag, "v") + name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH) + base := c.repo() + "/releases/download/" + r.Tag + "/" + + sums, err := c.get(ctx, base+"checksums.txt", 1<<20) + if err != nil { + return err + } + want, err := checksum(sums, name+".tar.gz") + if err != nil { + return err + } + archive, err := c.get(ctx, base+name+".tar.gz", maxDownload) + if err != nil { + return err + } + if got := sha256.Sum256(archive); hex.EncodeToString(got[:]) != want { + return fmt.Errorf("%s.tar.gz doesn't match checksums.txt: damaged, or not the release", name) + } + bin, err := extract(archive, path.Join(name, "lazysecrets")) + if err != nil { + return err + } + + // Next to the binary, so the rename that replaces it is atomic. + dir := filepath.Dir(exe) + tmp, err := os.CreateTemp(dir, ".lazysecrets-update-*") + if err != nil { + return fmt.Errorf("can't write next to %s (%w): reinstall it with install.sh, or update as the user who owns it", exe, err) + } + defer os.Remove(tmp.Name()) // gone after the rename; cleans up on failure + if _, err := tmp.Write(bin); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmp.Name(), 0o755); err != nil { + return err + } + // It must run, and be the version asked for, before it replaces + // anything. + out, err := exec.CommandContext(ctx, tmp.Name(), "--version").Output() + if err != nil || !strings.Contains(string(out), version) { + return fmt.Errorf("the downloaded %s doesn't run as expected (%q, %v)", r.Tag, strings.TrimSpace(string(out)), err) + } + if err := os.Rename(tmp.Name(), exe); err != nil { + return fmt.Errorf("replacing %s: %w", exe, err) + } + return nil +} + +func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := c.client().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s: %s", url, resp.Status) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > limit { + return nil, fmt.Errorf("%s: larger than expected", url) + } + return b, nil +} + +// checksum finds file's sha256 in a checksums.txt. +func checksum(sums []byte, file string) (string, error) { + sc := bufio.NewScanner(bytes.NewReader(sums)) + for sc.Scan() { + if f := strings.Fields(sc.Text()); len(f) == 2 && f[1] == file { + return f[0], nil + } + } + return "", fmt.Errorf("%s isn't in checksums.txt", file) +} + +// extract reads one file from a .tar.gz. +func extract(archive []byte, name string) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, err + } + tr := tar.NewReader(gz) + for { + h, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil, fmt.Errorf("%s isn't in the archive", name) + } + if err != nil { + return nil, err + } + if h.Name == name && h.Typeflag == tar.TypeReg { + return io.ReadAll(io.LimitReader(tr, maxDownload)) + } + } +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..61d9f2d --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,125 @@ +package update + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestNewer(t *testing.T) { + for _, c := range []struct { + current, latest string + want bool + }{ + {"0.1.1", "v0.2.0", true}, + {"v0.2.0", "v0.2.0", false}, + {"0.3.0", "v0.2.0", false}, + {"v0.1.2-0.20261009185704-2ba762ce2224", "v0.1.1", false}, // go install of a later commit + {"v0.1.2-0.20261009185704-2ba762ce2224", "v0.1.2", true}, + {"dev-2ba762c", "v0.2.0", false}, + {"dev", "v0.2.0", false}, + {"", "v0.2.0", false}, + } { + if got := Newer(c.current, c.latest); got != c.want { + t.Errorf("Newer(%q, %q) = %v", c.current, c.latest, got) + } + } +} + +func TestNotes(t *testing.T) { + r := Release{Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\nOn macOS…"} + if got := r.Notes(); got != "### Added\n\n- Install with a one-liner (#7)" { + t.Errorf("notes %q", got) + } +} + +func TestManaged(t *testing.T) { + if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" { + t.Error("package managers not recognised") + } + if m := Managed("/Users/x/.local/bin/lazysecrets"); m != "" { + t.Errorf("~/.local/bin is ours: %q", m) + } +} + +// server publishes v0.2.0 with a "binary" (a script) printing its version. +func server(t *testing.T, tamper bool) *httptest.Server { + t.Helper() + name := fmt.Sprintf("lazysecrets_0.2.0_%s_%s", runtime.GOOS, runtime.GOARCH) + var archive bytes.Buffer + gz := gzip.NewWriter(&archive) + tw := tar.NewWriter(gz) + script := []byte("#!/bin/sh\necho lazysecrets 0.2.0\n") + tw.WriteHeader(&tar.Header{Name: name + "/lazysecrets", Mode: 0o755, Size: int64(len(script)), Typeflag: tar.TypeReg}) + tw.Write(script) + tw.Close() + gz.Close() + sum := sha256.Sum256(archive.Bytes()) + if tamper { + sum[0] ^= 0xff + } + mux := http.NewServeMux() + mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`) + }) + mux.HandleFunc("/repo/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name) + }) + mux.HandleFunc("/repo/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) { + w.Write(archive.Bytes()) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestInstall(t *testing.T) { + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skip("releases are for macOS and Linux") + } + srv := server(t, false) + c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"} + r, err := c.Latest(context.Background()) + if err != nil || r.Tag != "v0.2.0" { + t.Fatalf("latest %+v %v", r, err) + } + exe := filepath.Join(t.TempDir(), "lazysecrets") + os.WriteFile(exe, []byte("old"), 0o755) + if err := c.Install(context.Background(), r, exe); err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(exe); !strings.Contains(string(b), "echo lazysecrets 0.2.0") { + t.Errorf("not replaced: %q", b) + } + if left, _ := filepath.Glob(filepath.Join(filepath.Dir(exe), ".lazysecrets-update-*")); len(left) > 0 { + t.Errorf("left behind: %v", left) + } +} + +func TestInstallRefusesATamperedArchive(t *testing.T) { + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skip("releases are for macOS and Linux") + } + srv := server(t, true) + c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"} + exe := filepath.Join(t.TempDir(), "lazysecrets") + os.WriteFile(exe, []byte("old"), 0o755) + err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe) + if err == nil || !strings.Contains(err.Error(), "doesn't match checksums.txt") { + t.Fatalf("err %v", err) + } + if b, _ := os.ReadFile(exe); string(b) != "old" { + t.Error("replaced anyway") + } +} diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..5c95d3b --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Releases main: writes CHANGELOG.md with git-cliff (cliff.toml), commits +# it, tags the commit and pushes both; the tag runs +# .gitea/workflows/release.yml, which builds and publishes the release. +# +# scripts/release.sh v0.2.0 # or v0.2.0-rc.1 for a pre-release +# scripts/release.sh --dry-run v0.2.0 # print the notes, change nothing +# +# A pre-release is tagged without touching CHANGELOG.md: its changes are +# listed under the release that follows. +set -euo pipefail + +dry=false +if [ "${1:-}" = "--dry-run" ]; then + dry=true + shift +fi +tag="${1:?usage: $0 [--dry-run] }" +if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "$tag is not vMAJOR.MINOR.PATCH (optionally -suffix)" >&2 + exit 1 +fi +command -v git-cliff >/dev/null || { echo "git-cliff is needed: brew install git-cliff" >&2; exit 1; } + +cd "$(dirname "$0")/.." + +notes=$(git cliff --unreleased --tag "$tag" --strip all) +if ! grep -q '^- ' <<<"$notes"; then + echo "Nothing for the changelog since the last release (no feat/fix/perf pull requests)." >&2 + $dry || exit 1 +fi +echo "$notes" +$dry && exit 0 + +if [ "$(git branch --show-current)" != main ]; then + echo "Release from main." >&2 + exit 1 +fi +if [ -n "$(git status --porcelain)" ]; then + echo "The working tree has changes; commit or stash them first." >&2 + exit 1 +fi +git fetch -q origin main --tags +if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then + echo "main isn't origin/main; pull (or push) first." >&2 + exit 1 +fi +if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + echo "$tag exists already." >&2 + exit 1 +fi + +echo +read -r -p "Release $tag with these notes? [y/N] " ok +[ "$ok" = y ] || [ "$ok" = Y ] || exit 1 + +if [[ "$tag" != *-* ]]; then + git cliff --tag "$tag" -o CHANGELOG.md + git add CHANGELOG.md + git commit -q -m "chore(release): $tag" +fi +git tag -a "$tag" -m "$tag" +git push -q origin main "$tag" +echo "Pushed $tag: the release workflow builds and publishes it."