commit f64ec47d717a12db79afb49498dd1d9c1f55c1a1 Author: Karl-Einar Bergstrøm Date: Mon Oct 12 08:48:23 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..7619357 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,95 @@ +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), then copies it to the GitHub +# mirror. Pushing the same tag again (after deleting it) replaces the +# release in both places. +# +# 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 }} + + - name: Copy the release to GitHub + # The same notes and files on the GitHub mirror, which lazysecrets + # updates from first (internal/update). GH_RELEASE_TOKEN is a + # fine-grained token that can write the mirror's contents; without + # it the step does nothing. + run: node scripts/github-release.mjs + env: + TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..e5efb65 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,46 @@ +name: Bug report +description: Something in lazysecrets doesn't work as it should. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for reporting! Please leave out secret values, tokens and anything else private: names of vaults and secrets are enough. + - type: textarea + id: what + attributes: + label: What happened + description: What you did, what you expected, and what happened instead. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: If you can, try `lazysecrets --demo` first. + placeholder: | + 1. lazysecrets --demo + 2. ctrl+s, type … + - type: input + id: version + attributes: + label: Version + description: The status pane shows it, e.g. `v0.1.0`. + validations: + required: true + - type: dropdown + id: source + attributes: + label: Where + multiple: true + options: + - Azure Key Vault + - Databricks + - SOPS or .env files + - Links + - Everywhere / not sure + - type: input + id: platform + attributes: + label: OS and terminal + placeholder: macOS 26, kitty + tmux diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ceaf9b1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Docs + url: https://git.kebcloud.no/keb/lazysecrets/src/branch/main/docs + about: Keys, sources, links, config and more. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..66e8eb7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,20 @@ +name: Feature request +description: An idea for lazysecrets, or something that could work better. +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + lazysecrets stays focused on finding, reading and sharing secrets, so not every idea will make it in, but they're all read. + - type: textarea + id: problem + attributes: + label: What you're trying to do + description: The problem or the situation, more than the solution. + validations: + required: true + - type: textarea + id: idea + attributes: + label: Your idea + description: How you'd like it to work, if you have one in mind. 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..b13c8a8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +Every release of lazysecrets, newest first. + +## v0.1.0 – 2026-10-12 + +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..c2282f1 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# lazysecrets + +A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic lazygit. + +![lazysecrets](docs/assets/lazysecrets.png) + +## Main features + +- **Read-only**: lazysecrets never creates, changes or deletes a secret. + That's by design and may stay that way. +- **Zero setup**: uses the logins you already have (`az`, the Databricks + CLI, `sops`, `git`). No config file needed. +- **Search everything**: `ctrl+s` searches every source at once, with + fzf-style matching. +- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it + with their own access, from the terminal or with a click in the browser + or chat. +- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim) +- **Careful with values**: values stay hidden until you reveal them, and + copied values are cleared from the clipboard after 30 seconds. +- **Scriptable**: `lazysecrets get ` prints a value for scripts and + pipes. +- **Favourites**: star the secrets you use most for easy access. +- **Expiry warnings**: secrets, keys and certificates that expire soon are + marked, listed together in the status pane. +- **Your way**: rebind any key, pick a theme, and add custom commands to run + on a secret. +- **Try it first**: `lazysecrets --demo` runs on built-in sample data. +- **Supported platforms**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos. + +> **Early development:** expect rough edges. 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, from GitHub or else +[git.kebcloud.no](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 # login with azure cli +lazysecrets # start lazysecrets +lazysecrets --demo # test with demo data +``` + +Pick a source on the left, then a vault, then a secret. With nothing +signed in yet, the right pane says how to get started. The keys you'll +use most: + +| Key | Action | +|---|---| +| `j`/`k`, `enter`, `esc` | move, drill in, back | +| `l` / `h` | the details of what's selected / 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 + +## Privacy + +lazysecrets never stores, caches or sends your secret values. It keeps the +names and metadata of what you've loaded in a cache file readable only by +you, to show it at once next time (`cache: false` turns it off). It talks +only to the services you use it with, and collects no usage data. The one +call of its own is a daily check for a new release, on GitHub or else +git.kebcloud.no, which `updateCheck: false` turns off. + +## Repositories + +The main repo of this codebase is at [git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets). +The [GitHub mirror](https://github.com/karleinarb/lazysecrets) is for discoverability, issue-tracking and releases. +It also makes it possible to give a ⭐ if you like lazysecrets! + +## Contributing + +I don't take pull requests as I don't have time to review and test them. +But bug reports and ideas are very welcome! +Open an [issue on GitHub](https://github.com/karleinarb/lazysecrets/issues) and I'll have a look. + +## AI disclosure + +Built with help from an agentic coding assistant. Every change is requested, reviewed and tested by me before it's merged. + +## 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/changelog.go b/changelog.go new file mode 100644 index 0000000..915cd66 --- /dev/null +++ b/changelog.go @@ -0,0 +1,10 @@ +// Package lazysecrets holds what the module's root has for the app: the +// changelog, embedded so the status pane can show it offline. +package lazysecrets + +import _ "embed" + +// Changelog is CHANGELOG.md as of the build: every release up to it. +// +//go:embed CHANGELOG.md +var Changelog string diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..e82f71c --- /dev/null +++ b/cliff.toml @@ -0,0 +1,67 @@ +# 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 }} \ +([{{ commit.id | truncate(length=7, end="") }}](https://git.kebcloud.no/keb/lazysecrets/commit/{{ commit.id }}))\ +{% endfor %} +{% endfor %}\n +""" +footer = "" +trim = true +# Runs on each part (header, every release) separately. +postprocessors = [ + { pattern = "\n{3,}", replace = "\n\n" }, + # Squash commits end in the PR's number: link it. + { pattern = "\\(#([0-9]+)\\)", replace = "([#${1}](https://git.kebcloud.no/keb/lazysecrets/pulls/${1}))" }, +] + +[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})" }, + # #11 was squash-merged with #10 (the status pane) in it: name both. + { pattern = "^feat: link each changelog entry to its PR and commit \\(#11\\)", replace = "feat: a status pane (0) with version, updates, accounts, files and links; changelog entries link to their PR and commit (#11)" }, + # 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..f680c19 --- /dev/null +++ b/cmd/lazysecrets/main.go @@ -0,0 +1,407 @@ +// 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" + "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(*configPath, 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:alice@example.com/demo", "Azure · alice"), + fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"), + fake.Databricks(600 * time.Millisecond), + fake.Repos(), + } + } 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 + } + + // What was loaded last time, and where it was left in this folder. + var cacheFile, placeDir string + if !*demo { + if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) { + cacheFile = filepath.Join(d, "cache.json") + } + placeDir = *dir + if placeDir == "" { + placeDir, _ = os.Getwd() + } + if abs, err := filepath.Abs(placeDir); err == nil { + placeDir = abs + } + } + + 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(cfg.Releases), + UpdateCheck: config.On(cfg.UpdateCheck), + Keys: &keymap, + Commands: cfg.CustomCommands, + Version: versionString(), + Store: st, + CacheFile: cacheFile, + ConfigFiles: configFiles(*configPath), + StatePath: statePath, + Changelog: lazysecrets.Changelog, + Dir: placeDir, + Open: open, + Styles: &theme, + DefaultFg: defaultFg, + }) + p = tea.NewProgram(m) + final, err := p.Run() + if fm, ok := final.(app.Model); ok { + _ = fm.Persist() // a cache it couldn't save is no reason to fail + } + return err +} + +// configFiles are the config files given, for the status pane: those +// that exist, or the first if none does (where to create one). +func configFiles(list string) []string { + var all, found []string + for _, f := range strings.Split(list, ",") { + if f = strings.TrimSpace(f); f == "" { + continue + } + all = append(all, f) + if _, err := os.Stat(f); err == nil { + found = append(found, f) + } + } + if len(found) == 0 && len(all) > 0 { + return all[:1] + } + return found +} diff --git a/cmd/lazysecrets/update.go b/cmd/lazysecrets/update.go new file mode 100644 index 0000000..a56111b --- /dev/null +++ b/cmd/lazysecrets/update.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/config" + "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, from the servers (config releases) or the +// default ones. +func updater(servers []string) *app.Updater { + c := &update.Client{Servers: servers} + 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(configPath string, args []string) error { + check := len(args) == 1 && args[0] == "--check" + if len(args) > 0 && !check { + return errors.New("usage: lazysecrets update [--check]") + } + cfg, err := config.LoadAll(strings.Split(configPath, ",")) + if err != nil { + return err + } + current := versionString() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + c := &update.Client{Servers: cfg.Releases} + 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/assets/lazysecrets.png b/docs/assets/lazysecrets.png new file mode 100644 index 0000000..22ee0bd Binary files /dev/null and b/docs/assets/lazysecrets.png differ diff --git a/docs/azure.md b/docs/azure.md new file mode 100644 index 0000000..855034b --- /dev/null +++ b/docs/azure.md @@ -0,0 +1,78 @@ +# 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. + +On an account's group row, the fourth pane shows who it signs in as: name, +user, tenant, object ID, whether the session used MFA, directory roles +(built-in ones by name) and its subscriptions, read from az's account list +and the token lazysecrets already has: no requests of their own. `y` on +the row copies the object ID, the one access policies and role +assignments ask for; `l` goes into the pane, where `j`/`k` pick any +field, `/` filters them (`/tenant`) and `y` copies the selected one. + +If an account's az login has expired, the status line says so once, and the +Details pane on that account's rows says it until you sign in: press `L` +there to run `az login` for it 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, shortened to what was denied +(the action, or the access policy permission), with what to check: a +role or group to activate in PIM, access from the vault's owner, or, when +its firewall refused, the network you're on. + +## 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 `✗`. + +The status pane (`0`) lists them across every vault loaded, opened or +cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter` +on one goes to it. That makes no requests of its own: expiry dates come +with the lists. It says how many vaults it covers; `ctrl+a` in search +loads the rest. + +On a vault, the right pane shows its settings: resource group, location, +SKU, whether access is by Azure roles or access policies, soft delete and +purge protection, which networks can reach it, private endpoints and tags. +They come with the list of vaults, so they cost no extra requests and are +cached with it. diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..f7a04e6 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,146 @@ +# 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) +releases: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server) + - https://github.com/karleinarb/lazysecrets + - https://git.kebcloud.no/keb/lazysecrets +history: true # count uses of items for Recent (R) +cache: true # keep what was loaded (never values) for the next start +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"] # in the other panes (default: palette 8, not bold) + 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. + +It also keeps where you left lazysecrets in each folder (the selected +source, vault or secret), and goes back there the next time it starts in +that folder, unless it was started on a link. + +## Cache + +What lazysecrets has loaded is kept in +`~/.cache/lazysecrets/cache.json` (`$XDG_CACHE_HOME`), readable only by +you: the Sources lists, vaults and scopes, and secrets' names and metadata +(tags, dates, references), never values. On the next start they show at +once, and anything older than two minutes reloads in the background when +you go to it, or when Go to searches it. While it does, the pane's +bottom border says how old what it shows is (`cached 3h ago`). Revealing +and copying always fetch the value live. + +Lists not loaded for 30 days are dropped, and so is everything from an +account you're no longer signed in to. Repos aren't cached: their files +are read from disk anyway. Deleting the file is always safe, and +`cache: false` stops using it. + +## Updates + +Once a day (`updateCheck`), in the background, lazysecrets asks for the +latest release; within the day it reuses the answer from the state file. +Releases are published on GitHub and on git.kebcloud.no, and it asks +them in that order, the next when one doesn't answer within 5 seconds; +`releases` lists others, such as a mirror of your own. Offline, or with +every 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 from the first server that has it, checks it against +that server'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..e60c703 --- /dev/null +++ b/docs/databricks.md @@ -0,0 +1,26 @@ +# 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)). + +When Databricks refuses, the pane says what to check: access to the +workspace and READ on the scope, or, when the workspace's IP access list +refused, the network you're on. + +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..40e1a24 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,67 @@ +# 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. Then `scripts/github-release.mjs` copies that release, +notes and files, to the GitHub mirror, with the `GH_RELEASE_TOKEN` +secret (a fine-grained token that can write the mirror's contents); +without the secret it skips that. `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..b4c1527 --- /dev/null +++ b/docs/keys.md @@ -0,0 +1,175 @@ +# 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`, `shift+tab`/`h` | next / previous pane | +| `l` | the details of what's selected, on the right (`h` goes back) | +| `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. On an Azure account's row: its object ID; in its pane, the selected field | +| `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) | +| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) | +| `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 is shown as it reads best: JSON indented, and PEM +certificates as their subject, issuer, dates, names and thumbprint, with +any other PEM block (a private key) named but not shown. `y` still copies +it exactly as stored. + +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 and what's in it on the right. The +scopes are All, then grouped like the Sources pane: each account (or +Databricks, or Repos) with its sources under it, with `○ ◐ ●` for how +much of each is loaded. Repos searches every repo without asking Azure +or Databricks for anything. Go to opens on All; `ctrl+s` again narrows +it to the source or group selected in the Sources pane, and once more +goes back to All. Typing a search loads what it covers: everything on +All, else the account or 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 | +| `g`/`G` | | top / bottom | +| `ctrl+s` | `ctrl+s` | only the source or group you're in / All | +| `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 + +The pane on the right describes what's selected in the pane you're in: the +account or subscription in Sources, the vault in Vaults, the secret in +Secrets. Going into it (`l`, or `4`) keeps it on that; there `j`/`k`, +`/` and `y` work on its rows, and `h` goes back. A value you reveal stays in view whichever pane you're in, until +it's hidden. + +On top, the status pane (`0`) shows the version, a newer release and how +your accounts are doing. Focused, the right pane lists more under the +logo (when there's room): the version and updates (`U`), each account +(`L` on one that needs it signs in), what's expiring (`enter` goes to +it), this session's errors, newest first (what a pane showed, or the +footer flashed before you moved on; not kept after quitting), the config, +state and cache files (`e` opens one in your editor), and links to the +docs, source, issues, releases and changelog (`o` opens one; on the +version or update, its release), then the changelog of every release up +to yours, built in so it needs no network: `/` filters it (`/fixed`, +`/search`). `y` copies any row. + +| 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..c3c6878 --- /dev/null +++ b/docs/repos.md @@ -0,0 +1,100 @@ +# 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. + +On a file, the right pane shows how it's encrypted and the keys it's +encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP +fingerprints), when it was last encrypted, and what git knows of it: +tracked, ignored or neither, and its last commit. A plain `.env` file that +git tracks, or that isn't ignored, is marked in red: its values are in the +repo's history, or easily could be. + +## Opening files + +`e` opens the selected file, or on a secret, its file at that key's line: + +- **SOPS files** open with `sops edit`, which decrypts them into your + editor (`$SOPS_EDITOR` or `$EDITOR`) and encrypts them again when you + save. lazysecrets comes back after, with the file's keys reloaded. +- **Other files**, when lazysecrets runs inside nvim (its plugin, or + `:terminal`): lazysecrets quits and the file opens in that nvim, in the + window you came from. +- Otherwise in `$VISUAL` or `$EDITOR` in the terminal, coming back after; + without either, in the system's default app for the file. + +Editors that take a line (vim, nvim, nano, emacs, micro, helix, VS Code, +Zed, Sublime and others) start on the key. With `sops edit` that works for +editors taking `+N` (vim, nvim, nano, emacs, micro and others), and needs +sops 3.8 or later. + +## 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..5063b9d --- /dev/null +++ b/install.sh @@ -0,0 +1,110 @@ +#!/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. +# +# Releases are on GitHub and git.kebcloud.no, tried in that order; +# LAZYSECRETS_RELEASES (repository addresses, space-separated) uses others. +set -eu + +repos="${LAZYSECRETS_RELEASES:-https://github.com/karleinarb/lazysecrets https://git.kebcloud.no/keb/lazysecrets}" +dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}" + +fail() { + echo "lazysecrets install: $*" >&2 + exit 1 +} + +# Short timeouts, so a server that's down leaves time for the next. +if command -v curl >/dev/null 2>&1; then + fetch() { curl -fsSL --connect-timeout 5 --max-time 15 "$1"; } + download() { curl -fsSL --connect-timeout 5 -o "$2" "$1"; } +elif command -v wget >/dev/null 2>&1; then + fetch() { wget -qO- -T 15 "$1"; } + download() { wget -qO "$2" -T 30 "$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 + +# api is a repository's API: GitHub's, or Gitea's (and Forgejo's). +api() { + case "$1" in + https://github.com/*) echo "https://api.github.com/repos/${1#https://github.com/}" ;; + *) + host=${1#*://} + host=${host%%/*} + echo "${1%%://*}://$host/api/v1/repos/${1#*://"$host"/}" + ;; + esac +} + +tag="${1:-}" +if [ -z "$tag" ]; then + for repo in $repos; do + tag=$(fetch "$(api "$repo")/releases/latest" 2>/dev/null | + sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || tag= + [ -n "$tag" ] && break + done + [ -n "$tag" ] || fail "couldn't look up the latest release on $repos" +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 + +# The archive and its checksums.txt from the first server that has both. +from= +for repo in $repos; do + base="$repo/releases/download/$tag" + if download "$base/checksums.txt" "$tmp/checksums.txt" 2>/dev/null; then + echo "Downloading lazysecrets $tag for $os/$arch from $repo" + if download "$base/$name.tar.gz" "$tmp/$name.tar.gz"; then + from=$repo + break + fi + fi +done +[ -n "$from" ] || fail "couldn't download $tag for $os/$arch from $repos" + +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..9226056 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,243 @@ +// 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" + "git.kebcloud.no/keb/lazysecrets/internal/update" +) + +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"` + // Releases are the repositories updates come from, tried in order: + // on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, then + // git.kebcloud.no (update.DefaultServers). + Releases []string `yaml:"releases"` + // Cache keeps what was loaded (never values) between runs, so lists + // show at once and refresh in the background; defaults to true. + Cache *bool `yaml:"cache"` + // 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") +} + +// CacheDir is $XDG_CACHE_HOME/lazysecrets, defaulting to +// ~/.cache/lazysecrets. It holds what was loaded, to show it at once. +func CacheDir() (string, error) { + return xdgDir("XDG_CACHE_HOME", ".cache") +} + +// 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, r := range c.Releases { + if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") { + return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo) + } + } + 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..27a285b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,152 @@ +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", + "releases": "releases: [git.example.com/team/lazysecrets]\n", + "http": "releases: [http://git.example.com/team/lazysecrets]\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 TestReleases(t *testing.T) { + c, err := Load(write(t, "releases: [https://git.example.com/team/lazysecrets]\n")) + if err != nil || len(c.Releases) != 1 { + t.Errorf("releases = %v, %v", c.Releases, err) + } +} + +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/account.go b/internal/provider/azure/account.go new file mode 100644 index 0000000..8b85691 --- /dev/null +++ b/internal/provider/azure/account.go @@ -0,0 +1,157 @@ +package azure + +import ( + "fmt" + "slices" + "strings" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// accountDetails describes an az account from az's account list and the +// claims of the latest token lazysecrets got for it: nothing is fetched +// for it. +type accountDetails struct { + account + claims func() map[string]any +} + +var _ provider.Accounts = (*Provider)(nil) + +// Account describes the az account, once a token has been fetched for it +// (the user's name and object ID come from the token). +func (p *Provider) Account() (provider.Account, bool) { + if p.account == nil { + return provider.Account{}, false + } + a := p.account + claims := a.claims() + str := func(k string) string { s, _ := claims[k].(string); return s } + + var out provider.Account + add := func(label, value string) { + if value != "" { + out.Fields = append(out.Fields, provider.Attr{Label: label, Value: value}) + } + } + add("name", str("name")) + user := a.user + if a.userType == "servicePrincipal" { + user += " (service principal)" + } + add("user", user) + add("tenant", a.tenantName) + add("tenant ID", a.tenant) + if oid := str("oid"); oid != "" { + add("object ID", oid) + out.Copy, out.CopyLabel = oid, "object ID" + } + if claims != nil { + add("sign-in", signInMethods(claims["amr"])) + add("roles", directoryRoles(claims["wids"])) + } + subs := fmt.Sprint(a.subscriptions) + if a.defaultSub != "" { + subs += " · default " + a.defaultSub + } + add("subs", subs) + if a.cloud != "" && a.cloud != "AzureCloud" { + add("cloud", a.cloud) + } + return out, len(out.Fields) > 0 +} + +// signInMethods says how the session was signed in to, from the amr +// claim: with MFA or not, and by what. +func signInMethods(amr any) string { + list, _ := amr.([]any) + var methods []string + mfa := false + for _, m := range list { + s, _ := m.(string) + switch s { + case "mfa", "ngcmfa": + mfa = true + case "pwd": + methods = append(methods, "password") + case "rsa": + methods = append(methods, "Windows Hello or certificate") + case "fido": + methods = append(methods, "security key") + case "wia": + methods = append(methods, "Windows sign-in") + case "otp", "sms": + methods = append(methods, "one-time code") + } + } + if len(list) == 0 { + return "" + } + how := strings.Join(methods, ", ") + switch { + case mfa && how != "": + return "MFA · " + how + case mfa: + return "MFA" + case how != "": + return "no MFA · " + how + } + return "no MFA" +} + +// builtinRoles names Entra ID's built-in directory roles by template ID, +// the IDs a token's wids claim lists. +var builtinRoles = map[string]string{ + "62e90394-69f5-4237-9190-012177145e10": "Global Administrator", + "f2ef992c-3afb-46b9-b7cf-a126ee74c451": "Global Reader", + "e8611ab8-c189-46e8-94e1-60213ab1f814": "Privileged Role Administrator", + "7be44c8a-adaf-4e2a-84d6-ab2649e08a13": "Privileged Authentication Administrator", + "194ae4cb-b126-40b2-bd5b-6091b380977d": "Security Administrator", + "5d6b6bb7-de71-4623-b4af-96380a352509": "Security Reader", + "5f2222b1-57c3-48ba-8ad5-d4759f1fde6f": "Security Operator", + "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3": "Application Administrator", + "158c047a-c907-4556-b7ef-446551a6b5f7": "Cloud Application Administrator", + "cf1c38e5-3621-4004-a7cb-879624dced7c": "Application Developer", + "fe930be7-5e62-47db-91af-98c3a49a38b1": "User Administrator", + "fdd7a751-b60b-444a-984c-02652fe8fa1c": "Groups Administrator", + "729827e3-9c14-49f7-bb1b-9608f156bbb8": "Helpdesk Administrator", + "966707d0-3269-4727-9be2-8c3a10f19b9d": "Password Administrator", + "c4e39bd9-1100-46d3-8c65-fb160da0071f": "Authentication Administrator", + "0526716b-113d-4c15-b2c8-68e3c22b9f80": "Authentication Policy Administrator", + "8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2": "Hybrid Identity Administrator", + "b0f54661-2d74-4c50-afa3-1ec803f12efe": "Billing Administrator", + "4d6ac14f-3453-41d0-bef9-a3e0c569773a": "License Administrator", + "17315797-102d-40b4-93e0-432062caca18": "Compliance Administrator", + "29232cdf-9323-42fd-ade2-1d097af3e4de": "Exchange Administrator", + "f28a1f50-f6e7-4571-818b-6a12f2af6b6c": "SharePoint Administrator", + "69091246-20e8-4a56-aa4d-066075b2a7a8": "Teams Administrator", + "3a2c62db-5318-420d-8d74-23affee5d9d5": "Intune Administrator", + "11648597-926c-4cf3-9c36-bcebb0ba8dcc": "Power Platform Administrator", + "e3973bdf-4987-49ae-837a-ba8e231c7286": "Azure DevOps Administrator", + "9360feb5-f418-4baa-8175-e2a00bac4301": "Directory Writers", + "88d8e3e3-8f55-4a1e-953a-9b9898b8876b": "Directory Readers", + "95e79109-95c0-4d8e-aee3-d01accf2d47b": "Guest Inviter", +} + +// memberRole is in every member's token: not worth listing. +const memberRole = "b79fbf4d-3ef9-4689-8143-76b194e85509" + +// directoryRoles names the roles in a wids claim: built-in ones by name, +// others by their ID's first part. +func directoryRoles(wids any) string { + list, _ := wids.([]any) + var names []string + for _, w := range list { + id, _ := w.(string) + switch name, ok := builtinRoles[strings.ToLower(id)]; { + case id == "" || id == memberRole: + case ok: + names = append(names, name) + default: + short, _, _ := strings.Cut(id, "-") + names = append(names, "role "+short) + } + } + slices.Sort(names) + return strings.Join(names, ", ") +} diff --git a/internal/provider/azure/account_test.go b/internal/provider/azure/account_test.go new file mode 100644 index 0000000..0093ea9 --- /dev/null +++ b/internal/provider/azure/account_test.go @@ -0,0 +1,67 @@ +package azure + +import ( + "testing" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +func TestAccount(t *testing.T) { + claims := map[string]any{ + "name": "Alice Example", "oid": "11111111-2222-3333-4444-555555555555", + "amr": []any{"pwd", "mfa"}, + "wids": []any{"62e90394-69f5-4237-9190-012177145e10", memberRole, "aaaaaaaa-0000-0000-0000-000000000000"}, + } + p := &Provider{account: &accountDetails{ + account: account{user: "alice@contoso.com", tenant: "t1", tenantName: "Contoso", subscriptions: 3, defaultSub: "platform"}, + claims: func() map[string]any { return claims }, + }} + a, ok := p.Account() + if !ok { + t.Fatal("no account") + } + got := map[string]string{} + for _, f := range a.Fields { + got[f.Label] = f.Value + } + want := map[string]string{ + "name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso", "tenant ID": "t1", + "object ID": "11111111-2222-3333-4444-555555555555", "sign-in": "MFA · password", + "roles": "Global Administrator, role aaaaaaaa", "subs": "3 · default platform", + } + for k, v := range want { + if got[k] != v { + t.Errorf("%s = %q, want %q", k, got[k], v) + } + } + if len(got) != len(want) { + t.Errorf("fields %v", got) + } + if a.Copy != claims["oid"] || a.CopyLabel != "object ID" { + t.Errorf("copy %q %q", a.Copy, a.CopyLabel) + } + + // Before any token: what az's list says. + claims = nil + a, _ = p.Account() + if len(a.Fields) != 4 || a.Copy != "" { + t.Errorf("without a token: %+v", a) + } + if _, ok := (&Provider{}).Account(); ok { + t.Error("a service principal from AZURE_* has no account details") + } + var _ provider.Accounts = p +} + +func TestSignInMethods(t *testing.T) { + for want, amr := range map[string][]any{ + "MFA · password": {"pwd", "mfa"}, + "MFA · Windows Hello or certificate": {"rsa", "ngcmfa"}, + "no MFA · password": {"pwd"}, + "": nil, + } { + if got := signInMethods(amr); got != want { + t.Errorf("%v: %q, want %q", amr, got, want) + } + } +} diff --git a/internal/provider/azure/accounts.go b/internal/provider/azure/accounts.go new file mode 100644 index 0000000..e760b82 --- /dev/null +++ b/internal/provider/azure/accounts.go @@ -0,0 +1,205 @@ +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 + userType string // "user" or "servicePrincipal" + tenantName string + cloud string // az's environment: AzureCloud, AzureChinaCloud, … + defaultSub string // az's default subscription's name, if this account's + 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"` + Name string `json:"name"` + State string `json:"state"` + IsDefault bool `json:"isDefault"` + TenantID string `json:"tenantId"` + TenantName string `json:"tenantDisplayName"` + Cloud string `json:"environmentName"` + User struct { + Name string `json:"name"` + Type string `json:"type"` + } `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, userType: s.User.Type, cloud: s.Cloud}) + 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, a.defaultSub = s.ID, enabled, true, s.Name + case a.isDefault: + case a.anchor == "" || enabled && !a.anchorEnabled: + a.anchor, a.anchorEnabled = s.ID, enabled + } + } + // az leaves the tenant's name out of some entries: take it from + // another in the same tenant. + for i := range out { + for _, b := range out { + if out[i].tenantName == "" && b.tenant == out[i].tenant { + out[i].tenantName = b.tenantName + } + } + } + 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("", "", nil), 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.user, a.tenant, func() ([]account, error) { return readAccounts(path) }), + 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 + } + p.account = &accountDetails{account: a, claims: cred.cred.(*cliCredential).claims} + 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..88eb080 --- /dev/null +++ b/internal/provider/azure/accounts_test.go @@ -0,0 +1,191 @@ +package azure + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "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("alice@contoso.com", "t1", func() ([]account, error) { + return []account{{user: "alice@contoso.com", tenant: "t1", anchor: "s-alice-2"}}, nil + }) + 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("", "", nil) + 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) + } +} + +// jwt is an unsigned token with claims, as az would print it. +func jwt(claims string) string { + enc := base64.RawURLEncoding.EncodeToString + return enc([]byte(`{"alg":"none"}`)) + "." + enc([]byte(claims)) + ".sig" +} + +func TestCLICredentialFollowsAzAndRefusesOtherUsers(t *testing.T) { + // az's profile has one entry per subscription: signing in as bob, + // who also sees s-shared, moved it from alice to bob. + profile := []account{ + {user: "alice@contoso.com", tenant: "t1", anchor: "s-shared"}, + {user: "bob@contoso.com", tenant: "t1", anchor: "s-bob"}, + } + var args []string + issuedTo := "alice@contoso.com" + c := newCLICredential("alice@contoso.com", "t1", func() ([]account, error) { return profile, nil }) + c.run = func(_ context.Context, a ...string) ([]byte, error) { + args = a + exp := time.Now().Add(time.Hour).Unix() + return []byte(fmt.Sprintf(`{"accessToken": %q, "expires_on": %d}`, jwt(`{"upn":"`+issuedTo+`"}`), exp)), nil + } + arm := policy.TokenRequestOptions{Scopes: []string{"https://management.azure.com/.default"}} + if _, err := c.GetToken(context.Background(), arm); err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(args, " "), "--subscription s-shared") { + t.Errorf("args %q", args) + } + + // Now alice has another subscription, and az would hand out bob's + // token for the old one: the new pin is used, and bob's token refused. + profile[0].anchor = "s-alice" + c.cache = map[string]azcore.AccessToken{} + issuedTo = "bob@contoso.com" + _, err := c.GetToken(context.Background(), arm) + if !strings.Contains(strings.Join(args, " "), "--subscription s-alice") { + t.Errorf("args %q", args) + } + if err == nil || !strings.Contains(err.Error(), "token for bob@contoso.com, not alice@contoso.com") { + t.Errorf("err = %v", err) + } + + // Alice gone from az entirely: sign in again. + profile = profile[1:] + if _, err := c.GetToken(context.Background(), arm); err == nil || !strings.Contains(err.Error(), "sign in as alice@contoso.com") { + t.Errorf("err = %v", err) + } +} + +func TestTokenUser(t *testing.T) { + users := []string{"bob@contoso.com"} + for claims, want := range map[string]bool{ + `{"upn":"Bob@Contoso.com"}`: true, + `{"unique_name":"live.com#bob@contoso.com"}`: true, + `{"upn":"alice@contoso.com"}`: false, + `{"appid":"00000000-0000-0000-0000-000000000000"}`: false, + } { + if _, got := tokenUser(tokenClaims(jwt(claims)), users); got != want { + t.Errorf("%s: %v", claims, got) + } + } + if _, got := tokenUser(tokenClaims("not-a-jwt"), users); got { + t.Error("not a JWT") + } +} 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..4a7bbc1 --- /dev/null +++ b/internal/provider/azure/azure.go @@ -0,0 +1,521 @@ +// 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 + account *accountDetails // nil for a service principal from AZURE_* + 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] +} + +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, Attrs: graphInfo(m).attrs()}, 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, Attrs: armInfo(v).attrs(), + }) + } + } + 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..a2417f7 --- /dev/null +++ b/internal/provider/azure/azure_test.go @@ -0,0 +1,476 @@ +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" + "slices" + "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.\n\nCheck:\n• your role or group is activated in PIM\n• "; !strings.HasPrefix(got, want) { + t.Errorf("error = %q, want %q…", got, want) + } + if checks := deniedChecks("Client address is not authorized and caller is not a trusted service."); !strings.Contains(checks[0], "network") { + t.Errorf("firewall checks = %q", checks) + } + var re *azcore.ResponseError + if !errors.As(err, &re) || re.StatusCode != 403 { + t.Error("original ResponseError lost from chain") + } +} + +func TestShortenPolicyDenied(t *testing.T) { + msg := "The user, group or application 'appid=04b07795;oid=1234;numgroups=83;iss=https://sts.windows.net/t/' does not have secrets list permission on key vault 'kv-app;location=norwayeast'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287" + if got := shortenDenied(msg); got != "no secrets list permission in the vault's access policies" { + t.Errorf("shortenDenied = %q", got) + } +} + +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:] +} + +func TestVaultSettings(t *testing.T) { + want := []string{"group=rg-sec", "location=norwayeast", "SKU=premium", "access=Azure roles (RBAC)", + "soft delete=on, 30 days", "purge prot.=on", "network=selected networks (1 IP ranges, 0 subnets)", + "private=1 endpoint", "tags=env: prod", "=team: platform"} + check := func(name string, attrs []provider.Attr) { + t.Helper() + var got []string + for _, a := range attrs { + got = append(got, a.Label+"="+a.Value) + } + if !slices.Equal(got, want) { + t.Errorf("%s\n got %q\nwant %q", name, got, want) + } + } + + // Resource Graph: the query's columns, as JSON gives them. + c, ok := graphVault(provider.Source{ID: "s"}, map[string]any{ + "id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/", "location": "norwayeast", + "resourceGroup": "rg-sec", "tags": map[string]any{"team": "platform", "env": "prod"}, + "sku": "premium", "rbac": true, "softDelete": nil, "retention": 30.0, "purge": true, + "publicAccess": "", "defaultAction": "Deny", "ipRules": 1.0, "subnets": 0.0, "endpoints": 1.0, "policies": 0.0, + }) + if !ok { + t.Fatal("row not read") + } + check("resource graph", c.Attrs) + + // The management API, when Resource Graph fails. + id := "/subscriptions/s/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv" + p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": id, "name": "kv", "location": "norwayeast", + "tags": map[string]any{"team": "platform", "env": "prod"}, + "properties": map[string]any{ + "tenantId": "t", "vaultUri": "https://kv.vault.azure.net/", "sku": map[string]any{"family": "A", "name": "premium"}, + "enableRbacAuthorization": true, "enablePurgeProtection": true, "softDeleteRetentionInDays": 30, + "networkAcls": map[string]any{"defaultAction": "Deny", "ipRules": []any{map[string]any{"value": "10.0.0.0/8"}}}, + "privateEndpointConnections": []any{map[string]any{"id": "pe1"}}, + }}}}) + }) + vaults, err := p.armVaults(context.Background(), provider.Source{ID: "s"}) + if err != nil || len(vaults) != 1 { + t.Fatalf("arm: %v %v", vaults, err) + } + check("management API", vaults[0].Attrs) +} diff --git a/internal/provider/azure/cli.go b/internal/provider/azure/cli.go new file mode 100644 index 0000000..aced535 --- /dev/null +++ b/internal/provider/azure/cli.go @@ -0,0 +1,212 @@ +package azure + +import ( + "bytes" + "context" + "encoding/base64" + "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. +// +// Which account a subscription belongs to changes: az's profile has one +// entry per subscription, and signing in to another account that sees it +// moves it there. So the subscription is looked up for every new token, +// and a token az issues for another of the signed-in users is refused. +// +// 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 { + user string // the account's user; "" for az's default account + tenant string // the account's tenant, if known + // accounts reads az's accounts, to pin the user; nil for the default + // account. + accounts func() ([]account, error) + run func(ctx context.Context, args ...string) ([]byte, error) + + mu sync.Mutex + cache map[string]azcore.AccessToken // by resource and tenant argument + // last is the claims of the latest token in the account's own + // tenant, to describe the account (Provider.Account). + last map[string]any +} + +// claims are the latest token's claims, nil before the first. +func (c *cliCredential) claims() map[string]any { + c.mu.Lock() + defer c.mu.Unlock() + return c.last +} + +func newCLICredential(user, tenant string, accounts func() ([]account, error)) *cliCredential { + return &cliCredential{user: user, tenant: tenant, accounts: accounts, run: runAz, cache: map[string]azcore.AccessToken{}} +} + +// pin is a subscription az maps to the user now, and the other users az +// holds. +func (c *cliCredential) pin() (subscription string, others []string, err error) { + if c.accounts == nil { + return "", nil, nil + } + accts, err := c.accounts() + if err != nil { + return "", nil, err + } + for _, a := range accts { + switch { + case a.user == c.user && a.tenant == c.tenant: + subscription = a.anchor + case !strings.EqualFold(a.user, c.user): + others = append(others, a.user) + } + } + if subscription == "" { + return "", nil, fmt.Errorf("az has no subscriptions for %s any more: sign in as %s", c.user, c.user) + } + return subscription, others, nil +} + +// 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") + otherTenant := opts.TenantID != "" && opts.TenantID != c.tenant + key := resource + " " + opts.TenantID + + c.mu.Lock() + defer c.mu.Unlock() + if tok, ok := c.cache[key]; ok && time.Until(tok.ExpiresOn) > refreshMargin { + return tok, nil + } + args := []string{"account", "get-access-token", "--output", "json", "--resource", resource} + subscription, others, err := c.pin() + switch { + case otherTenant: + // Another tenant: az picks the account itself. + args = append(args, "--tenant", opts.TenantID) + case err != nil: + return azcore.AccessToken{}, err + case subscription != "": + args = append(args, "--subscription", subscription) + } + out, err := c.run(ctx, args...) + if err != nil { + return azcore.AccessToken{}, err + } + tok, err := parseCLIToken(out) + if err != nil { + return azcore.AccessToken{}, err + } + claims := tokenClaims(tok.Token) + if u, ok := tokenUser(claims, others); ok { + return azcore.AccessToken{}, fmt.Errorf("az gave a token for %s, not %s: sign in as %s again", u, c.user, c.user) + } + if !otherTenant && claims != nil { + c.last = claims + } + c.cache[key] = tok + return tok, nil +} + +// tokenClaims reads a JWT's claims, without verifying it: they only +// describe the account and catch az picking the wrong one. nil if it +// isn't a JWT. +func tokenClaims(token string) map[string]any { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil + } + b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "=")) + if err != nil { + return nil + } + var claims map[string]any + if json.Unmarshal(b, &claims) != nil { + return nil + } + return claims +} + +// tokenUser reports which of users a token's claims are for, if any. +func tokenUser(claims map[string]any, users []string) (string, bool) { + for _, name := range []string{"upn", "unique_name", "preferred_username", "email"} { + v, _ := claims[name].(string) + if i := strings.LastIndexByte(v, '#'); i >= 0 { + v = v[i+1:] // live.com#user@example.com + } + for _, u := range users { + if v != "" && strings.EqualFold(v, u) { + return u, true + } + } + } + return "", false +} + +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..32f658c --- /dev/null +++ b/internal/provider/azure/convert.go @@ -0,0 +1,348 @@ +package azure + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net/http" + "regexp" + "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 + } + } + } + se := &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err} + if se.status == http.StatusForbidden { + return &provider.Denied{Err: se, Checks: deniedChecks(se.msg)} + } + return se +} + +var policyDenied = regexp.MustCompile(`does not have (\w+ \w+) permission on key vault`) + +// 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 { + // Access policies: "The user, group or application 'appid=…;oid=…' + // does not have secrets list permission on key vault 'kv;location=…'". + if m := policyDenied.FindStringSubmatch(msg); m != nil { + return "no " + m[1] + " permission in the vault's access policies" + } + 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 +} + +// deniedChecks are what to check when Key Vault refuses: the network, +// for its firewall's refusals, else access. +func deniedChecks(msg string) []string { + if strings.Contains(msg, "Client address is not authorized") || strings.Contains(msg, "network access") || + strings.Contains(msg, "private link") { + return []string{ + "you're on a network the vault lets in (VPN, office)", + "the vault's owner has let your network in", + } + } + return []string{ + "your role or group is activated in PIM", + "the vault's owner has given you access: a Key Vault role, or its access policies", + } +} + +func (e *serviceError) Unwrap() error { return e.err } diff --git a/internal/provider/azure/details.go b/internal/provider/azure/details.go new file mode 100644 index 0000000..714005c --- /dev/null +++ b/internal/provider/azure/details.go @@ -0,0 +1,158 @@ +package azure + +import ( + "fmt" + "slices" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// A vault's settings come with the listing, from Resource Graph or the +// Key Vault management API, with no requests of their own: where it is, +// how access to it works, and how it's protected. + +type vaultInfo struct { + group, location, sku string + rbac, softDelete, purge *bool + retention *int32 + publicAccess, defaultAction string + ipRules, subnets, endpoints int + policies int + tags map[string]string +} + +// vaultQuery lists vaults with just the settings shown, not their whole +// properties (access policies can be long). +const vaultQuery = `resources +| where type =~ 'microsoft.keyvault/vaults' +| project id, name, uri = tostring(properties.vaultUri), location, resourceGroup, tags, + sku = tostring(properties.sku.name), rbac = properties.enableRbacAuthorization, + softDelete = properties.enableSoftDelete, retention = properties.softDeleteRetentionInDays, + purge = properties.enablePurgeProtection, publicAccess = tostring(properties.publicNetworkAccess), + defaultAction = tostring(properties.networkAcls.defaultAction), + ipRules = array_length(properties.networkAcls.ipRules), + subnets = array_length(properties.networkAcls.virtualNetworkRules), + endpoints = array_length(properties.privateEndpointConnections), + policies = array_length(properties.accessPolicies)` + +// graphInfo reads a vaultQuery row's settings. +func graphInfo(m map[string]any) vaultInfo { + str := func(k string) string { s, _ := m[k].(string); return s } + num := func(k string) int { f, _ := m[k].(float64); return int(f) } + flag := func(k string) *bool { + if b, ok := m[k].(bool); ok { + return &b + } + return nil + } + v := vaultInfo{ + group: str("resourceGroup"), location: str("location"), sku: str("sku"), + rbac: flag("rbac"), softDelete: flag("softDelete"), purge: flag("purge"), + publicAccess: str("publicAccess"), defaultAction: str("defaultAction"), + ipRules: num("ipRules"), subnets: num("subnets"), endpoints: num("endpoints"), policies: num("policies"), + } + if f, ok := m["retention"].(float64); ok { + r := int32(f) + v.retention = &r + } + if tags, ok := m["tags"].(map[string]any); ok { + v.tags = map[string]string{} + for k, t := range tags { + v.tags[k] = fmt.Sprint(t) + } + } + return v +} + +// armInfo reads a vault's settings from the management API. +func armInfo(v *armkeyvault.Vault) vaultInfo { + info := vaultInfo{location: deref(v.Location), tags: map[string]string{}} + if id, err := arm.ParseResourceID(deref(v.ID)); err == nil { + info.group = id.ResourceGroupName + } + for k, t := range v.Tags { + info.tags[k] = deref(t) + } + pr := v.Properties + if pr == nil { + return info + } + if pr.SKU != nil && pr.SKU.Name != nil { + info.sku = string(*pr.SKU.Name) + } + info.rbac, info.softDelete, info.purge = pr.EnableRbacAuthorization, pr.EnableSoftDelete, pr.EnablePurgeProtection + info.retention, info.publicAccess = pr.SoftDeleteRetentionInDays, deref(pr.PublicNetworkAccess) + if acl := pr.NetworkACLs; acl != nil { + if acl.DefaultAction != nil { + info.defaultAction = string(*acl.DefaultAction) + } + info.ipRules, info.subnets = len(acl.IPRules), len(acl.VirtualNetworkRules) + } + info.endpoints, info.policies = len(pr.PrivateEndpointConnections), len(pr.AccessPolicies) + return info +} + +func (v vaultInfo) attrs() []provider.Attr { + var out []provider.Attr + add := func(label, value string) { + if value != "" { + out = append(out, provider.Attr{Label: label, Value: value}) + } + } + add("group", v.group) + add("location", v.location) + add("SKU", v.sku) + // Who may read secrets is decided by Azure roles or by the vault's + // own access policies, never both. + if on(v.rbac, false) { + add("access", "Azure roles (RBAC)") + } else { + add("access", fmt.Sprintf("access policies (%d)", v.policies)) + } + if on(v.softDelete, true) { + days := int32(90) + if v.retention != nil { + days = *v.retention + } + add("soft delete", fmt.Sprintf("on, %d days", days)) + } else { + add("soft delete", "off") + } + add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)]) + switch { + case strings.EqualFold(v.publicAccess, "disabled"): + add("network", "private endpoints only") + case strings.EqualFold(v.defaultAction, "deny"): + add("network", fmt.Sprintf("selected networks (%d IP ranges, %d subnets)", v.ipRules, v.subnets)) + default: + add("network", "all networks") + } + if v.endpoints > 0 { + add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1])) + } + keys := make([]string, 0, len(v.tags)) + for k := range v.tags { + keys = append(keys, k) + } + slices.Sort(keys) + for i, k := range keys { + label := "" + if i == 0 { + label = "tags" + } + out = append(out, provider.Attr{Label: label, Value: k + ": " + v.tags[k]}) + } + return out +} + +// on is *b, or def when it isn't set. +func on(b *bool, def bool) bool { + if b == nil { + return def + } + return *b +} diff --git a/internal/provider/databricks/databricks.go b/internal/provider/databricks/databricks.go new file mode 100644 index 0000000..63883c6 --- /dev/null +++ b/internal/provider/databricks/databricks.go @@ -0,0 +1,407 @@ +// 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 +} + +// deniedChecks are what to check when Databricks refuses: the network, +// when the workspace's IP access list refused, else access, and for a +// profile signed in through az, which account az is on. +func deniedChecks(pr profile, msg string) []string { + if strings.Contains(msg, "IP ACL") || strings.Contains(msg, "IP access list") { + return []string{ + "you're on a network the workspace lets in (VPN, office)", + "the workspace's admin has let your network in", + } + } + var checks []string + if pr.authType == "azure-cli" { + checks = append(checks, "az is signed in as the account with access: profile "+pr.name+" uses your az login (az account show)") + } + return append(checks, + "you have access to the workspace", + "the scope's manager has given you READ on it, or a group you're in", + ) +} + +var errNoFallback = errors.New("no Key Vault fallback") + +// combine reports both the Databricks and the Key Vault failure, with +// both their checks after. An auth error from either stays reachable +// with errors.As. +func combine(dbxErr, kvErr error) error { + if errors.Is(kvErr, errNoFallback) { + return dbxErr + } + var checks []string + strip := func(err error) error { + if d, ok := err.(*provider.Denied); ok { + for _, c := range d.Checks { + if !slices.Contains(checks, c) { + checks = append(checks, c) + } + } + return d.Err + } + return err + } + err := fmt.Errorf("%w; Key Vault fallback: %w", strip(dbxErr), strip(kvErr)) + if len(checks) > 0 { + return &provider.Denied{Err: err, Checks: checks} + } + return err +} + +// 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 == 403 { + return &provider.Denied{Err: err, Checks: deniedChecks(pr, api.Message)} + } + 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..10caba9 --- /dev/null +++ b/internal/provider/databricks/databricks_test.go @@ -0,0 +1,319 @@ +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/list" && q.Get("scope") == "locked": + write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have READ permission on scope locked"}) + 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") + } +} + +func TestDeniedSaysWhatToCheck(t *testing.T) { + p := newTestProvider(t, workspaceAPI(t, 0), nil) + _, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret) + var d *provider.Denied + // The test profile signs in through az: that's the first check. + if !errors.As(err, &d) || !strings.Contains(err.Error(), "READ permission on scope locked\n\nCheck:\n• az is signed in as the account with access: profile prod uses your az login") || + !strings.Contains(err.Error(), "\n• you have access to the workspace") { + t.Errorf("err %q", err) + } + if got := deniedChecks(profile{authType: "pat"}, "denied"); strings.Contains(got[0], "az") { + t.Errorf("a token profile's checks %q", got) + } + if got := deniedChecks(profile{}, "Source IP address: 1.2.3.4 is blocked by Databricks IP ACL for workspace: 1"); !strings.Contains(got[0], "network") { + t.Errorf("IP ACL checks %q", got) + } + + // Both refused: one message, then both lists of checks. + err = combine(&provider.Denied{Err: errors.New("dbx"), Checks: []string{"a", "b"}}, + &provider.Denied{Err: errors.New("kv"), Checks: []string{"b", "c"}}) + if got := err.Error(); got != "dbx; Key Vault fallback: kv\n\nCheck:\n• a\n• b\n• c" { + t.Errorf("combined %q", got) + } +} diff --git a/internal/provider/fake/fake.go b/internal/provider/fake/fake.go new file mode 100644 index 0000000..cd86071 --- /dev/null +++ b/internal/provider/fake/fake.go @@ -0,0 +1,408 @@ +// 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 + details map[string][]provider.Attr // by container 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.KindAzure { // only Key Vault keeps versions + 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 || (p.kind == provider.KindDatabricks && 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/fake/repos.go b/internal/provider/fake/repos.go new file mode 100644 index 0000000..bed7936 --- /dev/null +++ b/internal/provider/fake/repos.go @@ -0,0 +1,136 @@ +package fake + +import ( + "context" + "strings" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +var _ provider.ContainerDetails = (*Provider)(nil) + +// Details returns a file's details, like the repo provider's. +func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) { + attrs, ok := p.details[c.ID] + if !ok { + return nil, provider.ErrUnsupported + } + return attrs, ctx.Err() +} + +// Repos returns a fake repo provider: three git repos with SOPS and plain +// .env files, the first one the repo lazysecrets was started in. +func Repos() *Provider { + p := newProvider(provider.KindRepo, 0) + p.id, p.label = "Repos", "Repos" + p.details = map[string][]provider.Attr{} + + repo := func(name string, here bool) provider.Source { + s := p.addSource("/home/dev/git/"+name, name, "github.com/acme/"+name) + s.Here = here + p.sources[len(p.sources)-1] = s + return s + } + // file adds a file and its keys, alternating name and value. git and + // commit are what git says of it: "tracked", "ignored" or "untracked". + file := func(src provider.Source, path, method, git, commit string, kv ...string) { + sops := method != "" + detail := "env · plain text" + if sops { + detail = "sops · " + method + } + c := p.addContainer(src, path, detail, provider.TypeSecret) + attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}} + if sops { + attrs = []provider.Attr{{Label: "format", Value: "sops " + format(path)}, {Label: "encryption", Value: method}} + } + for i := 0; i+1 < len(kv); i += 2 { + o := provider.Object{Name: kv[i], Attrs: attrs} + if sops { + o.Updated = base + } + p.add(c, provider.TypeSecret, o, kv[i+1]) + } + + d := []provider.Attr{{Label: "encryption", Value: "none: plain text"}} + if sops { + d = []provider.Attr{{Label: "encryption", Value: detail}} + keys := map[string]string{ + "age": "age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p", + "azure_kv": "azure_kv https://kv-sops-prod.vault.azure.net/keys/sops/1f2e3d4c", + } + for i, m := range strings.Split(method, ", ") { + label := "" + if i == 0 { + label = "keys" // on the first only, like the repo provider + } + d = append(d, provider.Attr{Label: label, Value: keys[m]}) + } + d = append(d, provider.Attr{Label: "encrypted", Value: base.Local().Format("2006-01-02 15:04")}) + } + switch { + case git == "tracked" && !sops: + d = append(d, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true}) + case git == "untracked": + d = append(d, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !sops}) + default: + d = append(d, provider.Attr{Label: "git", Value: git}) + } + if commit != "" { + d = append(d, provider.Attr{Label: "commit", Value: commit}) + } + p.details[c.ID] = d + } + + api := repo("lazysecrets-demo", true) + file(api, "deploy/prod/secrets.yaml", "age, azure_kv", "tracked", "4f9c2ab · 3 days ago", + "database.password", "pr0d-Pg-7f3k9QwZ", + "database.url", "postgres://app:pr0d-Pg-7f3k9QwZ@pg-demo-prod:5432/app", + "stripe.secret_key", "sk_live_demo_51NxDemoKey0000", + "jwt.signing_key", "c2lnbmluZy1rZXktZm9yLXRoZS1kZW1v", + "gcp.service_account", `{"type":"service_account","project_id":"lazysecrets-demo","client_email":"app@lazysecrets-demo.iam.gserviceaccount.com","private_key_id":"demo1234"}`) + file(api, "deploy/staging/secrets.yaml", "age", "tracked", "a71e05d · 2 weeks ago", + "database.password", "stg-Pg-2b8d1L", + "database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app", + "stripe.secret_key", "sk_test_demo_51NxDemoKey0000") + file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago", + "github.client_secret", "ghs_demo0000000000000000000000", + "google.client_secret", "GOCSPX-demo-000000000000") + file(api, ".env", "", "ignored", "", + "DATABASE_URL", "postgres://app:local@localhost:5432/app", + "STRIPE_SECRET_KEY", "sk_test_demo_local", + "LOG_LEVEL", "debug") + + infra := repo("platform-infra", false) + file(infra, "terraform/prod/secrets.enc.yaml", "azure_kv", "tracked", "9d40c3e · yesterday", + "postgres.admin_password", "infra-Pg-admin-0Xq", + "grafana.admin_password", "grafana-demo-admin", + "acr.pull_token", "acr-demo-pull-token") + file(infra, "k8s/monitoring/alertmanager.enc.yaml", "age", "tracked", "1c58e7a · 5 days ago", + "slack.webhook_url", "https://hooks.slack.com/services/T000/B000/demo", + "pagerduty.routing_key", "pd-demo-routing-key") + file(infra, "scripts/.env", "", "tracked", "77b2d90 · 8 months ago", + "AZURE_CLIENT_SECRET", "azure-demo-client-secret", + "ARM_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000") + + web := repo("web-frontend", false) + file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago", + "SENTRY_AUTH_TOKEN", "sntrys_demo_token", + "NEXT_PUBLIC_API_URL", "https://api.acme.example") + file(web, ".env.local", "", "untracked", "", + "NEXT_PUBLIC_API_URL", "http://localhost:8080", + "ANALYTICS_KEY", "demo-analytics-key") + + return p +} + +// format is a SOPS file's format, by its name. +func format(path string) string { + switch { + case strings.HasSuffix(path, ".json"): + return "json" + case strings.HasSuffix(path, ".yaml"): + return "yaml" + } + return "dotenv" +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..e5136d4 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,228 @@ +// 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" + "strings" + "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 + // Attrs describe it further, from the listing (a vault's settings). + Attrs []Attr `json:",omitempty"` +} + +// 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 + Warn bool // worth a look (a plain .env tracked by git): shown in red +} + +// 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) +} + +// Files is implemented by providers whose containers are files on disk +// (SOPS and .env files in repos), so they can be opened in an editor. +type Files interface { + // File is where c is, and for o (nil for the whole file) the line its + // key is on. + File(c Container, o *Object) (File, error) +} + +// File is a container's file, to open. +type File struct { + Path string + Line int // 1-based; 0 for the top + Sops string // the sops command to edit it with; empty for a plain file +} + +// Accounts is implemented by providers that can describe the identity +// they use, from what they have locally: for the Details pane on the +// provider's row. ok is false when there's nothing to say. +type Accounts interface { + Account() (Account, bool) +} + +// Account describes a signed-in identity. +type Account struct { + Fields []Attr + // Copy is what y copies on the provider's row, named CopyLabel. + Copy, CopyLabel string +} + +// Denied is a refusal with what to check, from what the service said: a +// role to activate in PIM, access to ask for, the network. Error is the +// refusal, then the checks as a list. +type Denied struct { + Err error + Checks []string +} + +func (d *Denied) Error() string { + if len(d.Checks) == 0 { + return d.Err.Error() + } + return d.Err.Error() + "\n\nCheck:\n• " + strings.Join(d.Checks, "\n• ") +} + +func (d *Denied) Unwrap() error { return d.Err } + +// ContainerDetails is implemented by providers that can say more about a +// container than its listing does: how a file is encrypted and what git +// knows of it. ErrUnsupported means there's nothing more for c. +type ContainerDetails interface { + Details(ctx context.Context, c Container) ([]Attr, error) +} diff --git a/internal/provider/repo/details.go b/internal/provider/repo/details.go new file mode 100644 index 0000000..0ffc1e3 --- /dev/null +++ b/internal/provider/repo/details.go @@ -0,0 +1,62 @@ +package repo + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// Details are how a file is encrypted and for whom, and what git knows of +// it: a plain .env file that git tracks, or could, is marked. +func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) { + r, ok := p.repo(c.Source.ID) + if !ok { + return nil, provider.ErrUnsupported + } + rel, err := filepath.Rel(r.root, c.ID) + if err != nil { + return nil, err + } + var out []provider.Attr + meta, isSops := readMeta(c.ID) + if isSops { + out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")}) + for i, k := range meta.recipients { + label := "" + if i == 0 { + label = "keys" + } + out = append(out, provider.Attr{Label: label, Value: k}) + } + if !meta.modified.IsZero() { + out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")}) + } + } else { + out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"}) + } + + git := func(args ...string) (string, bool) { + b, err := exec.CommandContext(ctx, "git", append([]string{"-C", r.root}, args...)...).Output() + return strings.TrimSpace(string(b)), err == nil + } + switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); { + case tracked && !isSops: + out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true}) + case tracked: + out = append(out, provider.Attr{Label: "git", Value: "tracked"}) + default: + if _, ignored := git("check-ignore", "-q", "--", rel); ignored { + out = append(out, provider.Attr{Label: "git", Value: "ignored"}) + } else { + out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops}) + } + return out, nil + } + if last, ok := git("log", "-1", "--format=%h · %ar", "--", rel); ok && last != "" { + out = append(out, provider.Attr{Label: "last commit", Value: last}) + } + return out, nil +} diff --git a/internal/provider/repo/repo.go b/internal/provider/repo/repo.go new file mode 100644 index 0000000..424a02a --- /dev/null +++ b/internal/provider/repo/repo.go @@ -0,0 +1,1080 @@ +// 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 +} + +// File is the file c is, and the line o's key is on. +func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) { + path := c.ID + if path == "" { // made from a link + path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name)) + } + f := provider.File{Path: path} + if _, isSops := readMeta(path); isSops { + f.Sops = p.s.Sops + } + if o == nil { + return f, nil + } + b, err := os.ReadFile(path) + if err != nil { + return f, err + } + keys, err := parseKeys(path, b) + if err != nil { + return f, nil // still worth opening, at the top + } + _, extract, _ := strings.Cut(o.ID, "\x00") + for _, k := range keys { + if k.extract == extract || (extract == "" && k.name == o.Name) { + f.Line = k.line + break + } + } + return f, 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, … + recipients []string // "age age1…", "azure_kv https://kv…/keys/sops" + modified time.Time +} + +// recipient names a key a file is encrypted for, from its sops metadata: +// public, never a secret. +func recipient(method string, f map[string]string) string { + var v string + switch method { + case "age": + v = f["recipient"] + case "pgp": + v = f["fp"] + case "kms": + v = f["arn"] + case "gcp_kms": + v = f["resource_id"] + case "azure_kv": + v = strings.TrimSuffix(f["vault_url"], "/") + "/keys/" + f["name"] + case "hc_vault": + v = strings.TrimSuffix(f["vault_address"], "/") + " " + f["engine_path"] + "/" + f["key_name"] + } + return method + " " + v +} + +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) + for _, e := range v { + fields := map[string]string{} + if em, ok := e.(map[string]any); ok { + for fk, fv := range em { + fields[fk] = fmt.Sprint(fv) + } + } + m.recipients = append(m.recipients, recipient(k, fields)) + } + } + } + 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) + m.recipients = append(m.recipients, envRecipients(text, 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 +} + +// envRecipients are method's keys in a dotenv or ini sops file, whose +// metadata is flattened: sops_age__list_0__map_recipient=age1…. +func envRecipients(text, method string) []string { + prefix := "sops_" + method + "__list_" + var order []string + keys := map[string]map[string]string{} + for line := range strings.Lines(text) { + k, v, ok := strings.Cut(strings.TrimSpace(line), "=") + rest, found := strings.CutPrefix(strings.TrimSpace(k), prefix) + if !ok || !found { + continue + } + n, field, ok := strings.Cut(rest, "__map_") + if !ok { + continue + } + if keys[n] == nil { + keys[n] = map[string]string{} + order = append(order, n) + } + keys[n][field] = strings.TrimSpace(v) + } + out := make([]string, len(order)) + for i, n := range order { + out[i] = recipient(method, keys[n]) + } + return out +} + +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 + line int // 1-based +} + +// 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 i, 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["), line: i + 1}) + } + return out, nil + case "ini": + var out []key + section := "" + for i, 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["), line: i + 1}) + } + } + 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["), line: n.Line}) + } +} + +// 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..55b11dd --- /dev/null +++ b/internal/provider/repo/repo_test.go @@ -0,0 +1,523 @@ +package repo + +import ( + "context" + "errors" + "maps" + "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) + } +} + +func TestFile(t *testing.T) { + root := gitRepo(t, map[string]string{ + ".gitignore": ".env\n", + "secrets/app.yaml": sopsYAML, + }) + os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nTOKEN=x\n\nURL=y\n"), 0o600) + p := New(Settings{Dir: root, Sops: "/opt/sops"}) + ctx := context.Background() + srcs, _ := p.Sources(ctx) + files, err := p.Containers(ctx, srcs[0]) + if err != nil { + t.Fatal(err) + } + lines := map[string]int{} + for _, c := range files { + f, err := p.File(c, nil) + if err != nil || f.Path != filepath.Join(root, c.Name) || f.Line != 0 { + t.Errorf("%s: %+v, %v", c.Name, f, err) + } + if sops := f.Sops != ""; sops != (c.Name == "secrets/app.yaml") || (sops && f.Sops != "/opt/sops") { + t.Errorf("%s: sops %q", c.Name, f.Sops) + } + keys, _ := p.Objects(ctx, c, provider.TypeSecret) + for _, k := range keys { + f, err := p.File(c, &k) + if err != nil { + t.Fatal(err) + } + lines[c.Name+" "+k.Name] = f.Line + } + } + want := map[string]int{ + ".env TOKEN": 2, ".env URL": 4, + "secrets/app.yaml db.password": 2, "secrets/app.yaml db.port": 3, + "secrets/app.yaml list[0]": 5, "secrets/app.yaml public_unencrypted": 6, + } + if !maps.Equal(lines, want) { + t.Errorf("lines %v\nwant %v", lines, want) + } +} + +func TestFileDetails(t *testing.T) { + root := gitRepo(t, map[string]string{ + ".gitignore": ".env\n", + "secrets/app.yaml": sopsYAML, + "prod.env": sopsEnv, + "tracked.env": "TOKEN=x\n", // a plain .env in git: worth a look + }) + os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=x\n"), 0o600) + os.WriteFile(filepath.Join(root, "local.env"), []byte("TOKEN=x\n"), 0o600) + p := New(Settings{Dir: root}) + ctx := context.Background() + srcs, _ := p.Sources(ctx) + details := func(name string) map[string]provider.Attr { + t.Helper() + attrs, err := p.Details(ctx, provider.Container{Source: srcs[0], ID: filepath.Join(root, name), Name: name}) + if err != nil { + t.Fatal(err) + } + out := map[string]provider.Attr{} + for _, a := range attrs { + out[a.Label] = a + } + return out + } + + d := details("secrets/app.yaml") + if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" || + !strings.HasSuffix(d["last commit"].Value, " ago") || strings.Contains(d["last commit"].Value, ":") || d["encrypted"].Value == "" { + t.Errorf("sops yaml: %+v", d) + } + if d := details("prod.env"); d["keys"].Value != "age age1xxxx" { + t.Errorf("sops env: %+v", d) + } + if d := details("tracked.env"); d["encryption"].Value != "none: plain text" || !d["git"].Warn { + t.Errorf("tracked plain env: %+v", d) + } + if d := details(".env"); d["git"].Value != "ignored" || d["git"].Warn { + t.Errorf("ignored env: %+v", d) + } + if d := details("local.env"); !d["git"].Warn || !strings.Contains(d["git"].Value, "not ignored") { + t.Errorf("untracked env: %+v", d) + } +} 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..faf9dd0 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,342 @@ +// 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 }) +} + +// Place is where lazysecrets was left when it last ran in dir. +func (s *Store) Place(dir string) (Place, bool) { + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.data.Places[dir] + return p, ok +} + +// SetPlace remembers where lazysecrets was left in dir. +func (s *Store) SetPlace(dir string, p Place) error { + return s.save(func(d *file) { + if d.Places == nil { + d.Places = map[string]Place{} + } + d.Places[dir] = p + for len(d.Places) > maxPlaces { + oldest := "" + for k, v := range d.Places { + if oldest == "" || v.At.Before(d.Places[oldest].At) { + oldest = k + } + } + delete(d.Places, oldest) + } + }) +} + +// 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"` + // Places is where lazysecrets was left, by the folder it ran in. + Places map[string]Place `json:"places,omitempty"` +} + +// Place is what was selected when lazysecrets quit: an item's link, and +// the account and source it was read through. +type Place struct { + Link string `json:"link"` + Account string `json:"account"` + Source string `json:"source"` + At time.Time `json:"at"` +} + +// maxPlaces bounds Places: the folders used most recently are kept. +const maxPlaces = 100 + +// 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..9deebef --- /dev/null +++ b/internal/ui/app/app.go @@ -0,0 +1,1843 @@ +// 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" + "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" + + "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 + // paneStatus is on top of the left panes; after the right pane in + // the tab order (status.go). + paneStatus + 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 + slotDetails // a file's details + 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 + // ConfigFiles and StatePath are where lazysecrets reads its config + // and keeps its state, for the status pane. + ConfigFiles []string + StatePath string + // Changelog is CHANGELOG.md, for the status pane. + Changelog string + // CacheFile keeps what was loaded between runs; "" keeps it for this + // run only. + CacheFile string + // Dir is the folder lazysecrets runs in: it goes back to where it was + // left there, unless it was started on a link. "" doesn't. + Dir string + // 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 + // fields is the fourth pane for anything but a secret; lastLeft is + // the left pane it describes while the fourth has focus (fields.go). + fields panes.Fields + lastLeft pane + details map[string]detailsState // by containerKey + // errLog is this session's errors, newest first; loggedFlash the + // footer's text when last looked at (errlog.go). + errLog []loggedErr + loggedFlash string + getenv func(string) string + nvimOpen func(server, path string, line int) error // edit.go + now func() time.Time + // The cache file, whether the cache changed since it was saved, and + // when each provider's Sources list was loaded (persist.go). + cachePath string + // configFiles and statePath are for the status pane (status.go). + configFiles []string + statePath string + statusW int // the status pane's width: the left column's, zoomed or not + changelog []panes.Field + cacheDirty bool + sourcesAt map[string]time.Time + placeDir string + savedPlace string // the link last saved as the place + + // 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, + getenv: os.Getenv, + nvimOpen: nvimOpen, + cache: newCache(), + details: map[string]detailsState{}, + fields: panes.NewFields(), + } + 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, m.detail.CopyKey = keys.Name(m.keys.Reveal), keys.Name(m.keys.Versions), keys.Name(m.keys.Copy) + 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 + + m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{} + m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog) + if m.cachePath != "" { + m.loadCacheFile() + if len(m.sourceResults) > 0 { + m.rebuildSources() + m.sources.Loading = true // reloading what the file had + m.sources.Age = m.cachedAge(m.oldestSources()) + } + } + if m.placeDir != "" && opts.Open == nil { + m.restorePlace() + } + 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(), tea.RequestBackgroundColor} + if m.cachePath != "" || m.placeDir != "" { + cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })) + } + 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.BackgroundColorMsg: + m.st = m.st.OnBackground(msg.Color, msg.IsDark()) + return m, nil + 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 editDoneMsg: + cmds = append(cmds, m.handleEditDone(msg)) + + case persistTickMsg: + cmds = append(cmds, m.persistTick()) + + case persistedMsg: + m.handlePersisted(msg) + + case detailsMsg: + if msg.req == m.reqID[slotDetails] { + m.done(slotDetails) + } + if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) { + delete(m.details, msg.key) // tried again when shown again + } else { + m.details[msg.key] = detailsState{attrs: msg.attrs, err: msg.err} + } + + 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) + if src, ok := m.selectedSource(); ok { + m.logErrOf(src.Name, 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.cacheDirty = true + 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) + if c, ok := m.containers.Selected(); ok { + m.logErrOf(c.Name, 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()} + m.cacheDirty = true + 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.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, 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.cacheDirty = true + 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.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) + m.detail.SetDescribeError(msg.err) + } else { + m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()} + m.cacheDirty = true + 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.loginFor() != nil) + if m.focus != paneDetail { + m.lastLeft = m.focus + } + m.logFlash() + cmds = append(cmds, m.syncFields()) + 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): + // The four main panes, round; the status pane (0) leads into them. + m.focus = (m.focus + 1) % paneStatus + if r, ok := m.sources.Selected(); ok && m.focus == paneContainers && r.kind == rowGroup { + m.focus = paneDetail // an account's row: nothing between + } + case key.Matches(msg, k.Details): + m.focus = paneDetail // what's selected, on the right + case key.Matches(msg, k.PrevPane): + if m.focus == paneDetail { + m.focus = m.lastLeft // back to what it describes + } else { + m.focus = (m.focus + paneStatus - 1) % paneStatus + } + 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 key.Matches(msg, k.Pane0): + m.focus = paneStatus + case m.focus == paneStatus && key.Matches(msg, k.Enter): + m.focus = paneDetail // its rows are on the right + case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup(): + // folded or unfolded a provider group + case m.focus == paneDetail && key.Matches(msg, k.Enter) && m.fieldLink() != "": + r, err := ref.Parse(m.fieldLink()) + if err == nil { + f, _ := m.fields.Selected() + m.startNav(r, actGo, f.Row) + } + 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.focus == paneDetail) { + m.closeVersions() + } else if m.focus == paneStatus { + m.focus = paneSources + } 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.Edit): + return m.edit(), false + case key.Matches(msg, k.Login): + if auth := m.loginFor(); auth != nil { + c := exec.Command(auth.Login[0], 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 + case paneDetail: + if m.fields.Shown() { + return &m.fields + } + } + 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: + if m.fields.Shown() { + m.fields.Move(delta) + } else { + 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 && m.fields.Shown() && top: + m.fields.Top() + case m.focus == paneDetail && m.fields.Shown(): + m.fields.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() + m.details = map[string]detailsState{} + 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() + m.containers.Age = m.cachedAge(e.at) + 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() + m.secrets.Age = m.cachedAge(e.at) + 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() +} + +// cachedAge is how old a list loaded at at is, if it's older than +// cacheTTL: the panes say it while it reloads. +func (m Model) cachedAge(at time.Time) string { + if d := m.now().Sub(at); d >= cacheTTL { + return ago(d) + } + return "" +} + +// oldestSources is when the oldest of the cached Sources lists loaded. +func (m Model) oldestSources() time.Time { + var oldest time.Time + for _, at := range m.sourcesAt { + if oldest.IsZero() || at.Before(oldest) { + oldest = at + } + } + return oldest +} + +func (m *Model) handleSources(msg sourcesMsg) tea.Cmd { + if msg.gen != m.sourcesGen { + return nil + } + m.sourcesPending-- + m.sourceResults[msg.provider] = msg + if msg.err == nil { + m.sourcesAt[msg.provider], m.cacheDirty = m.now(), true + } + + var errs []string + for _, p := range m.providers { + if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil { + m.noteErr(r.err) + m.logErrOf(p.Label(), r.err) + errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err)) + } + } + // Said once, when they change; after that the Details pane says it + // for the selected account. + if status := strings.Join(errs, " · "); status != m.status { + m.status = status + if status != "" { + m.flash = flash{text: m.problemText(msg.provider, status), isErr: true} + } + } + 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 { + if m.focus == paneDetail && m.fields.Shown() { + if f, ok := m.fields.Selected(); ok { + return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false) + } + return nil + } + if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources { + return m.copyText(a.CopyLabel, a.Copy, false) + } + 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 { + case m.focus == paneDetail && m.fields.Shown(): + f, _ := m.fields.Selected() + if f.URL == "" { + m.flash = warning("nothing to open in the browser here") + return nil + } + name, url, used = f.Label, f.URL, func() {} + default: + name, url, used = m.openTarget() + } + if name == "" { + return nil + } + return m.openPage(name, url, used) +} + +// openTarget is the focused pane's selected item, for the browser. +func (m *Model) openTarget() (name, url string, 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) } + } + return name, url, used +} + +// openPage opens url in the browser, or copies it over SSH. +func (m *Model) openPage(name, url string, used func()) tea.Cmd { + 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, +// saying so once for each account. +func (m *Model) noteErr(err error) { + var auth *provider.AuthError + if errors.As(err, &auth) { + if m.auth == nil || m.auth.Who() != auth.Who() { + m.flash = flash{text: m.authText(auth), isErr: true} + } + m.auth = auth + } +} + +// authText says which account needs signing in, and how. +func (m Model) authText(auth *provider.AuthError) string { + if len(auth.Login) == 0 { + return fmt.Sprintf("%s: sign-in required: %v", auth.Who(), auth.Err) + } + return fmt.Sprintf("%s: sign-in required (not signed in or session expired)%s", + auth.Who(), withKey(m.keys.Login, " · press %s to run "+strings.Join(auth.Login, " "))) +} + +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 { + revealed := m.detail.State() == panes.ValueShown + help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.ShortHelp(), 2) + } + var lines []string + 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)) + } + 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 +} + +// selectedProblem is why the selected Sources row's account has nothing +// to show, if it failed: for the Details pane. +func (m Model) selectedProblem() string { + r, ok := m.sources.Selected() + if !ok || (r.kind != rowGroup && r.kind != rowSource) { + return "" + } + res, done := m.sourceResults[r.provider] + if !done || res.err == nil { + return "" + } + return m.problemText(r.provider, res.err.Error()) +} + +// problemText says what went wrong for a provider: for a sign-in, which +// account and how to sign in again; else err as given. +func (m Model) problemText(providerID, err string) string { + var auth *provider.AuthError + if r, ok := m.sourceResults[providerID]; ok && errors.As(r.err, &auth) { + return m.authText(auth) + } + return err +} + +// fieldLink is the link of the selected Fields row, if it has one. +func (m Model) fieldLink() string { + if f, ok := m.fields.Selected(); ok && m.fields.Shown() { + return f.Link + } + return "" +} + +// loginFor is the sign-in L runs: the selected account's, if it needs +// one, else the last one asked for. +func (m Model) loginFor() *provider.AuthError { + if f, ok := m.fields.Selected(); ok && m.focus == paneDetail && m.fields.Shown() && f.Provider != "" { + var auth *provider.AuthError + if res := m.sourceResults[f.Provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 { + return auth + } + } + if r, ok := m.sources.Selected(); ok { + var auth *provider.AuthError + if res := m.sourceResults[r.provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 { + return auth + } + } + if m.auth != nil && len(m.auth.Login) > 0 { + return m.auth + } + return nil +} + +// selectedAccount describes the account of the selected provider row, if +// its provider can. +func (m Model) selectedAccount() (provider.Account, bool) { + r, ok := m.sources.Selected() + if !ok || r.kind != rowGroup { + return provider.Account{}, false + } + if res := m.sourceResults[r.provider]; res.err != nil { + return provider.Account{}, false // the problem shows instead + } + a, ok := m.byID[r.provider].(provider.Accounts) + if !ok { + return provider.Account{}, false + } + return a.Account() +} diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go new file mode 100644 index 0000000..79be8ed --- /dev/null +++ b/internal/ui/app/app_test.go @@ -0,0 +1,1344 @@ +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" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +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: 33}) // 30 for the main panes, 3 for status + 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) + } + d.keys("3") + if !strings.Contains(d.screen(), "source "+azure+" Azure / platform-prod") { + t.Errorf("detail source line missing icon:\n%s", d.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 the 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)}) + d.keys("3") // the fourth pane follows focus: the secret + 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) + d.keys("3") // the fourth pane follows focus: the secret + screen := d.screen() + for _, want := range []string{ + "versions 3 · v to browse", + " 3f9c2a 2026-09-01 current", + " 91d3e0 2026-03-01 expired", + " 0a77c4 2025-09-01 disabled", + } { + if !strings.Contains(screen, want) { + t.Errorf("missing %q:\n%s", want, screen) + } + } + if strings.Contains(screen, "▸ 3f9c2a") { + t.Errorf("a cursor on a list that can't be moved in:\n%s", 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(), "name kv-restricted") { + 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}, {"tab", paneSources}, {"shift+tab", paneDetail}, + // l: what's selected, on the right; h: back to it. + {"2", paneContainers}, {"l", paneDetail}, {"h", paneContainers}, {"h", paneSources}, {"l", 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": {"z"}, "versions": {}}}); err != nil { + t.Fatal(err) + } + d := newDriverOpts(t, Options{Keys: &km}) + d.keys("3") + scr := d.screen() + if !strings.Contains(scr, "z 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), "z show") { + t.Errorf("help bar: %q", lastLine(scr)) + } + d.keys("z") + if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") { + t.Errorf("help bar with the value shown: %q", bar) + } + d.keys("z") + 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("z") + if !strings.Contains(d.screen(), "correct-horse-battery-staple") { + t.Errorf("z 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()) + } +} + +// signedOut is a second Azure account whose sign-in has expired. +type signedOut struct{ provider.Provider } + +func (signedOut) ID() string { return "Azure:admin@contoso.com/t1" } +func (signedOut) Label() string { return "Azure · admin" } +func (signedOut) Sources(context.Context) ([]provider.Source, error) { + return nil, &provider.AuthError{Kind: provider.KindAzure, Account: "Azure · admin", + Login: []string{"az", "login", "--tenant", "t1"}, Err: errors.New("AADSTS70043")} +} + +func TestSignInMessageOnceThenInDetails(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{CollapseGroups: false}, fake.Azure(0), signedOut{fake.Azure(0)}) + hint := "Azure · admin: sign-in required" + if !strings.Contains(lastLines(d.screen(), 2), hint) { // the footer: one line + t.Fatalf("not said at start:\n%s", d.screen()) + } + d.keys("j") + if strings.Contains(lastLines(d.screen(), 2), hint) { + t.Errorf("still in the footer after moving:\n%s", d.screen()) + } + + // On the account's group, Details says it, and L signs in to it. + d.keys("G") + if r, _ := d.m.sources.Selected(); r.provider != "Azure:admin@contoso.com/t1" { + t.Fatalf("selected %+v", r) + } + if scr := d.screen(); !strings.Contains(scr, "problem") || !strings.Contains(scr, hint) || !strings.Contains(scr, "press L to run az login") { + t.Errorf("not in Details:\n%s", d.screen()) + } + if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" { + t.Errorf("L signs in to %+v", a) + } + + // Elsewhere Details is as usual. + d.keys("g") + if strings.Contains(d.screen(), hint) { + t.Errorf("shown for another account:\n%s", d.screen()) + } +} + +// lastLines is the bottom n lines of a screen. +func lastLines(scr string, n int) string { + lines := strings.Split(strings.TrimRight(scr, "\n"), "\n") + return strings.Join(lines[max(0, len(lines)-n):], "\n") +} + +// described is an account that can say who it is. +type described struct{ provider.Provider } + +func (described) Account() (provider.Account, bool) { + return provider.Account{ + Fields: []provider.Attr{{Label: "name", Value: "Alice Example"}, {Label: "object ID", Value: "oid-123"}}, + Copy: "oid-123", CopyLabel: "object ID", + }, true +} + +func TestAccountInDetails(t *testing.T) { + t.Parallel() + d := newDriver(t, described{fake.Azure(0)}) + d.keys("g") // the group's row + if r, _ := d.m.sources.Selected(); r.kind != rowGroup { + t.Fatalf("not on the group: %+v", r) + } + if scr := d.screen(); !strings.Contains(scr, "Alice Example") || !strings.Contains(scr, "oid-123") { + t.Errorf("account not in Details:\n%s", scr) + } + d.keys("y") + if got := d.clip().content; got != "oid-123" { + t.Errorf("y copied %q", got) + } + d.keys("j") + if strings.Contains(d.screen(), "Alice Example") { + t.Errorf("still shown on a subscription:\n%s", d.screen()) + } +} + +func TestAccountPane(t *testing.T) { + t.Parallel() + d := newDriver(t, described{fake.Azure(0)}) + d.keys("g", "l") // the account's row, then its pane + if d.m.focus != paneDetail { + t.Fatalf("focus %v, want the fourth pane", d.m.focus) + } + scr := d.screen() + if !strings.Contains(scr, "Alice Example") { + t.Errorf("account not shown:\n%s", scr) + } + bar := lastLine(scr) + if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") { + t.Errorf("help bar: %q", bar) + } + d.keys("j", "y") + if got := d.clip().content; got != "oid-123" { + t.Errorf("y on the object ID copied %q", got) + } + + // / filters it, labels too. + d.keys("/") + d.typeText("object") + d.keys("enter") + if scr := d.screen(); !strings.Contains(scr, "oid-123") || strings.Contains(scr, "Alice Example") { + t.Errorf("filtered:\n%s", scr) + } + d.keys("esc") + if !strings.Contains(d.screen(), "Alice Example") { + t.Errorf("filter not cleared:\n%s", d.screen()) + } + + d.keys("h") + if d.m.focus != paneSources { + t.Errorf("back to %v, want Sources", d.m.focus) + } +} + +func TestDetailsFollowFocus(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3") // a secret + if !strings.Contains(d.screen(), "version 3f9c2a") { + t.Fatalf("secret not shown:\n%s", d.screen()) + } + d.keys("2") // its vault + if scr := d.screen(); !strings.Contains(scr, "name kv-app-prod") || strings.Contains(scr, "version 3f9c2a") { + t.Errorf("vault not described:\n%s", scr) + } + d.keys("1") // its subscription + if scr := d.screen(); !strings.Contains(scr, "name platform-prod") || !strings.Contains(scr, "vaults 3") { + t.Errorf("subscription not described:\n%s", scr) + } + // Into the fourth pane: it keeps describing the subscription, and + // shift+tab goes back there. + d.keys("4") + if !strings.Contains(d.screen(), "name platform-prod") { + t.Errorf("lost the subscription:\n%s", d.screen()) + } + d.keys("shift+tab") + if d.m.focus != paneSources { + t.Errorf("back to %v", d.m.focus) + } + // A value revealed from a left pane stays in view until hidden. + d.keys("space") + if !strings.Contains(d.screen(), "correct-horse-battery-staple") { + t.Errorf("value not shown:\n%s", d.screen()) + } +} + +func TestStatusLineCountsRepos(t *testing.T) { + t.Parallel() + d := newDriver(t, fake.Azure(0), fake.Repos()) + if line := strings.Split(d.screen(), "\n")[1]; !strings.Contains(line, "1 account · 3 repos") { + t.Errorf("status line: %q", line) + } + d = newDriver(t, fake.Azure(0)) + if line := strings.Split(d.screen(), "\n")[1]; strings.Contains(line, "repo") { + t.Errorf("repos counted without any: %q", line) + } +} + +func TestStatusPane(t *testing.T) { + t.Parallel() + var opened []string + d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json", + OpenURL: func(u string) error { opened = append(opened, u); return nil }}, + fake.Azure(0), signedOut{fake.Azure(0)}) + if !strings.Contains(d.screen(), "2 accounts · 1 need sign-in") { + t.Errorf("status line:\n%s", d.screen()) + } + d.keys("0") + if d.m.focus != paneStatus { + t.Fatalf("focus %v", d.m.focus) + } + scr := d.screen() + for _, want := range []string{logo[2], "version v0.1.0", "GPL-3.0-or-later", "Azure · 2 subscriptions", "Azure · admin · sign-in required", + "/cfg/config.yaml", "/state/state.json", "lazysecrets/…/docs"} { + if !strings.Contains(scr, want) { + t.Errorf("missing %q:\n%s", want, scr) + } + } + // Into its rows: L signs in to the failing account, y copies a path, + // o opens a link. + d.keys("enter") + if d.m.focus != paneDetail { + t.Fatalf("enter: focus %v", d.m.focus) + } + d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Provider == "Azure:admin@contoso.com/t1" }) + if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" { + t.Errorf("L signs in to %+v", a) + } + d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "config" }) + d.keys("y") + if got := d.clip().content; got != "/cfg/config.yaml" { + t.Errorf("y copied %q", got) + } + d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "docs" }) + d.keys("o") + if len(opened) != 1 || !strings.HasSuffix(opened[0], "/src/branch/main/docs") { + t.Errorf("o opened %q", opened) + } + d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "version" }) + d.keys("o") + if len(opened) != 2 || !strings.HasSuffix(opened[1], "/releases/tag/v0.1.0") { + t.Errorf("o on the version opened %q", opened) + } + d.keys("shift+tab") + if d.m.focus != paneStatus { + t.Errorf("back to %v", d.m.focus) + } +} + +// empty is an account with nothing in it, like Databricks without profiles. +type empty struct{ provider.Provider } + +func (empty) Sources(context.Context) ([]provider.Source, error) { return nil, nil } + +func TestGetStarted(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{}, signedOut{fake.Azure(0)}, empty{fake.Databricks(0)}) + scr := d.screen() + for _, want := range []string{"get started", "Azure az login", "L runs it here", + "databricks auth login --host", "lazysecrets --demo"} { + if !strings.Contains(scr, want) { + t.Errorf("missing %q:\n%s", want, scr) + } + } + d.keys("l", "j") + if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" { + t.Errorf("L signs in to %+v", a) + } + d.keys("y") + if got := d.clip().content; got != "az login" { + t.Errorf("y copied %q", got) + } + + // Once an account has something, an empty one says what it needs. + d = newDriverOpts(t, Options{}, fake.Azure(0), empty{fake.Databricks(0)}) + d.m.sources.SelectWhere(func(r sourceRow) bool { return r.kind == rowGroup && r.provider == fake.Databricks(0).ID() }) + d.send(nil) + if scr := d.screen(); strings.Contains(scr, "get started") || !strings.Contains(scr, "databricks auth login") { + t.Errorf("empty Databricks:\n%s", scr) + } +} + +// detailed is an account whose vaults have details. +type detailed struct{ provider.Provider } + +func (detailed) Details(_ context.Context, c provider.Container) ([]provider.Attr, error) { + return []provider.Attr{{Label: "access", Value: "Azure roles (RBAC)"}, {Label: "git", Value: "tracked", Warn: true}}, nil +} + +func TestContainerDetails(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{}, detailed{fake.Azure(0)}) + d.keys("2") + if scr := d.screen(); !strings.Contains(scr, "access Azure roles (RBAC)") || !strings.Contains(scr, "URI") { + t.Errorf("details:\n%s", scr) + } + if st := d.m.details[d.m.containers.SelectedID()]; st.loading || len(st.attrs) != 2 { + t.Errorf("state %+v", st) + } +} + +func TestExpiringInStatus(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0)) + d.keys("3", "]", "]") // load kv-app-prod's certificates + if !strings.Contains(d.screen(), "⚠ 1 expiring") { + t.Errorf("status line:\n%s", d.screen()) + } + d.keys("0") + scr := d.screen() + for _, want := range []string{"expiring ✗ expired old-tls · kv-app-prod", "in 1 of 3 vaults (ctrl+a in search loads all)"} { + if !strings.Contains(scr, want) { + t.Errorf("missing %q:\n%s", want, scr) + } + } + d.keys("3", "[", "[", "0", "enter") // elsewhere, then to it from the status pane + d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "expiring" }) + d.keys("enter") + if _, v, o := d.selected(); v != "kv-app-prod" || o != "old-tls" { + t.Errorf("enter went to %s/%s\n%s", v, o, d.screen()) + } +} + +func TestErrorLog(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{}, fake.Azure(0)) + d.keys("2", "j", "j", "3") // kv-restricted: its secrets can't be listed + d.m.flash = flash{text: "copy failed: no clipboard", isErr: true} + d.send(nil) + d.send(nil) // still showing: logged once + d.keys("0") + scr := d.screen() + if !strings.Contains(scr, "errors") || !strings.Contains(scr, "copy failed: no clipboard") || !strings.Contains(scr, "kv-restricted: 403 Forbidden") { + t.Errorf("errors not listed:\n%s", scr) + } + if n := len(d.m.errLog); n != 2 || !strings.HasPrefix(d.m.errLog[0].text, "copy failed") { + t.Errorf("log %+v", d.m.errLog) + } + d.m.logErr("403 Forbidden: again") // a new one, and an old one again: on top + d.m.logErr(d.m.errLog[2].text) + if len(d.m.errLog) != 3 || d.m.errLog[0].text == "403 Forbidden: again" { + t.Errorf("log %+v", d.m.errLog) + } +} diff --git a/internal/ui/app/cache.go b/internal/ui/app/cache.go new file mode 100644 index 0000000..3bfe3ab --- /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: never secret values or errors. It's kept between +// runs in the cache file (persist.go). +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..6631aca --- /dev/null +++ b/internal/ui/app/commands.go @@ -0,0 +1,321 @@ +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 { + if m.focus == paneStatus { + return nil + } + 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/edit.go b/internal/ui/app/edit.go new file mode 100644 index 0000000..420b38c --- /dev/null +++ b/internal/ui/app/edit.go @@ -0,0 +1,224 @@ +package app + +import ( + "cmp" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + + "git.kebcloud.no/keb/lazysecrets/internal/browser" + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// Opening a repo's file (e): SOPS files with sops edit, which decrypts +// them for the editor and encrypts them again after. Other files, inside +// nvim: in that nvim, once lazysecrets has quit; else in $VISUAL or +// $EDITOR, coming back after; else in the system's default app. On a +// secret, editors that can open at a line start on its key. + +type editDoneMsg struct { + c provider.Container + name string + err error +} + +// sopsUnchanged is sops edit's exit code when the file wasn't changed. +const sopsUnchanged = 200 + +// edit opens the selected file, or the file of the selected secret. +func (m *Model) edit() tea.Cmd { + if m.focus == paneDetail && m.fields.Shown() { + f, ok := m.fields.Selected() + if !ok || f.Path == "" { + m.flash = warning("nothing to open in an editor here") + return nil + } + name := filepath.Base(f.Path) + return m.openInEditor(provider.File{Path: f.Path}, func(err error) tea.Msg { return editDoneMsg{name: name, err: err} }) + } + var c provider.Container + var o *provider.Object + switch m.focus { + case paneSources: + m.flash = warning("select a file to open it") + return nil + case paneContainers: + sel, ok := m.containers.Selected() + if !ok { + return nil + } + c = sel + default: + sel, ok := m.detail.Secret() + if !ok { + return nil + } + c, o = sel.Container, &sel + } + files, ok := m.providerFor(c.Source).(provider.Files) + if !ok { + m.flash = warning("only SOPS and .env files in repos can be opened") + return nil + } + f, err := files.File(c, o) + if err != nil { + m.flash = flash{text: "open: " + err.Error(), isErr: true} + return nil + } + if o != nil { + m.usedObject(*o) + } else { + m.usedContainer(c) + } + name := filepath.Base(f.Path) + finished := func(err error) tea.Msg { return editDoneMsg{c: c, name: name, err: err} } + + if f.Sops != "" { + cmd := exec.Command(f.Sops, "edit", f.Path) + cmd.Dir = filepath.Dir(f.Path) // sops looks for .sops.yaml from here + // sops starts SOPS_EDITOR (else EDITOR, else vim) on the decrypted + // copy, which keeps the file's lines: start it on the key. + editor := cmp.Or(m.getenv("SOPS_EDITOR"), m.getenv("EDITOR"), "vim") + if at, ok := editorAtLine(editor, f.Line); ok { + cmd.Env = append(os.Environ(), "SOPS_EDITOR="+at) + } + m.flash = flash{} + return tea.ExecProcess(cmd, finished) + } + return m.openInEditor(f, finished) +} + +// plusLine are editors that open at a line with +N before the file. +var plusLine = map[string]bool{ + "vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true, + "micro": true, "kak": true, "mg": true, "ne": true, "joe": true, "vis": true, +} + +// editorAtLine is editor starting at line, for editors that take +N: +// sops adds the file after it. +func editorAtLine(editor string, line int) (string, bool) { + fields := strings.Fields(editor) + if line == 0 || len(fields) == 0 || !plusLine[filepath.Base(fields[0])] { + return "", false + } + return editor + " +" + strconv.Itoa(line), true +} + +// editorArgs opens f at its line, for editors known to take one. +func editorArgs(editor string, f provider.File) []string { + fields := strings.Fields(editor) + if f.Line == 0 || len(fields) == 0 { + return []string{f.Path} + } + at := f.Path + ":" + strconv.Itoa(f.Line) + switch base := filepath.Base(fields[0]); { + case plusLine[base]: + return []string{"+" + strconv.Itoa(f.Line), f.Path} + case base == "hx" || base == "helix" || base == "subl" || base == "zed": + return []string{at} + case base == "code" || base == "codium" || base == "cursor" || base == "windsurf": + return []string{"-g", at} + } + return []string{f.Path} +} + +// handleEditDone reports how editing went and reloads the file's secrets: +// their names, lines or values may have changed. +func (m *Model) handleEditDone(msg editDoneMsg) tea.Cmd { + var exit *exec.ExitError + switch { + case errors.As(msg.err, &exit) && exit.ExitCode() == sopsUnchanged: + m.flash = flash{text: msg.name + " unchanged"} + return nil + case errors.Is(msg.err, exec.ErrNotFound): + m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error() + " (is sops installed?)", isErr: true} + return nil + case msg.err != nil: + m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true} + return nil + } + m.flash = done("closed " + msg.name) + if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) { + m.hideValue() + m.secrets.Refresh() + return m.loadObjects(c, m.objType, m.selContainer) + } + return nil +} + +// nvimLua opens a file in the nvim lazysecrets runs in. If lazysecrets is +// that terminal's own process (the nvim plugin, or :terminal lazysecrets), +// it waits for the terminal to close, so the file opens in the window +// below rather than in one about to close. Otherwise (a shell in a +// terminal) it opens it in the previous window now. The autocmd isn't +// the buffer's own: snacks wipes the buffer when the terminal closes, +// which would delete it before it runs. +const nvimLua = `local path, line, pid = ... +local function open() + local ok, err = pcall(vim.cmd, "edit " .. vim.fn.fnameescape(path)) + if not ok then vim.notify("lazysecrets: " .. err, vim.log.levels.ERROR) return end + if line > 0 then pcall(vim.api.nvim_win_set_cursor, 0, { line, 0 }) vim.cmd("normal! zz") end +end +for _, b in ipairs(vim.api.nvim_list_bufs()) do + if vim.bo[b].buftype == "terminal" and vim.b[b].terminal_job_pid == pid then + vim.api.nvim_create_autocmd("TermClose", { callback = function(ev) + if ev.buf ~= b then return end + vim.defer_fn(open, 50) + return true + end }) + return + end +end +pcall(vim.cmd, "wincmd p") +open()` + +// nvimOpen asks the nvim at server to open path at line. +func nvimOpen(server, path string, line int) error { + q := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } + lua := strings.ReplaceAll(nvimLua, "\n", " ") + expr := "luaeval('load(_A[1])(_A[2], _A[3], _A[4])', [" + q(lua) + ", " + q(path) + ", " + + strconv.Itoa(line) + ", " + strconv.Itoa(os.Getpid()) + "])" + out, err := exec.Command("nvim", "--server", server, "--remote-expr", expr).CombinedOutput() + if err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return errors.New(msg) + } + return err + } + return nil +} + +// openInEditor opens f: in the nvim lazysecrets runs in, quitting; else +// in $VISUAL or $EDITOR, coming back after; else in the default app. +func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.Cmd { + name := filepath.Base(f.Path) + if server := m.getenv("NVIM"); server != "" { + open := m.nvimOpen + return func() tea.Msg { + if err := open(server, f.Path, f.Line); err != nil { + return finished(err) + } + return tea.QuitMsg{} + } + } + if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" { + // Through the shell, so EDITOR can carry arguments ("code -w"). + args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...) + m.flash = flash{} + return tea.ExecProcess(exec.Command("/bin/sh", args...), finished) + } + switch err := m.openURL(f.Path); { + case errors.Is(err, browser.ErrRemote): + m.flash = warning("set $EDITOR to open files over SSH") + case err != nil: + m.flash = flash{text: "open: " + err.Error(), isErr: true} + default: + m.flash = done("opened " + name) + } + return nil +} diff --git a/internal/ui/app/errlog.go b/internal/ui/app/errlog.go new file mode 100644 index 0000000..9d108b3 --- /dev/null +++ b/internal/ui/app/errlog.go @@ -0,0 +1,64 @@ +package app + +import ( + "errors" + "slices" + "strings" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +// The status pane lists this session's errors, newest first: what a pane +// showed (a list that couldn't load) and what the footer flashed (a copy +// that failed), which go when you move on. Only the first line of each, +// and never kept after quitting. + +// maxErrors is how many errors are kept. +const maxErrors = 20 + +type loggedErr struct { + at time.Time + text string +} + +// logErr records text; an error already there moves to the top, with +// the time it happened again. +func (m *Model) logErr(text string) { + text, _, _ = strings.Cut(strings.TrimSpace(text), "\n") + if text == "" { + return + } + m.errLog = slices.DeleteFunc(m.errLog, func(e loggedErr) bool { return e.text == text }) + m.errLog = append([]loggedErr{{m.now(), text}}, m.errLog[:min(len(m.errLog), maxErrors-1)]...) +} + +// logErrOf records err, about what, except a sign-in, which the footer +// and the account's details say. +func (m *Model) logErrOf(what string, err error) { + var auth *provider.AuthError + if !errors.As(err, &auth) { + m.logErr(what + ": " + err.Error()) + } +} + +// logFlash records an error the footer is showing, once. +func (m *Model) logFlash() { + if m.flash.isErr && m.flash.text != m.loggedFlash { + m.logErr(m.flash.text) + } + m.loggedFlash = m.flash.text +} + +// errorFields are the status pane's rows for them. +func (m Model) errorFields() []panes.Field { + out := make([]panes.Field, len(m.errLog)) + for i, e := range m.errLog { + out[i] = panes.Field{Value: e.at.Format("15:04") + " " + e.text, Err: true} + } + if len(out) > 0 { + out[0].Label = "errors" + } + return out +} diff --git a/internal/ui/app/expiring.go b/internal/ui/app/expiring.go new file mode 100644 index 0000000..dea864f --- /dev/null +++ b/internal/ui/app/expiring.go @@ -0,0 +1,75 @@ +package app + +import ( + "cmp" + "fmt" + "slices" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ref" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +// The status pane lists what has expired or will soon, across every vault +// loaded (opened, searched, or from the cache): expiry dates come with +// the lists, so this makes no requests. enter on one goes to it. + +type expiringItem struct { + o provider.Object + row string // its source's row key, to go to it +} + +// expiringItems are the loaded objects that have expired or will within +// the warning period, soonest first. +func (m Model) expiringItems() []expiringItem { + var out []expiringItem + for _, p := range m.providers { + for _, src := range m.sourceResults[p.ID()].sources { + row := sourceRowKey(p.ID(), src.ID) + for _, c := range m.cache.containers[row].value { + for _, t := range c.ObjectTypes() { + for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value { + if state, _ := m.expiry(o); state != notExpiring { + out = append(out, expiringItem{o, row}) + } + } + } + } + } + } + slices.SortFunc(out, func(a, b expiringItem) int { + return cmp.Or(a.o.Expires.Compare(b.o.Expires), cmp.Compare(a.o.Name, b.o.Name)) + }) + return out +} + +// expiringFields are the status pane's rows for them, and how much of +// everything they cover. +func (m Model) expiringFields() []panes.Field { + items := m.expiringItems() + var out []panes.Field + for _, it := range items { + state, left := m.expiry(it.o) + mark := "⚠ " + daysLeft(left) + if state == expired { + mark = "✗ expired" + } + f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row} + if r, err := ref.ForObject(it.o, ""); err == nil { + f.Link = r.String() + } + out = append(out, f) + } + cov := m.coverageOf(ofKind(provider.KindAzure)) + switch { + case cov.vaults == 0 && len(items) == 0: + return nil + case !cov.complete(): + out = append(out, panes.Field{Value: fmt.Sprintf("in %d of %d vaults", cov.vaultsLoaded, cov.vaults) + + withKey(m.keys.SearchLoadAll, " (%s in search loads all)"), Info: true}) + case len(items) == 0: + out = append(out, panes.Field{Value: fmt.Sprintf("nothing within %d days", int(m.expiryWarning.Hours()/24)), Info: true}) + } + out[0].Label = "expiring" + return out +} 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..9c1dd84 --- /dev/null +++ b/internal/ui/app/favourites_test.go @@ -0,0 +1,436 @@ +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") + // On both accounts' rows, and in the selected one's details. + if n := strings.Count(d.screen(), "shared note"); n != 3 { + t.Errorf("note shown %d times, want 3:\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/fields.go b/internal/ui/app/fields.go new file mode 100644 index 0000000..1aa7e63 --- /dev/null +++ b/internal/ui/app/fields.go @@ -0,0 +1,224 @@ +package app + +import ( + "errors" + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +// The fourth pane shows Fields for anything but a secret. On a provider's +// row: its account, from what the provider knows locally. + +// A file's details beyond its listing load once a +// session, when the cursor rests on it, and again with refresh. They're +// extra: moving on cancels the load, and search never waits for them. +type detailsState struct { + attrs []provider.Attr + err error + loading bool +} + +type detailsMsg struct { + req int + key string + attrs []provider.Attr + err error +} + +// detailOf is the pane the fourth pane describes: the one in focus, or, +// with the fourth in focus, the one it was entered from. +func (m Model) detailOf() pane { + if m.focus != paneDetail { + return m.focus + } + return m.lastLeft +} + +// syncFields shows the Fields pane for what the focused pane has selected, +// unless that's a secret (or a vault with the Secrets pane in focus), which +// the secret pane shows, as it does a revealed value whatever the focus. +func (m *Model) syncFields() tea.Cmd { + if m.detail.State() != panes.ValueHidden { + // A value revealed (or being fetched) from any pane stays in + // view until it's hidden. + m.fields.Hide() + return nil + } + m.fields.Header, m.fields.HeaderShort = nil, "" + switch m.detailOf() { + case paneSources: + r, ok := m.sources.Selected() + switch { + case m.nothingFound(): + m.fields.Show("get started", m.getStartedFields()) + case !ok: + m.fields.Show("", nil) + case m.selectedProblem() != "": + m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem())) + case r.kind == rowGroup: + m.showAccount(r) + case r.kind == rowSource: + m.fields.Show(r.source.Name, m.sourceFields(r.source)) + default: + m.fields.Show("", nil) + } + case paneStatus: + m.fields.Header, m.fields.HeaderShort = logo, strings.TrimSpace("lazysecrets "+m.versionLabel()) + m.fields.Show("lazysecrets", m.statusFields()) + case paneContainers: + if c, ok := m.containers.Selected(); ok { + cmd := m.loadDetails(c) + m.fields.Show(c.Name, m.containerFields(c)) + return cmd + } else { + m.fields.Show("", nil) + } + default: + m.fields.Hide() + } + return nil +} + +// showAccount shows a provider's account. +func (m *Model) showAccount(r sourceRow) { + p := m.byID[r.provider] + var fields []panes.Field + if a, ok := p.(provider.Accounts); ok { + if acct, ok := a.Account(); ok { + for _, f := range acct.Fields { + fields = append(fields, panes.Field{Label: f.Label, Value: f.Value}) + } + } + } + if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 { + hint := emptyHint(p.Kind()) + fields = append(fields, panes.Field{Label: "nothing", Value: "found: try", Info: true}, panes.Field{Value: hint.Value, URL: hint.URL}) + } + m.fields.Show(p.Label(), fields) +} + +// sourceFields describe a subscription, workspace or repo. +func (m Model) sourceFields(src provider.Source) []panes.Field { + var out []panes.Field + add := func(label, value string) { + if value != "" { + out = append(out, panes.Field{Label: label, Value: value}) + } + } + // Azure's detail is "subscription ": the ID row says it. + id, detail := "ID", "" + switch src.Kind { + case provider.KindDatabricks: + id, detail = "profile", "host" + case provider.KindRepo: + id, detail = "path", "remote" + } + add("name", src.Name) + add("note", m.store.Node(sourceKey(src)).Note) + if src.ID != src.Name { + add(id, src.ID) + } + if detail != "" { + add(detail, src.Detail) + } + if src.ProviderLabel != string(src.Kind) { + add("account", src.ProviderLabel) + } + add("web", src.URL) + if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok { + label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind] + add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value))) + } + return out +} + +// containerFields describe a vault, scope or file. +func (m Model) containerFields(c provider.Container) []panes.Field { + var out []panes.Field + add := func(label, value string) { + if value != "" { + out = append(out, panes.Field{Label: label, Value: value}) + } + } + add("name", c.Name) + add("note", m.store.Node(storeKey(c)).Note) + // The vault's URI, the scope's backend, the file's format. + switch c.Source.Kind { + case provider.KindAzure: + add("URI", c.Detail) + case provider.KindDatabricks: + add("backend", c.Detail) + case provider.KindRepo: + add("format", c.Detail) + } + add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) + add("web", c.URL) + for _, t := range c.ObjectTypes() { + if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok { + add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value))) + } + } + for _, a := range c.Attrs { + out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn}) + } + switch st := m.details[containerKey(c)]; { + case st.loading: + out = append(out, panes.Field{Label: "details", Value: "loading…", Info: true}) + case errors.Is(st.err, provider.ErrUnsupported): + case st.err != nil: + out = append(out, panes.Field{Label: "details", Value: "couldn't load them: " + st.err.Error(), Info: true, Err: true}) + } + for _, a := range m.details[containerKey(c)].attrs { + out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn}) + } + return out +} + +// loadDetails loads c's details, if its provider has them and they +// aren't loaded yet. +func (m *Model) loadDetails(c provider.Container) tea.Cmd { + d, ok := m.providerFor(c.Source).(provider.ContainerDetails) + key := containerKey(c) + if _, seen := m.details[key]; !ok || seen { + return nil + } + // One at a time: the previous one, if it's still loading, is + // cancelled and forgotten, to load when it's shown again. + for k, st := range m.details { + if st.loading { + delete(m.details, k) + } + } + m.details[key] = detailsState{loading: true} + ctx, req := m.begin(slotDetails) + return m.settled(ctx, func() tea.Msg { + attrs, err := d.Details(ctx, c) + if ctx.Err() != nil { + err = ctx.Err() + } + return detailsMsg{req: req, key: key, attrs: attrs, err: err} + }) +} + +// problemFields show why an account has nothing to show, wrapped to the +// pane, in red. +func (m Model) problemFields(problem string) []panes.Field { + var out []panes.Field + for _, l := range strings.Split(ansi.Wrap(problem, max(20, m.fields.Width()-16), " "), "\n") { + out = append(out, panes.Field{Value: l, Info: true, Err: true}) + } + out[0].Label = "problem" + return out +} + +// fieldName names a copied field: its label, or a row of several under +// one label (a tag), its value. +func fieldName(f panes.Field) string { + return cmpOr(f.Label, f.Value) +} diff --git a/internal/ui/app/getstarted.go b/internal/ui/app/getstarted.go new file mode 100644 index 0000000..382cd5b --- /dev/null +++ b/internal/ui/app/getstarted.go @@ -0,0 +1,66 @@ +package app + +import ( + "errors" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" + "git.kebcloud.no/keb/lazysecrets/internal/update" +) + +// When no account has anything to show, as on a first run, the right +// pane says how to get started: the sign-in for each kind of account +// (y copies it, L runs az login), and the demo. + +// nothingFound reports whether every account has loaded, with nothing. +func (m Model) nothingFound() bool { + if m.sourcesPending > 0 { + return false + } + for _, p := range m.providers { + if len(m.sourceResults[p.ID()].sources) > 0 { + return false + } + } + return true +} + +// getStartedFields are the rows saying how to get started. +func (m Model) getStartedFields() []panes.Field { + out := []panes.Field{{Value: "Nothing to show yet: sign in where your secrets are.", Info: true}} + seen := map[provider.Kind]bool{} + for _, p := range m.providers { + if seen[p.Kind()] { + continue + } + seen[p.Kind()] = true + f := emptyHint(p.Kind()) + var auth *provider.AuthError + switch err := m.sourceResults[p.ID()].err; { + case errors.As(err, &auth) && len(auth.Login) > 0: + f.Provider = p.ID() // L signs in + out = append(out, f, panes.Field{Value: withKey(m.keys.Login, "%s runs it here"), Info: true}) + case err != nil: + out = append(out, f, panes.Field{Value: err.Error(), Info: true, Err: true}) + default: + out = append(out, f) + } + } + return append(out, + panes.Field{Label: "try", Value: "lazysecrets --demo"}, + panes.Field{Value: "built-in sample data, to look around", Info: true}, + panes.Field{Label: "docs", Value: "getting started", URL: update.DefaultRepo + "#get-started"}, + ) +} + +// emptyHint is how an account of kind gets something to show. +func emptyHint(kind provider.Kind) panes.Field { + switch kind { + case provider.KindDatabricks: + return panes.Field{Label: "Databricks", Value: "databricks auth login --host https://"} + case provider.KindRepo: + return panes.Field{Label: "repos", Value: "SOPS and .env files in your git repos", + URL: update.DefaultRepo + "/src/branch/main/docs/repos.md"} + } + return panes.Field{Label: string(kind), Value: "az login"} +} 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..a405f91 --- /dev/null +++ b/internal/ui/app/help.go @@ -0,0 +1,335 @@ +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 + account bool // the fourth pane shows Fields (fields.go) + of pane // what the fourth pane describes + // revealed, when set, says whether the selected value is shown, so + // the reveal key says just what it'll do: show or hide, the same + // width so the bar doesn't shift. + revealed *bool +} + +func (c contextHelp) ShortHelp() []key.Binding { + k := c.k + var b []key.Binding + if k.Login.Enabled() { + b = append(b, k.Login) + } + copyField := key.NewBinding(key.WithKeys(k.Copy.Keys()...), key.WithHelp(k.Copy.Help().Key, "copy")) + switch { + case c.focus == paneStatus: + details := key.NewBinding(key.WithKeys(k.Enter.Keys()...), key.WithHelp(k.Enter.Help().Key, "details")) + return append(b, details, k.Update, k.Help, k.Quit) + case c.focus == paneDetail && c.account && c.of == paneStatus: + open := key.NewBinding(key.WithKeys(k.Open.Keys()...), key.WithHelp(k.Open.Help().Key, "open link")) + return append(b, k.Up, k.Down, copyField, open, k.Edit, k.Filter, k.Help, k.Quit) + case c.focus == paneDetail && c.account: + return append(b, k.Up, k.Down, copyField, k.Filter, k.Help, k.Quit) + } + switch c.focus { + case paneSources: + b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Filter) + case paneContainers: + b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter) + default: + reveal := k.Reveal + if c.revealed != nil { + reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed])) + } + b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, 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", "Status"} + +// 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..dbaa15b --- /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 ("alice" in +// "Azure · alice"), 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..fbdd711 --- /dev/null +++ b/internal/ui/app/layout.go @@ -0,0 +1,202 @@ +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.listAvail() + 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()) } + +// listAvail is the rows the three list panes share, under the status pane. +func (m Model) listAvail() int { return m.leftAvail() - statusH } + +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-statusH) + + switch m.zoom { + case zoomHalf: + if m.focus == paneDetail || m.focus == paneStatus { + leftW = minLeftW + } else { + leftW = max(m.leftWidth(), m.w/2) + h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH} + h[m.focus] = avail - statusH + h = fitHeights(h, avail-statusH, tinyPaneH) + } + case zoomFull: + // Only the focused pane is drawn; it gets the whole screen. + switch m.focus { + case paneDetail, paneStatus: + leftW = 0 + default: + leftW = m.w + h = [3]int{} + h[m.focus] = avail + } + } + + m.statusW = leftW + 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) + m.fields.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 := [paneStatus]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 { + if m.fields.Shown() { + return m.fields.View(m.st, m.active(paneDetail), spin) + } + return m.detail.View(m.st, m.active(paneDetail), spin) + }, + } + if m.zoom == zoomFull { + if m.focus == paneStatus { + return views[paneDetail]() // its rows + } + return views[m.focus]() + } + left := lipgloss.JoinVertical(lipgloss.Left, m.statusView(), 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..5c6becb --- /dev/null +++ b/internal/ui/app/layout_test.go @@ -0,0 +1,270 @@ +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 show") { + 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 TestVersionInStatus(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{Version: "0.1.0"}) + if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") { + t.Errorf("no version in the status pane:\n%s", d.screen()) + } + if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") { + t.Errorf("help line %q", bar) + } +} diff --git a/internal/ui/app/links.go b/internal/ui/app/links.go new file mode 100644 index 0000000..7d7bbbd --- /dev/null +++ b/internal/ui/app/links.go @@ -0,0 +1,892 @@ +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 + // quiet gives up without a word: going back to where lazysecrets + // was left, which may be gone. + quiet bool +} + +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) { + if !m.nav.quiet { + 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..1c3a850 --- /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 main prod"} { + 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/persist.go b/internal/ui/app/persist.go new file mode 100644 index 0000000..659637d --- /dev/null +++ b/internal/ui/app/persist.go @@ -0,0 +1,248 @@ +package app + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "time" + + 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" +) + +// What was loaded is kept between runs in a cache file (Options.CacheFile): +// the Sources lists, vaults, secrets' metadata and where the cursor was in +// each. Starting again shows it at once, and lists older than cacheTTL +// reload in the background as usual. Values are never in it; neither are +// repos, whose files are read from disk anyway. +// +// And where lazysecrets was left, per folder it ran in, goes in the store, +// to go back there on the next start. + +// cacheFileVersion is the file's format; a file of another version is +// ignored, and replaced on the next save. +const cacheFileVersion = 1 + +// cacheMaxAge drops what hasn't been loaded again for this long. +const cacheMaxAge = 30 * 24 * time.Hour + +// persistEvery is how often a changed cache is saved while running, so +// little is lost if lazysecrets is killed rather than quit. +const persistEvery = 30 * time.Second + +type cacheFile struct { + Version int `json:"version"` + Sources map[string]stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID + Containers map[string]stamped[[]provider.Container] `json:"containers,omitempty"` + Objects map[string]stamped[[]provider.Object] `json:"objects,omitempty"` + Details map[string]stamped[provider.Object] `json:"details,omitempty"` + Cursor map[string]string `json:"cursor,omitempty"` +} + +type stamped[T any] struct { + At time.Time `json:"at"` + Value T `json:"value"` +} + +type persistTickMsg struct{} + +type persistedMsg struct{ err error } + +// keepCached reports whether a cache key's account still exists and +// isn't a repo: every key starts with the provider ID. +func (m *Model) keepCached(key string) bool { + id, _ := splitSourceRow(key) + p, ok := m.byID[id] + return ok && p.Kind() != provider.KindRepo +} + +// loadCacheFile fills the cache from the file, leaving out what's too +// old or belongs to accounts that aren't signed in any more. +func (m *Model) loadCacheFile() { + b, err := os.ReadFile(m.cachePath) + if err != nil { + return + } + var f cacheFile + if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion { + return + } + now := m.now() + recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge } + for id, e := range f.Sources { + if m.keepCached(id+"|") && recent(e.At) { + m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value} + m.sourcesAt[id] = e.At + } + } + for k, e := range f.Containers { + if m.keepCached(k) && recent(e.At) { + m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At} + } + } + for k, e := range f.Objects { + if m.keepCached(k) && recent(e.At) { + m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At} + } + } + for k, e := range f.Details { + if m.keepCached(k) && recent(e.At) { + m.cache.details[k] = entry[provider.Object]{e.Value, e.At} + } + } + for k, v := range f.Cursor { + if m.keepCached(k) { + m.cache.cursor[k] = v + } + } +} + +// cacheSnapshot is the cache as the file holds it. +func (m *Model) cacheSnapshot() cacheFile { + f := cacheFile{ + Version: cacheFileVersion, + Sources: map[string]stamped[[]provider.Source]{}, + Containers: map[string]stamped[[]provider.Container]{}, + Objects: map[string]stamped[[]provider.Object]{}, + Details: map[string]stamped[provider.Object]{}, + Cursor: map[string]string{}, + } + for id, r := range m.sourceResults { + if r.err == nil && m.keepCached(id+"|") { + f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources} + } + } + for k, e := range m.cache.containers { + if m.keepCached(k) { + f.Containers[k] = stamped[[]provider.Container]{e.at, e.value} + } + } + for k, e := range m.cache.objects { + if m.keepCached(k) { + f.Objects[k] = stamped[[]provider.Object]{e.at, e.value} + } + } + for k, e := range m.cache.details { + if m.keepCached(k) { + f.Details[k] = stamped[provider.Object]{e.at, e.value} + } + } + for k, v := range m.cache.cursor { + if m.keepCached(k) { + f.Cursor[k] = v + } + } + return f +} + +// writeCacheFile writes f atomically, readable by the user only. +func writeCacheFile(path string, f cacheFile) error { + b, err := json.Marshal(f) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) // after a successful rename, nothing there + 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) +} + +// persistTick saves the cache, and where lazysecrets is, in the +// background when they changed. +func (m *Model) persistTick() tea.Cmd { + cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })} + if m.cacheDirty && m.cachePath != "" { + m.cacheDirty = false + f, path := m.cacheSnapshot(), m.cachePath + cmds = append(cmds, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} }) + } + if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace { + m.savedPlace = p.Link + st, dir := m.store, m.placeDir + cmds = append(cmds, func() tea.Msg { return persistedMsg{st.SetPlace(dir, p)} }) + } + return tea.Batch(cmds...) +} + +// Persist saves the cache and where lazysecrets was left; run it after +// the program ends. +func (m Model) Persist() error { + var errs []error + if m.cachePath != "" { + errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot())) + } + if m.placeDir != "" { + if p, ok := m.place(); ok { + errs = append(errs, m.store.SetPlace(m.placeDir, p)) + } + } + return errors.Join(errs...) +} + +// place is the selected item, as deep as the focus. +func (m *Model) place() (store.Place, bool) { + src, ok := m.selectedSource() + if !ok || m.nav != nil { + return store.Place{}, false + } + var r ref.Ref + var err error + switch c, cok := m.containers.Selected(); { + case m.focus == paneSources || !cok: + r, err = ref.ForSource(src) + case m.focus == paneContainers: + r, err = ref.ForContainer(c, m.objType) + default: + o, ook := m.detail.Secret() + if !ook { + r, err = ref.ForContainer(c, m.objType) + } else { + r, err = ref.ForObject(o, "") + } + } + if err != nil { + return store.Place{}, false + } + return store.Place{Link: r.String(), Account: src.Provider, Source: src.ID, At: m.now()}, true +} + +// restorePlace goes back to where lazysecrets was left in dir, if that +// account is still signed in. +func (m *Model) restorePlace() { + p, ok := m.store.Place(m.placeDir) + if !ok { + return + } + if _, ok := m.byID[p.Account]; !ok { + return + } + r, err := ref.Parse(p.Link) + if err != nil { + return + } + m.startNav(r, actGo, sourceRowKey(p.Account, p.Source)) + m.nav.sourceOnly = r.IsSource() + m.nav.quiet = true +} + +func (m *Model) handlePersisted(msg persistedMsg) { + if msg.err != nil { + m.logErrOf("saving the cache or place", msg.err) + } +} diff --git a/internal/ui/app/persist_test.go b/internal/ui/app/persist_test.go new file mode 100644 index 0000000..e1c1b6c --- /dev/null +++ b/internal/ui/app/persist_test.go @@ -0,0 +1,136 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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 TestCacheFileShowsLastRunAtOnce(t *testing.T) { + t.Parallel() + file := filepath.Join(t.TempDir(), "lazysecrets", "cache.json") + d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0)) + d.keys("3", "space") + if !strings.Contains(d.screen(), "correct-horse-battery-staple") { + t.Fatalf("value not revealed:\n%s", d.screen()) + } + src, vault, secret := d.selected() + if err := d.m.Persist(); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "correct-horse") { + t.Error("a value is in the cache file") + } + if fi, _ := os.Stat(file); fi.Mode().Perm() != 0o600 { + t.Errorf("cache file mode %v", fi.Mode().Perm()) + } + + // Next run, with a backend too slow to answer during the test: the + // lists show from the file, and the cursor is where it was. + d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute)) + if strings.Contains(d2.screen(), "cached") { + t.Errorf("a fresh cache said its age:\n%s", d2.screen()) + } + d2.keys("2") + if _, v, _ := d2.selected(); v != vault { + t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen()) + } + d2.keys("3") + if s, v, o := d2.selected(); s != src || v != vault || o != secret { + t.Errorf("selected %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen()) + } +} + +// Lists from an older cache say how old they are while they reload. +func TestCacheFileSaysItsAge(t *testing.T) { + t.Parallel() + file := filepath.Join(t.TempDir(), "cache.json") + d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0)) + d.keys("3") + at := time.Now().Add(-3 * time.Hour) + for k, e := range d.m.cache.containers { + d.m.cache.containers[k] = entry[[]provider.Container]{e.value, at} + } + for id := range d.m.sourcesAt { + d.m.sourcesAt[id] = at + } + if err := d.m.Persist(); err != nil { + t.Fatal(err) + } + d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute)) + d2.keys("2") + if n := strings.Count(d2.screen(), "cached 3h ago"); n != 2 { + t.Errorf("Sources and Vaults should say they're from 3h ago (%d do):\n%s", n, d2.screen()) + } +} + +func TestCacheFileDropsOldAndSignedOut(t *testing.T) { + t.Parallel() + file := filepath.Join(t.TempDir(), "cache.json") + az := fake.Azure(0) + now := time.Now() + f := cacheFile{ + Version: cacheFileVersion, + Containers: map[string]stamped[[]provider.Container]{ + az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}}, + az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}}, + "Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}}, + }, + } + if err := writeCacheFile(file, f); err != nil { + t.Fatal(err) + } + d := newDriverOpts(t, Options{CacheFile: file}, az) + c := d.m.cache.containers + if _, ok := c[az.ID()+"|recent"]; !ok { + t.Error("dropped a recent entry") + } + if _, ok := c[az.ID()+"|old"]; ok { + t.Error("kept an entry older than cacheMaxAge") + } + if _, ok := c["Azure:gone@x/t|recent"]; ok { + t.Error("kept an entry of an account that isn't signed in") + } +} + +func TestBackWhereItWasLeft(t *testing.T) { + t.Parallel() + st, _ := store.Load("") + dir := "/work/project" + d := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0)) + d.keys("3", "j", "j") + src, vault, secret := d.selected() + if err := d.m.Persist(); err != nil { + t.Fatal(err) + } + if p, ok := st.Place(dir); !ok || !strings.Contains(p.Link, secret) { + t.Fatalf("place %+v", p) + } + + d2 := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0)) + if s, v, o := d2.selected(); s != src || v != vault || o != secret { + t.Errorf("back at %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen()) + } + + // Another folder starts as usual; so does opening a link. + d3 := newDriverOpts(t, Options{Store: st, Dir: "/elsewhere"}, fake.Azure(0)) + if _, _, o := d3.selected(); o == secret { + t.Errorf("another folder went to %s too", o) + } + r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password") + d4 := newDriverOpts(t, Options{Store: st, Dir: dir, Open: &r}, fake.Azure(0)) + if d4.m.nav != nil && d4.m.nav.quiet { + t.Error("a link was overridden by where it was left") + } +} diff --git a/internal/ui/app/repo_test.go b/internal/ui/app/repo_test.go new file mode 100644 index 0000000..8222d9a --- /dev/null +++ b/internal/ui/app/repo_test.go @@ -0,0 +1,344 @@ +package app + +import ( + tea "charm.land/bubbletea/v2" + "context" + "fmt" + "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 != accountScope+"Repos" { + if d.m.search.src == len(d.m.search.scopes)-1 { + t.Fatalf("no 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 Repos") { + t.Errorf("title:\n%s", d.screen()) + } +} + +func TestEditFile(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") + envPath := filepath.Join(root, "secrets", ".env") + os.WriteFile(envPath, []byte("# local\nTOKEN=x\n"), 0o600) + var opened []string + d := newDriverOpts(t, Options{OpenURL: func(u string) error { opened = append(opened, u); return nil }}, + fake.Azure(0), repo.New(repo.Settings{Dir: root})) + env := map[string]string{"NVIM": "/tmp/nvim.sock"} + d.m.getenv = func(k string) string { return env[k] } + var nvim []string + d.m.nvimOpen = func(server, path string, line int) error { + nvim = append(nvim, fmt.Sprintf("%s %s:%d", server, path, line)) + return nil + } + + // Inside nvim: the file, then a secret at its line, and lazysecrets quits. + d.keys("2") + if _, c, _ := d.selected(); c != "secrets/.env" { + t.Fatalf("selected %q\n%s", c, d.screen()) + } + d.keys("e") + d.keys("3", "e") + want := []string{"/tmp/nvim.sock " + envPath + ":0", "/tmp/nvim.sock " + envPath + ":2"} + if !slices.Equal(nvim, want) { + t.Errorf("nvim got %q\nwant %q", nvim, want) + } + if msg := d.m.edit()(); msg != (tea.QuitMsg{}) { + t.Errorf("after opening in nvim: %#v, want quit", msg) + } + + // SOPS files go to sops edit, in the terminal, even inside nvim. + nvim = nil + d.keys("2", "j") + if _, c, _ := d.selected(); c != "secrets/app.yaml" { + t.Fatalf("selected %q", c) + } + if cmd := d.m.edit(); cmd == nil || len(nvim) > 0 { + t.Errorf("sops file: cmd %v, nvim %q", cmd, nvim) + } + + // Neither nvim nor an editor: the system's default app. + delete(env, "NVIM") + d.keys("k", "e") + if !slices.Equal(opened, []string{envPath}) || !strings.Contains(d.screen(), "opened .env") { + t.Errorf("opened %q\n%s", opened, d.screen()) + } + + // Done editing: the file's secrets reload. + d.send(editDoneMsg{c: provider.Container{Source: provider.Source{Provider: "Repos"}, ID: envPath}, name: ".env"}) + if !strings.Contains(d.screen(), "closed .env") { + t.Errorf("after editing:\n%s", d.screen()) + } + + // Other providers have no files. + az := newDriver(t, fake.Azure(0)) + az.keys("2", "e") + if !strings.Contains(az.screen(), "only SOPS and .env files") { + t.Errorf("azure:\n%s", az.screen()) + } +} + +func TestEditorArgs(t *testing.T) { + t.Parallel() + f := provider.File{Path: "/r/.env", Line: 7} + for editor, want := range map[string][]string{ + "nvim": {"+7", "/r/.env"}, + "/usr/bin/vim -p": {"+7", "/r/.env"}, + "hx": {"/r/.env:7"}, + "code --wait": {"-g", "/r/.env:7"}, + "open -a TextEdit": {"/r/.env"}, + } { + if got := editorArgs(editor, f); !slices.Equal(got, want) { + t.Errorf("%s: %q, want %q", editor, got, want) + } + } + if got := editorArgs("nvim", provider.File{Path: "/r/.env"}); !slices.Equal(got, []string{"/r/.env"}) { + t.Errorf("no line: %q", got) + } +} + +func TestEditorAtLine(t *testing.T) { + t.Parallel() + for _, c := range []struct { + editor string + line int + want string + ok bool + }{ + {"nvim", 4, "nvim +4", true}, + {"/usr/bin/vim -p", 4, "/usr/bin/vim -p +4", true}, + {"code --wait", 4, "", false}, // sops adds the file last: no -g file:N + {"nvim", 0, "", false}, + } { + if got, ok := editorAtLine(c.editor, c.line); got != c.want || ok != c.ok { + t.Errorf("%q at %d: %q %v", c.editor, c.line, got, ok) + } + } +} 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..f151c17 --- /dev/null +++ b/internal/ui/app/search.go @@ -0,0 +1,400 @@ +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(nil) } + +// providerFilter picks the providers a search or a load covers; nil is +// all of them. +type providerFilter func(provider.Provider) bool + +func ofKind(k provider.Kind) providerFilter { + return func(p provider.Provider) bool { return p.Kind() == k } +} + +func ofProvider(id string) providerFilter { + return func(p provider.Provider) bool { return p.ID() == id } +} + +// coverageOf is searchCoverage for the providers in. +func (m *Model) coverageOf(in providerFilter) coverage { + var c coverage + for _, p := range m.providers { + if in != nil && !in(p) { + 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(nil) } + +// startCrawlOf is startCrawl for the providers in. +func (m *Model) startCrawlOf(in providerFilter) tea.Cmd { + for _, p := range m.providers { + if in != nil && !in(p) { + 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 lately +// and not force, loading their secrets. Lists from an earlier run (the +// cache file) are listed again, so searching brings them up to date. +func (m *Model) queueSource(src provider.Source, force bool) { + row := sourceRowKey(src.Provider, src.ID) + if e, ok := m.cache.containers[row]; ok && e.fresh(m.now()) && !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 lately and not +// force. +func (m *Model) queueContainer(c provider.Container, row string, force bool) { + key := containerKey(c) + "|" + string(provider.TypeSecret) + if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !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()} + m.cacheDirty = true + 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()} + m.cacheDirty = true + } + 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..3246df4 --- /dev/null +++ b/internal/ui/app/search_test.go @@ -0,0 +1,468 @@ +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") // on All: found at once + if !strings.Contains(d.screen(), "ctrl+s only platform-dev") { + t.Errorf("no hint to narrow to the source:\n%s", d.screen()) + } + d.send(ctrl('s')) + if !strings.Contains(d.screen(), "no matches here · All searches everything") || !strings.Contains(d.screen(), "ctrl+s searches all") { + t.Errorf("no hint to search everything:\n%s", d.screen()) + } + d.send(ctrl('s')) + 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() + d.send(ctrl('s')) // only 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.send(ctrl('s')) + 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 + scope := func() string { return ansiStrip(v.scopes[v.src].label) } + if scope() != "All" { + t.Errorf("opened on %q, want All", scope()) + } + 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()) + } + d.send(ctrl('s')) // narrows to the source, and back + if !strings.Contains(scope(), "platform-prod") { + t.Errorf("ctrl+s narrowed to %q", scope()) + } + d.send(ctrl('s')) + if scope() != "All" { + t.Errorf("second ctrl+s on %q, want All", scope()) + } + + // From Favourites: to Search, on All. + d.keys("esc", "]", "]") // stop typing; Favourites + d.send(ctrl('s')) + if v.tab != tabSearch || scope() != "All" { + t.Errorf("ctrl+s from another tab: tab %d, %q", v.tab, scope()) + } + + // From the Sources pane: the source. + d.keys("esc", "1", "j") // close; platform-dev + d.openSearch() + v = d.m.search + d.send(ctrl('s')) + if !strings.Contains(scope(), "platform-dev") { + t.Errorf("ctrl+s narrowed to %q", scope()) + } +} + +func TestSearchNarrowsToAccount(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("1", "g") // the first group header + header, _ := d.m.sources.Selected() + d.openSearch() + d.send(ctrl('s')) + v := d.m.search + if v.scope().id != accountScope+header.provider { + t.Fatalf("ctrl+s on %q narrowed to %q", header.label, v.scope().id) + } + if len(v.shown) == 0 || !strings.Contains(d.screen(), "Results in "+header.label) { + t.Errorf("no results in the account:\n%s", d.screen()) + } + for _, o := range v.shown { + if providerOf(o.group) != header.provider { + t.Errorf("result from another account: %q", ansiStrip(o.label)) + } + } + // The account's sources are under it, indented. + if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") { + t.Errorf("after the account: %q", ansiStrip(next.label)) + } +} + +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()) + } + + // g and G: top and bottom of the list with the cursor. + d.keys("G") + if v.res != len(v.shown)-1 || v.res == 0 { + t.Errorf("G on results: %d of %d", v.res, len(v.shown)) + } + d.keys("g") + if v.res != 0 { + t.Errorf("g on results: %d", v.res) + } + d.keys("h", "G") + if v.src != len(v.scopes)-1 { + t.Errorf("G on scopes: %d of %d", v.src, len(v.scopes)) + } + d.keys("g", "l") + if v.src != 0 { + t.Errorf("g on scopes: %d", v.src) + } + + 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..7fda0b2 --- /dev/null +++ b/internal/ui/app/searchview.go @@ -0,0 +1,778 @@ +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: 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; Favourites and Recent (from the store, so they work +// before anything loads) are one list each. +// +// 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, on All. search again narrows to the source you were in, and +// back to All. +// +// 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 // tabSearch, tabFavs or tabRecent + 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 + here string // the scope of the source Go to was opened from, if any + + // 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 ( + tabSearch = iota + tabFavs + tabRecent + tabCount +) + +var tabNames = [tabCount]string{"Search", "Favourites", "Recent"} + +const ( + scopeFavs = "#favs" + scopeRecent = "#recent" + allSources = "#all" + accountScope = "#account:" // + a provider ID: an account's sources, or Databricks', or the repos +) + +const goToTitle = "Go to" + +// showSearch opens Go to on Search, typing, on 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 is the one +// search narrows to, and the selected vault or scope is under the cursor. +func (m *Model) preselect() { + v := m.search + row := m.sources.SelectedID() + account := "" + if id, ok := strings.CutPrefix(row, "group:"); ok { // a group header + account = accountScope + id + } + i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == account || o.group != "" && o.group == row }) + if i < 0 { + return + } + v.here = v.scopes[i].id + 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(nil)} + // Grouped like the Sources pane: each account (or Databricks, or the + // repos) is a scope too, its sources under it, so a search can stay on + // one, like the repos (local, quick), without asking Azure for every + // vault. With only one, it's the same as All. + var groups []provider.Provider + for _, p := range m.providers { + if len(m.sourceResults[p.ID()].sources) > 0 { + groups = append(groups, p) + } + } + scopes := []option{all} + if len(groups) < 2 { + scopes = append(scopes, sources...) + } else { + for _, p := range groups { + k := p.Kind() + scopes = append(scopes, option{ + label: m.st.Kind(k).Render(m.icons.Prefix(k) + p.Label()), + id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())), + }) + for _, o := range sources { + if providerOf(o.group) == p.ID() { + o.label = " " + o.label + scopes = append(scopes, o) + } + } + } + } + v.scopes, v.items = scopes, 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)) +} + +// coverageMark is ○ ◐ ● for how much of the providers in is loaded. +func (m *Model) coverageMark(in providerFilter) string { + switch c := m.coverageOf(in); { + case c.complete(): + return m.st.Success.Render("●") + case c.vaultsLoaded > 0: + return m.st.Warning.Render("◐") + } + return m.st.Muted.Render("○") +} + +// scopeAccount is the provider whose sources the scope covers, if it's +// an account's. +func (v *searchView) scopeAccount() (string, bool) { + id, ok := strings.CutPrefix(v.scope().id, accountScope) + if !ok || v.tab != tabSearch { + return "", false + } + return id, true +} + +// scopeFilter is what loading everything in the scope covers: an +// account's, else all of them. +func (v *searchView) scopeFilter() providerFilter { + if id, ok := v.scopeAccount(); ok { + return ofProvider(id) + } + return nil +} + +// scopeName is the scope's label without colours or indent. +func scopeName(o option) string { return strings.TrimSpace(ansi.Strip(o.label)) } + +// 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: + account, ofAccount := v.scopeAccount() + for _, o := range v.items { + if sc.id == allSources || ofAccount && providerOf(o.group) == account || 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 key.Matches(msg, km.Search) { + return m.narrow() + } + 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.GoTop), key.Matches(msg, km.GoBottom): + n := len(v.shown) + if !v.results { + n = len(v.scopes) + } + i := 0 + if key.Matches(msg, km.GoBottom) { + i = max(0, n-1) + } + if v.results { + v.res = i + } else if v.src != i { + v.src, v.res = i, 0 + m.fillSearch(false) + } + return nil + 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) + } + return m.startCrawlOf(v.scopeFilter()) + case inSearch && key.Matches(msg, k.loadAll): + return m.startCrawlOf(v.scopeFilter()) + 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 +} + +// narrow switches Search between All and the source Go to was opened +// from; from another tab, or another scope, it goes to Search on All. +func (m *Model) narrow() tea.Cmd { + v := m.search + src := 0 + if v.tab == tabSearch && v.scope().id == allSources { + src = slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here }) + if src < 0 { + return nil + } + } + v.tab, v.src, v.res, v.results = tabSearch, src, 0, true + m.fillSearch(false) + return m.searchLoads() +} + +// 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 _, ok := v.scopeAccount(); ok { + return m.startCrawlOf(v.scopeFilter()) + } + 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 { + load, narrow := m.loadHint(), m.narrowHint() + switch { + case narrow == "": + return load + case load != "": + narrow = " · " + narrow + } + return load + m.st.Muted.Render(narrow) +} + +// narrowHint names the key for the other of All and the source Go to was +// opened from. +func (m Model) narrowHint() string { + v := m.search + if v.tab != tabSearch || m.crawl.running { + return "" + } + if v.scope().id == v.here && v.here != "" { + return withKey(m.keys.Search, "%s searches all") + } + i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here }) + if i < 0 || v.scope().id != allSources { + return "" + } + return withKey(m.keys.Search, "%s only "+scopeName(v.scopes[i])) +} + +// loadHint says how much of the scope is loaded, and the key to load it. +func (m Model) loadHint() 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 _, ok := v.scopeAccount(); ok || v.scope().id == allSources { + c := m.coverageOf(v.scopeFilter()) + 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 _, ok := v.scopeAccount(); ok || v.onSource() { + resultsTitle = "Results in " + scopeName(v.scope()) + } + 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/status.go b/internal/ui/app/status.go new file mode 100644 index 0000000..829c4c7 --- /dev/null +++ b/internal/ui/app/status.go @@ -0,0 +1,204 @@ +package app + +import ( + "errors" + "fmt" + "strings" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" + "git.kebcloud.no/keb/lazysecrets/internal/update" +) + +// The status pane, like lazygit's: a line on top of the left panes with +// the version, a newer release and how the accounts are doing. Focused +// (0), the right pane has the rest: updates, each account, the files +// lazysecrets uses, links, and the changelog. + +// logo heads the status pane's rows. +var logo = []string{ + ` _ _`, + `| | __ _ _____ _ ___ ___ ___ _ __ ___| |_ ___`, + `| |/ _` + "`" + ` |_ / | | / __|/ _ \/ __| '__/ _ \ __/ __|`, + `| | (_| |/ /| |_| \__ \ __/ (__| | | __/ |_\__ \`, + `|_|\__,_/___|\__, |___/\___|\___|_| \___|\__|___/`, + ` |___/`, +} + +// statusH is the status pane's height: one line in a box. +const statusH = 3 + +// statusLine is what the status pane says while it isn't focused. +func (m Model) statusLine() string { + var parts []string + switch { + case m.updated != "": + parts = append(parts, m.st.Notice.Render("→ "+m.updated+" (restart)")) + case m.newer != nil: + parts = append(parts, m.st.Notice.Render("↑ "+m.newer.Tag+withKey(m.keys.Update, " %s"))) + } + accounts, failing, repos := 0, 0, 0 + for _, p := range m.providers { + if p.Kind() == provider.KindRepo { + repos += len(m.sourceResults[p.ID()].sources) // those with secret files + continue + } + accounts++ + if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil { + failing++ + } + } + if n := len(m.expiringItems()); n > 0 { + parts = append(parts, m.st.Warning.Render(fmt.Sprintf("⚠ %d expiring", n))) + } + if accounts > 0 { + s := plural(accounts, "account") + if failing > 0 { + s += " · " + m.st.Error.Render(fmt.Sprintf("%d need sign-in", failing)) + } + parts = append(parts, s) + } + if repos > 0 { + parts = append(parts, plural(repos, "repo")) + } + return " " + strings.Join(parts, m.st.Muted.Render(" · ")) +} + +func (m Model) statusView() string { + title := "[0] lazysecrets" + if v := m.versionLabel(); v != "" { + title += " " + v + } + return panes.Box(m.st, panes.TitleStyle(m.st, m.active(paneStatus)).Render(title), "", + []string{m.statusLine()}, m.statusW, statusH, m.active(paneStatus)) +} + +// statusFields are the status pane's rows on the right. +func (m Model) statusFields() []panes.Field { + var out []panes.Field + add := func(f panes.Field) { + if f.Value != "" { + out = append(out, f) + } + } + // A release's rows open its release page. + version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")} + if strings.HasPrefix(version.Value, "v") { + version.URL = update.DefaultRepo + "/releases/tag/" + version.Value + } + add(version) + upd := panes.Field{Label: "update", Value: m.updateStatus(), Info: m.newer == nil} + if m.newer != nil { + upd.URL = update.DefaultRepo + "/releases/tag/" + m.newer.Tag + } + add(upd) + add(panes.Field{Label: "license", Value: "GPL-3.0-or-later · © 2026 Karl-Einar Bergstrøm", Info: true}) + + for i, p := range m.providers { + f := panes.Field{Value: p.Label() + " · " + m.accountStatus(p), Provider: p.ID()} + if i == 0 { + f.Label = "accounts" + } + var auth *provider.AuthError + f.Err = errors.As(m.sourceResults[p.ID()].err, &auth) + add(f) + } + + out = append(out, m.expiringFields()...) + out = append(out, m.errorFields()...) + + for i, path := range m.configFiles { + label := "" + if i == 0 { + label = "config" + } + add(panes.Field{Label: label, Value: path, Path: path}) + } + add(panes.Field{Label: "state", Value: m.statePath, Path: m.statePath}) + add(panes.Field{Label: "cache", Value: m.cachePath, Path: m.cachePath}) + + // Short, without https://; y copies and o opens the whole URL. + repo := strings.TrimPrefix(update.DefaultRepo, "https://") + add(panes.Field{Label: "docs", Value: repo + "/…/docs", URL: update.DefaultRepo + "/src/branch/main/docs"}) + add(panes.Field{Label: "source", Value: repo, URL: update.DefaultRepo}) + // Issues are on the GitHub mirror. + github := strings.TrimPrefix(update.GitHubRepo, "https://") + add(panes.Field{Label: "issues", Value: github + "/issues", URL: update.GitHubRepo + "/issues"}) + add(panes.Field{Label: "releases", Value: repo + "/releases", URL: update.DefaultRepo + "/releases"}) + add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"}) + return append(out, m.changelog...) +} + +// updateStatus says whether a newer release is out, and when it was +// last checked. +func (m Model) updateStatus() string { + switch { + case m.updated != "": + return "updated to " + m.updated + ": restart to use it" + case m.newer != nil: + return m.newer.Tag + " is out" + withKey(m.keys.Update, " · %s updates") + } + at := m.store.UpdateCheck().At + if at.IsZero() { + return "" + } + return "up to date · checked " + ago(m.now().Sub(at)) +} + +// accountStatus says whether an account's sources loaded. +func (m Model) accountStatus(p provider.Provider) string { + r, done := m.sourceResults[p.ID()] + var auth *provider.AuthError + switch { + case !done: + return "loading…" + case errors.As(r.err, &auth) && len(auth.Login) > 0: + return "sign-in required" + withKey(m.keys.Login, " · %s signs in") + case r.err != nil: + return r.err.Error() + } + noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()] + return plural(len(r.sources), cmpOr(noun, "source")) +} + +// ago is a duration as people say it: "just now", "5m ago", "2h ago". +func ago(d time.Duration) string { + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 48*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + } + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) +} + +// changelogFields are CHANGELOG.md as rows: each release's version and +// date, then its entries, marked by what kind they are. +func changelogFields(md string) []panes.Field { + var out []panes.Field + kind := "" + for _, line := range strings.Split(md, "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "## "): + version, date, _ := strings.Cut(strings.TrimPrefix(line, "## "), " – ") + out = append(out, panes.Field{Label: version, Value: date, Info: true}) + kind = "" + case strings.HasPrefix(line, "### "): + kind = strings.ToLower(strings.TrimPrefix(line, "### ")) + case strings.HasPrefix(line, "- ") && len(out) > 0: + text := update.PlainLinks(strings.TrimPrefix(line, "- ")) + if kind != "" { + text = kind + ": " + text + } + out = append(out, panes.Field{Value: text}) + case line != "" && len(out) > 0 && !strings.HasPrefix(line, "\n" + got := changelogFields(md) + want := []panes.Field{ + {Label: "v0.2.0", Value: "2026-10-12", Info: true}, + {Value: "added: Search (#11) (65dc2d7)"}, + {Value: "fixed: A fix (#3)"}, + {Label: "v0.1.0", Value: "2026-10-10", Info: true}, + {Value: "Hello world 👋"}, + } + if len(got) != len(want) { + t.Fatalf("got %+v", got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("row %d: %+v, want %+v", i, got[i], want[i]) + } + } +} diff --git a/internal/ui/app/testdata/TestGolden.golden b/internal/ui/app/testdata/TestGolden.golden new file mode 100644 index 0000000..3943763 --- /dev/null +++ b/internal/ui/app/testdata/TestGolden.golden @@ -0,0 +1,33 @@ +╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮ +│ 2 accounts ││ name platform-prod │ +╰───────────────────────────────╯│ ID sub-prod │ +╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│ +│ ▾ Azure (2) ││ vaults 3 │ +│ platform-prod ││ │ +│ platform-dev ││ │ +│ ▾ Databricks (2) ││ │ +│ prod ││ │ +╰────────────────────── 2 of 6 ─╯│ │ +╭─ [2] Vaults ──────────────────╮│ │ +│ kv-app-prod ││ │ +│ kv-shared-prod ││ │ +│ kv-restricted ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰────────────────────── 1 of 3 ─╯│ │ +╭─ [3] Secrets - Keys - Certs ──╮│ │ +│ db-password ││ │ +│ api-key ││ │ +│ legacy-token (disabled) ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯ + enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? 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..37b019c --- /dev/null +++ b/internal/ui/app/update.go @@ -0,0 +1,157 @@ +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") +} diff --git a/internal/ui/app/update_test.go b/internal/ui/app/update_test.go new file mode 100644 index 0000000..08b7c55 --- /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 scr := d.screen(); !strings.Contains(scr, "[0] lazysecrets v0.1.1") || !strings.Contains(scr, "↑ v0.2.0 U") { + t.Errorf("no update in the status pane:\n%s", scr) + } + + // 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..c9c3a0d --- /dev/null +++ b/internal/ui/keys/keys.go @@ -0,0 +1,382 @@ +// 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, Details key.Binding + Pane0, Pane1, Pane2, Pane3, Pane4 key.Binding + NextTab, PrevTab key.Binding + Enter, Back, Filter key.Binding + Reveal, Copy, CopyRef, Open, Edit 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, GoTop, GoBottom key.Binding + 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; from the detail pane, the one it describes."), + a(Lists, "details", &m.Details, "details", "move", "Focus the detail pane on the right, on what's selected: a source, vault, file or secret."), + 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, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."), + 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. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), 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, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line. SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."), + + 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, "top", &m.GoTop, "top", "go to", "Jump to the top of the list in Go to while browsing."), + a(GoTo, "bottom", &m.GoBottom, "bottom", "go to", "Jump to the bottom of the list in Go to while browsing."), + a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Search, Favourites, Recent."), + 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"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"}, + "lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"}, + "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.edit": {"e"}, + "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.top": {"g", "home"}, "goto.bottom": {"G", "end"}, "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..4876daf --- /dev/null +++ b/internal/ui/panes/detail.go @@ -0,0 +1,394 @@ +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, CopyKey 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, CopyKey: d.CopyKey, 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. + // What the keys do with the value, on the line under it. + hints := func(reveal string, more ...string) string { + var h []string + if d.RevealKey != "" { + h = append(h, d.RevealKey+" to "+reveal) + } + if d.CopyKey != "" { + h = append(h, d.CopyKey+" to copy") + } + return muted(strings.Join(append(h, more...), " · ")) + } + label, hidden, reveal := "value", st.Masked.Render("••••••••"), "reveal" + switch s.Type { + case provider.TypeKey: + label, hidden, reveal = "public", "", "show the public key" + case provider.TypeCertificate: + label, hidden, reveal = "cert", "", "show the certificate" + } + indent := strings.Repeat(" ", 12) + + out = append(out, "") + switch d.state { + case ValueHidden: + if hidden != "" { + field(label, hidden) + out = append(out, indent+hints(reveal)) + } else { + field(label, hints(reveal)) + } + 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: + // Where the dots were, continuing under itself; the keys below. + first := true + lines, note := valueView(d.value.Reveal()) + for _, line := range lines { + for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") { + if first { + out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w)) + first = false + } else { + out = append(out, indent+st.Value.Render(w)) + } + } + } + var more []string + if note != "" && d.CopyKey != "" { + note += ": " + d.CopyKey + " copies it as stored" + } + if note != "" { + more = append(more, note) + } + if d.hidesIn != "" { + more = append(more, "hides in "+d.hidesIn) + } + out = append(out, indent+hints("hide", more...)) + } + 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 + } + // A list to read, not to move in (v browses them): no cursor. The + // version shown above, when it isn't the current one, is named. + line := " " + VersionLine(st, v, d.Now(), current) + if d.secret.Version != "" && v.Version == d.secret.Version && v.Version != current { + line += muted(" · shown above") + } + out = append(out, line) + } + 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/fields.go b/internal/ui/panes/fields.go new file mode 100644 index 0000000..8b7780d --- /dev/null +++ b/internal/ui/panes/fields.go @@ -0,0 +1,79 @@ +package panes + +import ( + "fmt" + + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +// Fields is the fourth pane for anything but a secret (an account, and +// what describes vaults, scopes and files): labelled values, one per row, +// a label on the first of several values, to move through, filter and +// copy from. It's a List, so / filters it like the other panes. Secrets +// have Detail, for revealing and versions. +type Fields struct { + List[Field] + shown bool +} + +// Field is a row: a value under a label. Info rows (loading, errors) +// can't be copied; Err ones are errors. A row can also be opened: URL in +// the browser (o), Path in the editor (e), Provider signed in to (L), or +// Link gone to (enter). +type Field struct { + Label, Value string + Info, Err bool + + URL, Path, Provider string + // Link is a lazysecrets link enter goes to, in the source of row Row. + Link, Row string +} + +func NewFields() Fields { + l := NewList("", Item[Field]{ + ID: func(f Field) string { return f.Label + "\x00" + f.Value }, + Filter: func(f Field) string { return f.Value }, + // "/tenant" finds the tenant's rows. + FilterExtra: func(f Field) string { return f.Label }, + Render: func(st styles.Styles, f Field, width int) string { + value := f.Value + switch { + case f.Err: + value = st.Error.Render(value) + case f.Info: + value = st.Muted.Render(value) + } + return ansi.Truncate(st.Label.Render(fmt.Sprintf("%-10s", f.Label))+" "+value, width, "…") + }, + }) + l.Empty = "nothing to show" + return Fields{List: l} +} + +// Show shows fields under title, instead of the secret pane. Another +// title (another account or vault) starts over, without a filter. +func (s *Fields) Show(title string, fields []Field) { + if title != s.Title { + s.Reset() + s.Title = title + } + s.shown = true + s.SetItems(fields) +} + +// Hide gives the fourth pane back to the secret pane. +func (s *Fields) Hide() { s.shown = false } + +// Shown reports whether the fourth pane shows Fields. +func (s Fields) Shown() bool { return s.shown } + +// Selected is the field under the cursor, unless it's an info row. +func (s Fields) Selected() (Field, bool) { + f, ok := s.List.Selected() + if !ok || f.Info { + return Field{}, false + } + return f, true +} 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..c1feb06 --- /dev/null +++ b/internal/ui/panes/list.go @@ -0,0 +1,449 @@ +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 + // Age says how old the items are ("2h ago") while they reload, by + // the counter. + Age string + // Header lines (a logo) go above the items while the cursor is in + // the rows under it, the pane has room and there's no filter; else + // HeaderShort, a line of plain text, does. + Header []string + HeaderShort string + + 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 + l.Age = "" +} + +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-- + } + r -= len(l.header()) + return max(0, r) +} + +// headerMinRows is how many item rows Header leaves at least. +const headerMinRows = 5 + +// header is the lines above the items: Header and a blank line, if it +// fits, leaves headerMinRows and the cursor is in the rows under it; +// else HeaderShort. +func (l List[T]) header() []string { + short := []string{} + if l.HeaderShort != "" { + short = []string{l.HeaderShort} + } + if len(l.Header) == 0 || l.filtering || l.input.Value() != "" { + return short + } + for _, line := range l.Header { + if ansi.StringWidth(line) > l.w-3 { + return short + } + } + below := l.h - 2 - len(l.Header) - 1 + if below < headerMinRows || l.cursor >= below { + return short + } + return append(l.Header[:len(l.Header):len(l.Header)], "") +} + +// 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()) + } + for _, line := range l.header() { + body = append(body, st.Notice.Render(" "+line)) + } + + switch { + case l.Loading && len(l.items) == 0: + body = append(body, " "+spin+" loading…") + case l.Err != nil: + for _, line := range wrapLines(l.Err.Error(), inner-1) { + 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)) + } + if l.Loading && l.Age != "" && len(l.items) > 0 { + counter = "cached " + l.Age + " · " + counter + } + 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)) +} + +// wrapLines wraps text to width, line by line; a "• " item's further +// lines line up under its text. +func wrapLines(text string, width int) []string { + var out []string + for _, line := range strings.Split(text, "\n") { + indent := "" + if strings.HasPrefix(line, "• ") { + indent = " " + } + for i, l := range strings.Split(ansi.Wrap(line, max(1, width-len(indent)), " -/"), "\n") { + if i > 0 { + l = indent + l + } + out = append(out, l) + } + } + return out +} diff --git a/internal/ui/panes/list_test.go b/internal/ui/panes/list_test.go new file mode 100644 index 0000000..aa8aafc --- /dev/null +++ b/internal/ui/panes/list_test.go @@ -0,0 +1,203 @@ +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") + } +} + +func TestListHeader(t *testing.T) { + l := NewList("t", 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.Header, l.HeaderShort = []string{"ART1", "ART2"}, "short" + l.SetItems([]string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}) + l.SetSize(30, 2+2+1+6) // the art, a blank line and 6 rows + st := styles.Default() + if v := l.View(st, true, ""); !strings.Contains(v, "ART1") || strings.Contains(v, "short") { + t.Errorf("at the top, the art:\n%s", v) + } + l.Move(6) // below the rows under the art + if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") || !strings.Contains(v, " g") { + t.Errorf("further down, the text:\n%s", v) + } + l.Top() + l.SetSize(30, 8) // too short for the art and 5 rows + if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") { + t.Errorf("no room, the text:\n%s", v) + } +} diff --git a/internal/ui/panes/valueview.go b/internal/ui/panes/valueview.go new file mode 100644 index 0000000..beae4d3 --- /dev/null +++ b/internal/ui/panes/valueview.go @@ -0,0 +1,79 @@ +package panes + +import ( + "bytes" + "crypto/sha1" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "strings" + "time" +) + +// A revealed value is shown as it reads best: JSON indented, and PEM +// certificates as what they say (subject, issuer, dates, names) rather +// than base64, with any other PEM block, a private key say, named but not +// shown. Anything else is shown as it is. y copies it as stored. + +// valueView is how to show v, and what's been done to it ("" when it's +// shown as it is). +func valueView(v string) (lines []string, note string) { + if lines, ok := pemView(v); ok { + return lines, "certificate" + } + if t := strings.TrimSpace(v); (strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[")) && json.Valid([]byte(t)) { + var b bytes.Buffer + if json.Indent(&b, []byte(t), "", " ") == nil { + return strings.Split(b.String(), "\n"), "JSON, formatted" + } + } + return strings.Split(v, "\n"), "" +} + +// pemView describes the PEM blocks in v, if it's PEM with a certificate. +func pemView(v string) ([]string, bool) { + rest := []byte(strings.TrimSpace(v)) + var out []string + certs := 0 + for len(rest) > 0 { + block, after := pem.Decode(rest) + if block == nil { + return nil, false // text that isn't PEM: show it all + } + rest = bytes.TrimSpace(after) + if len(out) > 0 { + out = append(out, "") + } + if block.Type != "CERTIFICATE" { + out = append(out, block.Type+" (not shown)") + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, false + } + certs++ + out = append(out, certLines(cert)...) + } + return out, certs > 0 +} + +func certLines(c *x509.Certificate) []string { + day := func(t time.Time) string { return t.UTC().Format("2006-01-02") } + out := []string{ + "subject " + c.Subject.String(), + "issuer " + c.Issuer.String(), + "valid " + day(c.NotBefore) + " to " + day(c.NotAfter), + } + names := append([]string{}, c.DNSNames...) + for _, ip := range c.IPAddresses { + names = append(names, ip.String()) + } + names = append(names, c.EmailAddresses...) + if len(names) > 0 { + out = append(out, "names "+strings.Join(names, ", ")) + } + sum := sha1.Sum(c.Raw) // what Azure and Windows call the thumbprint + return append(out, "SHA-1 "+fmt.Sprintf("%X", sum[:])) +} diff --git a/internal/ui/panes/valueview_test.go b/internal/ui/panes/valueview_test.go new file mode 100644 index 0000000..1f2e05b --- /dev/null +++ b/internal/ui/panes/valueview_test.go @@ -0,0 +1,60 @@ +package panes + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "slices" + "strings" + "testing" + "time" +) + +func TestValueViewJSON(t *testing.T) { + lines, note := valueView(`{"user":"app","port":5432,"tags":["a"]}`) + want := []string{"{", ` "user": "app",`, ` "port": 5432,`, ` "tags": [`, ` "a"`, " ]", "}"} + if !slices.Equal(lines, want) || note != "JSON, formatted" { + t.Errorf("lines %q, note %q", lines, note) + } + for _, v := range []string{"plain value", "{not json", "42", "line one\nline two"} { + if lines, note := valueView(v); note != "" || strings.Join(lines, "\n") != v { + t.Errorf("%q shown as %q (%q)", v, lines, note) + } + } +} + +func TestValueViewCertificate(t *testing.T) { + 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", "www.app.example.com"}, + NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + keyDER, _ := x509.MarshalECPrivateKey(key) + bundle := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + + string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) + + lines, note := valueView(bundle) + text := strings.Join(lines, "\n") + for _, want := range []string{"subject CN=app.example.com", "valid 2026-01-01 to 2027-01-01", + "names app.example.com, www.app.example.com", "SHA-1 ", "EC PRIVATE KEY (not shown)"} { + if !strings.Contains(text, want) { + t.Errorf("missing %q:\n%s", want, text) + } + } + if note != "certificate" || strings.Contains(text, "BEGIN") { + t.Errorf("note %q, or PEM shown:\n%s", note, text) + } + // A private key alone isn't a certificate: shown as it is. + if _, note := valueView(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))); note != "" { + t.Errorf("a key alone: note %q", note) + } +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go new file mode 100644 index 0000000..8fe991c --- /dev/null +++ b/internal/ui/styles/styles.go @@ -0,0 +1,105 @@ +// 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 ( + "image/color" + + "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 + + // themed marks the selection colours as set by the theme, so + // OnBackground leaves them. + themed, themedBlurred bool +} + +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; + // in every pane, so each shows what's selected in it. The focused + // pane's is bold too. Palette 8 until the terminal says what its + // background is (OnBackground). + Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true), + SelectedBlurred: lipgloss.NewStyle().Background(lipgloss.BrightBlack), + 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 +} + +// OnBackground shades the selection colours from the terminal's +// background, a little lighter on a dark one and darker on a light one: +// a quiet band that keeps the row's colours readable, where palette 8 is +// often too bright. Colours the theme sets are kept. +func (s Styles) OnBackground(bg color.Color, dark bool) Styles { + shade := func(p float64) color.Color { + if dark { + return lipgloss.Lighten(bg, p) + } + return lipgloss.Darken(bg, p) + } + if !s.themed { + s.Selected = s.Selected.Background(shade(0.09)) + } + if !s.themedBlurred { + s.SelectedBlurred = s.SelectedBlurred.Background(shade(0.05)) + } + return s +} diff --git a/internal/ui/styles/theme.go b/internal/ui/styles/theme.go new file mode 100644 index 0000000..6142d1f --- /dev/null +++ b/internal/ui/styles/theme.go @@ -0,0 +1,200 @@ +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) + st.themed = true + case "inactiveViewSelectedLineBgColor": + st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true) + st.themedBlurred = 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..4ae4d4d --- /dev/null +++ b/internal/ui/styles/theme_test.go @@ -0,0 +1,94 @@ +package styles + +import ( + "fmt" + "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()) + } +} + +func TestOnBackground(t *testing.T) { + hex := func(c any) string { + r, g, b, _ := c.(interface{ RGBA() (r, g, b, a uint32) }).RGBA() + return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8) + } + st := Default().OnBackground(lipgloss.Color("#282c34"), true) + if got := hex(st.Selected.GetBackground()); got != "#3e424a" { + t.Errorf("dark: selected %s", got) + } + if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" { + t.Errorf("dark: blurred %s", got) + } + if got := hex(Default().OnBackground(lipgloss.Color("#ffffff"), false).Selected.GetBackground()); got != "#e8e8e8" { + t.Errorf("light: selected %s", got) + } + + themed, _, err := FromTheme(map[string][]string{"selectedLineBgColor": {"#2d3f76"}}) + if err != nil { + t.Fatal(err) + } + st = themed.OnBackground(lipgloss.Color("#282c34"), true) + if got := hex(st.Selected.GetBackground()); got != "#2d3f76" { + t.Errorf("themed: selected %s (want the theme's)", got) + } + if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" { + t.Errorf("themed: blurred %s (not themed: shaded)", got) + } +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..c7e08f8 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,315 @@ +// 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. +// +// Releases are published in more than one place; each is tried in turn +// (GitHub, then git.kebcloud.no), so one being down doesn't stop updates. +package update + +import ( + "archive/tar" + "bufio" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +// DefaultRepo is lazysecrets' home: its source, docs and releases. +// GitHubRepo mirrors it, with the issues and the releases too. +const ( + DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets" + GitHubRepo = "https://github.com/karleinarb/lazysecrets" +) + +// DefaultServers are where releases are looked for, in order. +var DefaultServers = []string{GitHubRepo, DefaultRepo} + +// latestTimeout bounds asking one server for the latest release, so a +// server that's down leaves time for the next. +const latestTimeout = 5 * time.Second + +// 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 PlainLinks(strings.TrimSpace(notes)) +} + +// PlainLinks shows Markdown links as their text, for a terminal. +func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") } + +// mdLink is a Markdown link, [text](url): the notes link PRs and commits, +// and a terminal shows them as their text. +var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`) + +// Client talks to the release servers. +type Client struct { + // Servers are the repositories' web addresses, on GitHub or a Gitea + // or Forgejo server, tried in order; DefaultServers when empty. + Servers []string + HTTP *http.Client +} + +func (c *Client) servers() []string { + if len(c.Servers) > 0 { + return c.Servers + } + return DefaultServers +} + +func (c *Client) client() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: 2 * time.Minute} +} + +// API is a repository's API: GitHub's for github.com, else Gitea's (and +// Forgejo's). +func API(repo string) (string, error) { + u, err := url.Parse(strings.TrimSuffix(repo, "/")) + if err != nil || u.Host == "" || strings.Count(u.Path, "/") != 2 { + return "", fmt.Errorf("%q isn't a repository's address, like %s", repo, GitHubRepo) + } + if u.Host == "github.com" { + return "https://api.github.com/repos" + u.Path, nil + } + return u.Scheme + "://" + u.Host + "/api/v1/repos" + u.Path, nil +} + +// Latest is the newest release (pre-releases aside), from the first +// server that answers. +func (c *Client) Latest(ctx context.Context) (Release, error) { + var errs []error + for _, repo := range c.servers() { + r, err := c.latest(ctx, repo) + if err == nil { + return r, nil + } + errs = append(errs, err) + } + return Release{}, errors.Join(errs...) +} + +func (c *Client) latest(ctx context.Context, repo string) (Release, error) { + api, err := API(repo) + if err != nil { + return Release{}, err + } + ctx, cancel := context.WithTimeout(ctx, latestTimeout) + defer cancel() + b, err := c.get(ctx, 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 from %s: %w", repo, err) + } + if !semver.IsValid(r.Tag) { + return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo) + } + 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) + archive, err := c.download(ctx, r.Tag, name+".tar.gz") + if err != nil { + return err + } + 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 +} + +// download fetches a release's file from the first server that has it, +// checked against that server's checksums.txt. A file that doesn't match +// stops there: it's damaged, or not the release. +func (c *Client) download(ctx context.Context, tag, file string) ([]byte, error) { + var errs []error + for _, repo := range c.servers() { + base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/" + sctx, cancel := context.WithTimeout(ctx, latestTimeout) + sums, err := c.get(sctx, base+"checksums.txt", 1<<20) + cancel() + if err != nil { + errs = append(errs, err) + continue + } + want, err := checksum(sums, file) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", repo, err)) + continue + } + b, err := c.get(ctx, base+file, maxDownload) + if err != nil { + errs = append(errs, err) + continue + } + if got := sha256.Sum256(b); hex.EncodeToString(got[:]) != want { + return nil, fmt.Errorf("%s from %s doesn't match checksums.txt: damaged, or not the release", file, repo) + } + return b, nil + } + return nil, errors.Join(errs...) +} + +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..190f7f0 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,179 @@ +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/v1/repos/keb/lazysecrets/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("/keb/lazysecrets/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("/keb/lazysecrets/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{Servers: []string{srv.URL + "/keb/lazysecrets"}} + 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) + } +} + +// A server that's down, or doesn't have the release, is skipped. +func TestFallsBackToTheNextServer(t *testing.T) { + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skip("releases are for macOS and Linux") + } + down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "down", http.StatusBadGateway) + })) + t.Cleanup(down.Close) + srv := server(t, false) + c := &Client{Servers: []string{down.URL + "/keb/lazysecrets", srv.URL + "/keb/lazysecrets"}} + 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) + } + + // Every server down: both say why. + c = &Client{Servers: []string{down.URL + "/a/b", down.URL + "/c/d"}} + if _, err := c.Latest(context.Background()); err == nil || strings.Count(err.Error(), "502") != 2 { + t.Errorf("all down: %v", err) + } +} + +func TestAPI(t *testing.T) { + for repo, want := range map[string]string{ + "https://github.com/karleinarb/lazysecrets": "https://api.github.com/repos/karleinarb/lazysecrets", + "https://git.kebcloud.no/keb/lazysecrets/": "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets", + "https://codeberg.org/someone/lazysecrets": "https://codeberg.org/api/v1/repos/someone/lazysecrets", + "https://git.kebcloud.no/keb": "", + "git.kebcloud.no/keb/lazysecrets": "", + "https://github.com/karleinarb/lazysecrets/x": "", + } { + got, err := API(repo) + if got != want || (err != nil) != (want == "") { + t.Errorf("API(%q) = %q, %v", repo, got, err) + } + } +} + +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{Servers: []string{srv.URL + "/keb/lazysecrets"}} + 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") + } +} + +func TestNotesShowLinksAsText(t *testing.T) { + r := Release{Body: "### Added\n\n- Search ([#11](https://git.example.com/pulls/11)) ([65dc2d7](https://git.example.com/commit/65dc2d7abc))\n\n## Install\n\ncurl …"} + if got, want := r.Notes(), "### Added\n\n- Search (#11) (65dc2d7)"; got != want { + t.Errorf("notes %q, want %q", got, want) + } +} diff --git a/scripts/github-release.mjs b/scripts/github-release.mjs new file mode 100644 index 0000000..d1ca7dd --- /dev/null +++ b/scripts/github-release.mjs @@ -0,0 +1,75 @@ +// Copies a release GoReleaser published on Gitea to the GitHub mirror: +// the same notes and the same files (dist/), so lazysecrets can update +// from either (internal/update tries GitHub first). Run by the release +// workflow after GoReleaser; a release already on GitHub for the tag is +// replaced, like GoReleaser's mode: replace on Gitea. +// +// TAG=v0.2.0 GH_TOKEN=… GITEA_TOKEN=… node scripts/github-release.mjs +// +// GH_TOKEN is a fine-grained token that can write the mirror's contents; +// without it, this does nothing. No dependencies: Node 20's fetch. + +import { readdir, readFile } from "node:fs/promises"; + +const github = "karleinarb/lazysecrets"; +const { TAG: tag, GH_TOKEN: token, GITEA_TOKEN: giteaToken, GITHUB_SHA: sha } = process.env; +const gitea = `${process.env.GITHUB_SERVER_URL ?? "https://git.kebcloud.no"}/api/v1/repos/${process.env.GITHUB_REPOSITORY ?? "keb/lazysecrets"}`; + +if (!token) { + console.log("No GH_TOKEN: not copying the release to GitHub."); + process.exit(0); +} +if (!tag) throw new Error("TAG isn't set"); + +const gh = (path, init = {}) => + fetch(path.startsWith("https://") ? path : `https://api.github.com/repos/${github}${path}`, { + ...init, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + ...init.headers, + }, + }); + +async function ok(res, what) { + if (!res.ok) throw new Error(`${what}: ${res.status} ${await res.text()}`); + return res; +} + +// The notes as GoReleaser wrote them on Gitea. +const rel = await ( + await ok(await fetch(`${gitea}/releases/tags/${tag}`, { headers: giteaToken ? { Authorization: `token ${giteaToken}` } : {} }), "reading the Gitea release") +).json(); + +// Replace what's there for the tag. +const old = await gh(`/releases/tags/${tag}`); +if (old.ok) { + const { id } = await old.json(); + await ok(await gh(`/releases/${id}`, { method: "DELETE" }), "deleting the old GitHub release"); + console.log(`Deleted the GitHub release for ${tag}.`); +} + +// The mirror may not have the tag's commit yet: it pushes on its own. +// Wait for it a while. +const body = JSON.stringify({ tag_name: tag, target_commitish: sha, name: rel.name || tag, body: rel.body, prerelease: rel.prerelease }); +let created; +for (let i = 0; ; i++) { + const res = await gh("/releases", { method: "POST", body }); + if (res.ok) { + created = await res.json(); + break; + } + if (res.status !== 422 || i === 9) await ok(res, "creating the GitHub release"); + console.log("GitHub doesn't have the commit yet; waiting for the mirror…"); + await new Promise((r) => setTimeout(r, 30_000)); +} + +const files = (await readdir("dist")).filter((f) => f.endsWith(".tar.gz") || f === "checksums.txt"); +if (files.length === 0) throw new Error("nothing to upload in dist/"); +for (const f of files) { + const url = `https://uploads.github.com/repos/${github}/releases/${created.id}/assets?name=${encodeURIComponent(f)}`; + await ok(await gh(url, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: await readFile(`dist/${f}`) }), `uploading ${f}`); + console.log(`Uploaded ${f}.`); +} +console.log(`Published ${tag} on GitHub: ${created.html_url}`); 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."