Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
6fab41703e
111 files changed
+22988
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Release
|
||||
|
||||
# A tag vMAJOR.MINOR.PATCH (or with a suffix, like v0.2.0-rc.1, published
|
||||
# as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml)
|
||||
# builds it for macOS and Linux, arm64 and amd64, and publishes a Gitea
|
||||
# release with the archives, checksums and this release's section of the
|
||||
# changelog (git-cliff, cliff.toml). Pushing the same tag again (after
|
||||
# deleting it) replaces the release.
|
||||
#
|
||||
# scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build and publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout (full history, for the changelog)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
# The runner mounts Go, the module cache and the build cache as
|
||||
# volumes (nix-config, gitea-runner.nix): nothing to restore.
|
||||
cache: false
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
go vet ./...
|
||||
go test ./...
|
||||
|
||||
- name: Install git-cliff
|
||||
# Pinned, with checksums.
|
||||
run: |
|
||||
set -eo pipefail
|
||||
V=2.14.2
|
||||
case "$(uname -m)" in
|
||||
x86_64) A=x86_64; SUM=26d1f7c8ea2400f2ccb0b2e7f321635f600b583bf4cd1f7afaa5e8c24068ad1088a2504e0a4d9f4333d4bb20a80329327d9462634343ee3af91e1a7fbdc6f18a ;;
|
||||
aarch64) A=aarch64; SUM=fd27768ea76fea8c3ba3f3247fef2f6604988b629f15e48bf40be5eaa08b8fef2e4087cc9c8af16bc522bc19cc369611fa7692101b513f63469ad58522708600 ;;
|
||||
*) echo "::error::no git-cliff for $(uname -m)"; exit 1 ;;
|
||||
esac
|
||||
F="git-cliff-$V-$A-unknown-linux-gnu.tar.gz"
|
||||
curl -fsSL -o "$RUNNER_TEMP/$F" "https://github.com/orhun/git-cliff/releases/download/v$V/$F"
|
||||
echo "$SUM $RUNNER_TEMP/$F" | sha512sum -c -
|
||||
tar -xzf "$RUNNER_TEMP/$F" -C "$RUNNER_TEMP"
|
||||
install "$RUNNER_TEMP/git-cliff-$V/git-cliff" /usr/local/bin/
|
||||
|
||||
- name: Release notes
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -eo pipefail
|
||||
# This release's section of the changelog: the feat/fix/perf pull
|
||||
# requests since the previous release, without its heading (the
|
||||
# release has a title). A pre-release's tag isn't a release to
|
||||
# git-cliff (tag_pattern), so it's what's unreleased. Written
|
||||
# outside the checkout, which GoReleaser wants clean.
|
||||
if [[ "$TAG" == *-* ]]; then
|
||||
notes=$(git cliff --unreleased --tag "$TAG" --strip all)
|
||||
else
|
||||
notes=$(git cliff --latest --strip all)
|
||||
fi
|
||||
printf '%s\n' "$notes" | sed '1{/^## /d;}' | sed '/./,$!d' > "$RUNNER_TEMP/notes.md"
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
|
||||
- name: GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: v2.18.3
|
||||
args: release --clean --release-notes ${{ runner.temp }}/notes.md
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+33
@@ -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/
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
Every release of lazysecrets, newest first.
|
||||
|
||||
## v0.1.0 – 2026-10-10
|
||||
|
||||
Hello world 👋
|
||||
|
||||
<!-- generated by git-cliff (cliff.toml) -->
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,110 @@
|
||||
# lazysecrets
|
||||
|
||||
A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic lazygit.
|
||||
|
||||
## Main features
|
||||
|
||||
- **Read-only**: lazysecrets never creates, changes or deletes a secret, so
|
||||
it's safe to point at production. 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 (`'exact`, `^prefix`, `suffix$`, `!not`), and jumps
|
||||
straight to the result.
|
||||
- **Share links**: `s` copies a `lazysecrets://` link. A colleague opens it
|
||||
with their own access, from the terminal or with a click in the browser
|
||||
or chat.
|
||||
- **Careful with values**: values stay hidden until you reveal them, and
|
||||
copied values are cleared from the clipboard after 30 seconds.
|
||||
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
|
||||
pipes.
|
||||
- **Favourites**: star the secrets you use most and get back to them in one
|
||||
key.
|
||||
- **Expiry warnings**: secrets, keys and certificates that expire soon are
|
||||
marked.
|
||||
- **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](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. The keys you'll
|
||||
use most:
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `j`/`k`, `h`/`l`, `enter`, `esc` | move, switch panes, drill in, back |
|
||||
| `/` | filter the list you're in |
|
||||
| `space` | reveal / hide the value |
|
||||
| `y` | copy the value (cleared from the clipboard after 30s) |
|
||||
| `ctrl+s` | Go to: search everything and jump there |
|
||||
| `f` / `F` | star / your favourites |
|
||||
| `s` | copy a link to share |
|
||||
| `?` | every key, searchable |
|
||||
| `q` | quit |
|
||||
|
||||
## Docs
|
||||
|
||||
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
|
||||
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
|
||||
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
|
||||
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting
|
||||
- [Links](docs/links.md): sharing, opening, `lazysecrets get` in scripts,
|
||||
and opening links from the browser and chat
|
||||
- [Custom commands](docs/custom-commands.md): run your own commands on a
|
||||
secret
|
||||
- [Config](docs/config.md): every option, themes, layered configs, the
|
||||
state file
|
||||
- [Development](docs/development.md): building, tests, pull requests and
|
||||
releasing
|
||||
|
||||
## Privacy
|
||||
|
||||
lazysecrets never stores, caches or sends your secret values. 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 this server, which
|
||||
`updateCheck: false` turns off.
|
||||
|
||||
|
||||
## 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).
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# git-cliff (https://git-cliff.org) builds CHANGELOG.md and the release
|
||||
# notes from the commits on main. Pull requests are squash-merged, so each
|
||||
# one is a single commit whose message is the PR title: a conventional one
|
||||
# (feat: …, fix: …, perf: …) puts it in the changelog, anything else
|
||||
# (chore, ci, docs, refactor, test, …) leaves it out.
|
||||
#
|
||||
# git cliff --unreleased # what the next release would say
|
||||
|
||||
[changelog]
|
||||
header = """
|
||||
# Changelog
|
||||
|
||||
Every release of lazysecrets, newest first.\n
|
||||
"""
|
||||
body = """
|
||||
{% if version -%}
|
||||
## {{ version }} – {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else -%}
|
||||
## Unreleased
|
||||
{% endif -%}
|
||||
{% if commits | length == 0 %}
|
||||
Hello world 👋
|
||||
{% endif -%}
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.breaking %}**Breaking:** {% endif %}{{ commit.message | split(pat="\n") | first | upper_first | trim }}\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
footer = "<!-- generated by git-cliff (cliff.toml) -->"
|
||||
trim = true
|
||||
# Runs on each part (header, every release) separately.
|
||||
postprocessors = [
|
||||
{ pattern = "\n{3,}", replace = "\n\n" },
|
||||
]
|
||||
|
||||
[git]
|
||||
conventional_commits = true
|
||||
filter_unconventional = false
|
||||
split_commits = false
|
||||
protect_breaking_commits = true
|
||||
tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+$"
|
||||
sort_commits = "oldest"
|
||||
commit_preprocessors = [
|
||||
# A merge commit (not a squash) says "Merge pull request 'feat: …' (#4)
|
||||
# from branch into main": keep the title and number.
|
||||
{ pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" },
|
||||
# Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see
|
||||
# pr-title.yml); all become "feat: x".
|
||||
{ pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " },
|
||||
{ pattern = "^(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?):(\\S)", replace = "${1}${2}${3}: ${4}" },
|
||||
]
|
||||
commit_parsers = [
|
||||
{ body = "\\[skip changelog\\]", skip = true },
|
||||
|
||||
{ message = "^[a-z]+(\\(.*\\))?!:", group = "<!-- 0 -->Added" },
|
||||
{ message = "^feat", group = "<!-- 0 -->Added" },
|
||||
{ message = "^perf", group = "<!-- 1 -->Changed" },
|
||||
{ message = "^fix", group = "<!-- 2 -->Fixed" },
|
||||
{ message = ".*", skip = true },
|
||||
]
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>.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>")
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// Command lazysecrets is a terminal UI for browsing secrets in Azure Key
|
||||
// Vault and Databricks secret scopes.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/instance"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/azure"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/databricks"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// looksLikeRef tells a reference given without "open" from a mistyped
|
||||
// subcommand, so the latter still gets the usage text.
|
||||
func looksLikeRef(s string) bool {
|
||||
return strings.Contains(s, "://") || strings.HasPrefix(s, "@Microsoft.KeyVault(") || strings.HasPrefix(s, "{{secrets/")
|
||||
}
|
||||
|
||||
// starredRepos are the repos the user starred something in: the repo, a
|
||||
// file or a key. Their records keep the repo's folder.
|
||||
func starredRepos(st *store.Store) []string {
|
||||
var out []string
|
||||
for key, n := range st.Nodes() {
|
||||
r, err := ref.Parse(key)
|
||||
if err == nil && r.Kind == provider.KindRepo && len(n.StarredBy) > 0 && n.Source != "" && !slices.Contains(out, n.Source) {
|
||||
out = append(out, n.Source)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// checkCommandKeys reports a custom command key that a built-in action or
|
||||
// another command in an overlapping context takes.
|
||||
func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
|
||||
for i, c := range cmds {
|
||||
if c.Key == "" {
|
||||
continue
|
||||
}
|
||||
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command))
|
||||
if err := km.CheckExtra(c.Key, name); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range cmds[:i] {
|
||||
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
|
||||
if d.Key == c.Key && overlap {
|
||||
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmpOr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
|
||||
// config directory.
|
||||
func defaultConfigPath() string {
|
||||
if env := os.Getenv("LAZYSECRETS_CONFIG_FILE"); env != "" {
|
||||
return env
|
||||
}
|
||||
dir, err := config.Dir()
|
||||
if err != nil {
|
||||
return "config.yaml"
|
||||
}
|
||||
return filepath.Join(dir, "config.yaml")
|
||||
}
|
||||
|
||||
func run() error {
|
||||
configPath := flag.String("config", defaultConfigPath(),
|
||||
"config file; several, comma-separated, are layered in order (also LAZYSECRETS_CONFIG_FILE)")
|
||||
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||
dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)")
|
||||
demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends")
|
||||
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request")
|
||||
icons := flag.Bool("icons", true,
|
||||
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0")
|
||||
clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
|
||||
lazysecrets [flags] browse secrets
|
||||
lazysecrets [flags] [open] <ref>
|
||||
open a lazysecrets:// link, Key Vault URL,
|
||||
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
|
||||
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
||||
for scripts: VAR=$(lazysecrets get <ref>)
|
||||
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 <ref> open a link in a new terminal window, as
|
||||
the link handler does
|
||||
lazysecrets update [--check] install the newest release in place of this
|
||||
one (--check: just say if there is one)
|
||||
|
||||
Flags:
|
||||
`)
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.Parse()
|
||||
if *showVersion {
|
||||
fmt.Println("lazysecrets", versionString())
|
||||
return nil
|
||||
}
|
||||
|
||||
var open, getRef *ref.Ref
|
||||
printKeys := false
|
||||
switch args := flag.Args(); {
|
||||
case len(args) == 1 && args[0] == "keybindings":
|
||||
printKeys = true
|
||||
case len(args) == 2 && args[0] == "get":
|
||||
r, err := ref.Parse(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
getRef = &r
|
||||
case len(args) > 0 && args[0] == "handler":
|
||||
return handler(*configPath, args[1:])
|
||||
case len(args) > 0 && args[0] == "launch":
|
||||
return launch(*configPath, args[1:])
|
||||
case len(args) > 0 && args[0] == "update":
|
||||
return updateCmd(args[1:])
|
||||
case len(args) == 0:
|
||||
case args[0] == "open" && len(args) == 2:
|
||||
r, err := ref.Parse(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
open = &r
|
||||
case len(args) == 1 && looksLikeRef(args[0]):
|
||||
// lazysecrets <link>, like lazysecrets open <link>
|
||||
r, err := ref.Parse(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
open = &r
|
||||
default:
|
||||
flag.Usage()
|
||||
return fmt.Errorf("unexpected arguments: %v", args)
|
||||
}
|
||||
|
||||
cfg, err := config.LoadAll(strings.Split(*configPath, ","))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
theme, defaultFg, err := styles.FromTheme(cfg.Theme)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keymap := keys.Default()
|
||||
if err := keymap.Apply(cfg.Keybinding); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
|
||||
return err
|
||||
}
|
||||
if printKeys {
|
||||
fmt.Print(keymap.YAML())
|
||||
return nil
|
||||
}
|
||||
// Precedence: flag > environment > config file > default.
|
||||
set := map[string]bool{}
|
||||
flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
|
||||
if !set["timeout"] && cfg.Timeout > 0 {
|
||||
*timeout = cfg.Timeout
|
||||
}
|
||||
if !set["clipboard-clear"] && cfg.ClipboardClear != nil {
|
||||
*clipClear = *cfg.ClipboardClear
|
||||
}
|
||||
expiryDays := 30
|
||||
if cfg.ExpiryWarningDays != nil {
|
||||
expiryDays = *cfg.ExpiryWarningDays
|
||||
}
|
||||
revealTimeout := 60 * time.Second
|
||||
if cfg.RevealTimeout != nil {
|
||||
revealTimeout = *cfg.RevealTimeout
|
||||
}
|
||||
loadDelay := 150 * time.Millisecond
|
||||
if cfg.LoadDelay != nil {
|
||||
loadDelay = *cfg.LoadDelay
|
||||
}
|
||||
if !set["icons"] {
|
||||
switch {
|
||||
case os.Getenv("LAZYSECRETS_ICONS") != "":
|
||||
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
|
||||
case cfg.Icons != nil:
|
||||
*icons = *cfg.Icons
|
||||
}
|
||||
}
|
||||
|
||||
// Stars, notes and the layout. Demo mode keeps them in memory, so
|
||||
// sample vaults never end up in the real file.
|
||||
stateDir, err := config.StateDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
statePath := ""
|
||||
if !*demo {
|
||||
statePath = filepath.Join(stateDir, "state.json")
|
||||
}
|
||||
st, err := store.Load(statePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var providers []provider.Provider
|
||||
if *demo {
|
||||
// Two Azure accounts, like a personal and an admin login in az.
|
||||
providers = []provider.Provider{
|
||||
fake.Azure(400*time.Millisecond).As("Azure:dev@example.com/demo", "Azure · dev"),
|
||||
fake.AzureOps(700*time.Millisecond).As("Azure:ops@example.com/demo", "Azure · ops"),
|
||||
fake.Databricks(600 * time.Millisecond),
|
||||
}
|
||||
} else {
|
||||
pc := cfg.Providers
|
||||
// One provider per az account. Created even when disabled: AKV-
|
||||
// backed Databricks scopes fall back to reading the vault through
|
||||
// the default account.
|
||||
accounts, err := azure.NewAccounts(azure.Settings{
|
||||
HideSubscriptions: pc.Azure.HideSubscriptions,
|
||||
Vaults: pc.Azure.Vaults,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("azure: %w", err)
|
||||
}
|
||||
if config.On(pc.Azure.Enabled) {
|
||||
for _, a := range accounts {
|
||||
providers = append(providers, a)
|
||||
}
|
||||
}
|
||||
if config.On(pc.Databricks.Enabled) {
|
||||
dbx, err := databricks.New(accounts[0], pc.Databricks.HideProfiles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("databricks: %w", err)
|
||||
}
|
||||
providers = append(providers, dbx)
|
||||
}
|
||||
if config.On(pc.Repos.Enabled) {
|
||||
start := *dir
|
||||
if start == "" {
|
||||
start, _ = os.Getwd()
|
||||
}
|
||||
roots := []string{"~/git"}
|
||||
if pc.Repos.Roots != nil {
|
||||
roots = *pc.Repos.Roots
|
||||
}
|
||||
repos := repo.New(repo.Settings{
|
||||
Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st),
|
||||
Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
|
||||
})
|
||||
// The repo it started in (shown on top as Repo) and the
|
||||
// others, checked for secret files in the background like
|
||||
// the other sources; repos without any aren't listed.
|
||||
providers = append(providers, repos)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return fmt.Errorf("all providers are disabled in %s", *configPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Libraries (notably the Databricks SDK) log token refreshes through
|
||||
// the standard logger, which would draw over the TUI.
|
||||
log.SetOutput(io.Discard)
|
||||
|
||||
if getRef != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
return get(ctx, providers, st, *getRef)
|
||||
}
|
||||
|
||||
// Links opened elsewhere (lazysecrets launch) can come here.
|
||||
var p *tea.Program
|
||||
var srv *instance.Server
|
||||
if !*demo {
|
||||
srv, err = instance.Listen(instance.Dir(stateDir), func(link, front string) error {
|
||||
r, err := ref.Parse(link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Accept at once: launch waits for the answer, and opens a
|
||||
// window anyway if it's slow. Coming to the front can be
|
||||
// (activating the terminal app).
|
||||
go func() {
|
||||
p.Send(app.OpenLink(r))
|
||||
instance.Raise(front)
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
defer srv.Close()
|
||||
} // else links open in new windows: not worth failing for
|
||||
}
|
||||
var setup *app.HandlerSetup
|
||||
if !*demo {
|
||||
setup = handlerSetup(cfg, *configPath)
|
||||
}
|
||||
onActive := func() {}
|
||||
if srv != nil {
|
||||
onActive = srv.Touch
|
||||
}
|
||||
|
||||
m := app.New(providers, app.Options{
|
||||
Timeout: *timeout,
|
||||
Icons: *icons,
|
||||
ClipboardClear: *clipClear,
|
||||
FlatSources: cfg.SourcesView == "flat",
|
||||
CollapseGroups: config.On(cfg.CollapseGroups),
|
||||
LoadDelay: loadDelay,
|
||||
NoHistory: !config.On(cfg.History),
|
||||
OnActive: onActive,
|
||||
RevealTimeout: revealTimeout,
|
||||
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
||||
HideOnBlur: config.On(cfg.HideOnBlur),
|
||||
HandlerSetup: setup,
|
||||
Updater: updater(),
|
||||
UpdateCheck: config.On(cfg.UpdateCheck),
|
||||
Keys: &keymap,
|
||||
Commands: cfg.CustomCommands,
|
||||
Version: versionString(),
|
||||
Store: st,
|
||||
Open: open,
|
||||
Styles: &theme,
|
||||
DefaultFg: defaultFg,
|
||||
})
|
||||
p = tea.NewProgram(m)
|
||||
_, err = p.Run()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
|
||||
// updater is the app's way to find and install releases, replacing the
|
||||
// binary that's running.
|
||||
func updater() *app.Updater {
|
||||
c := &update.Client{}
|
||||
return &app.Updater{
|
||||
Latest: c.Latest,
|
||||
Install: func(ctx context.Context, r update.Release) error {
|
||||
exe, err := update.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Install(ctx, r, exe)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// updateCmd is lazysecrets update [--check]: the newer release's
|
||||
// changelog, then (unless --check) install it.
|
||||
func updateCmd(args []string) error {
|
||||
check := len(args) == 1 && args[0] == "--check"
|
||||
if len(args) > 0 && !check {
|
||||
return errors.New("usage: lazysecrets update [--check]")
|
||||
}
|
||||
current := versionString()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
c := &update.Client{}
|
||||
r, err := c.Latest(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
|
||||
}
|
||||
if !update.Newer(current, r.Tag) {
|
||||
if !update.Newer(current, "v999.0.0") {
|
||||
return fmt.Errorf("this is a development build (%s); the latest release is %s: install it with install.sh, or go install …@%s", current, r.Tag, r.Tag)
|
||||
}
|
||||
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes()))
|
||||
if check {
|
||||
fmt.Println("lazysecrets update installs it.")
|
||||
return nil
|
||||
}
|
||||
exe, err := update.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Updating %s…\n", exe)
|
||||
if err := c.Install(ctx, r, exe); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Updated to %s.\n", r.Tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
func indent(s string) string {
|
||||
if s == "" {
|
||||
return " (no changelog)"
|
||||
}
|
||||
return " " + strings.ReplaceAll(s, "\n", "\n ")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Azure Key Vault
|
||||
|
||||
lazysecrets reads Azure with your az CLI login: run `az login` once, and
|
||||
it reuses it.
|
||||
|
||||
## Accounts
|
||||
|
||||
Every account signed in to az gets its own group in the Sources pane
|
||||
(`az login` again with another account to add one), and each account's
|
||||
vaults are read with that account. A subscription several accounts can see
|
||||
is listed under each, since their permissions can differ.
|
||||
|
||||
A service principal is used instead when `AZURE_CLIENT_ID`,
|
||||
`AZURE_TENANT_ID` and a secret or certificate are set in the environment.
|
||||
|
||||
If your az login expires mid-session, the status line says so: press `L` to
|
||||
run `az login` without leaving lazysecrets, and everything reloads.
|
||||
|
||||
## Permissions
|
||||
|
||||
Vaults are discovered with Resource Graph, so you need read access to the
|
||||
vault resources (e.g. *Reader*). Key Vault data-plane access is separate
|
||||
(Azure RBAC or access policies). With RBAC, typically:
|
||||
|
||||
| To | Role |
|
||||
|---|---|
|
||||
| list secrets, keys and certificates | *Key Vault Reader* |
|
||||
| read secret values | *Key Vault Secrets User* |
|
||||
| read certificate details | *Key Vault Certificate User* |
|
||||
|
||||
A missing permission is shown in the pane, including the action that was
|
||||
denied.
|
||||
|
||||
## Vaults you can't discover
|
||||
|
||||
A vault you can read but not see through Resource Graph can be pinned in
|
||||
the [config](config.md); it's listed under "Pinned vaults" and read with
|
||||
az's default account:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
azure:
|
||||
vaults:
|
||||
- https://kv-foo.vault.azure.net/
|
||||
hideSubscriptions: [platform-mgmt] # by name or ID
|
||||
```
|
||||
|
||||
## What's shown
|
||||
|
||||
Secrets, keys and certificates, each on their own tab of the Secrets pane
|
||||
(`[`/`]`). `space` reveals a secret's value, or the public material of a
|
||||
key (PEM public key) or certificate; private key material is never
|
||||
fetched. `v` lists an item's versions. Items expiring within
|
||||
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# Config
|
||||
|
||||
Optional, at `~/.config/lazysecrets/config.yaml` (or
|
||||
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override
|
||||
it. Unknown keys are an error, so typos don't go unnoticed.
|
||||
|
||||
```yaml
|
||||
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
|
||||
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
|
||||
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
|
||||
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
|
||||
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗
|
||||
timeout: 30s # per backend request
|
||||
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
|
||||
sourcesView: grouped # or flat; ` toggles it
|
||||
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
|
||||
updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way)
|
||||
history: true # count uses of items for Recent (R)
|
||||
providers:
|
||||
azure: # applies to every az account
|
||||
enabled: true # default
|
||||
hideSubscriptions: [platform-mgmt] # by name or ID
|
||||
vaults: # listed under "Pinned vaults", for vaults
|
||||
- https://kv-foo.vault.azure.net/ # you can read but not discover
|
||||
databricks:
|
||||
enabled: true
|
||||
hideProfiles: [test]
|
||||
repos:
|
||||
enabled: true
|
||||
roots: [~/git]
|
||||
paths: [~/work/infra]
|
||||
include: ["secrets/**"]
|
||||
exclude: ["**/testdata/**"]
|
||||
```
|
||||
|
||||
More, each on its own page: [repos](repos.md) (`providers.repos`), the
|
||||
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
|
||||
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
|
||||
[custom commands](custom-commands.md) (`customCommands`).
|
||||
|
||||
## Clipboard
|
||||
|
||||
The clipboard is the system clipboard locally and OSC52 over SSH (your
|
||||
terminal must allow OSC52 writes; in tmux, `set -g set-clipboard on`).
|
||||
|
||||
## Theme
|
||||
|
||||
Colours follow your terminal's 16-colour palette by default. A `theme:`
|
||||
section overrides roles with hex colours, palette numbers (0-255) or names,
|
||||
plus attributes (`bold`, `italic`, `underline`, `reverse`, `faint`). Role
|
||||
names follow lazygit's `gui.theme` where they mean the same thing:
|
||||
|
||||
```yaml
|
||||
theme:
|
||||
activeBorderColor: ["#ff966c", bold] # focused pane
|
||||
inactiveBorderColor: ["#589ed7"]
|
||||
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
|
||||
inactiveViewSelectedLineBgColor: ["#2f334d"]
|
||||
defaultFgColor: ["#c8d3f5"]
|
||||
optionsTextColor: ["#82aaff"] # keys and labels
|
||||
searchMatchColor: ["#ffc777", bold]
|
||||
errorColor: ["#c53b53"] # ✗ failures
|
||||
noticeColor: ["#c3e88d"]
|
||||
successColor: ["#c3e88d", bold] # the ✓ before confirmations
|
||||
warningColor: ["#ffc777"] # ⚠ what can't be done here
|
||||
mutedColor: ["#636da6"]
|
||||
favouriteColor: ["#ffc777"]
|
||||
valueColor: ["#ffc777"]
|
||||
azureColor: ["#82aaff"]
|
||||
databricksColor: ["#ff757f"]
|
||||
repoColor: ["#c099ff"]
|
||||
```
|
||||
|
||||
Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render
|
||||
as boxes, turn them off with `icons: false`.
|
||||
|
||||
## Layering
|
||||
|
||||
Several config files can be layered, later ones winning (theme roles and
|
||||
keybindings merge one by one), with a comma-separated `--config` or
|
||||
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`. An editor
|
||||
plugin can so add a generated theme file to your own config, the way
|
||||
snacks.nvim themes lazygit from Neovim's highlight groups:
|
||||
|
||||
```sh
|
||||
LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets
|
||||
```
|
||||
|
||||
## State
|
||||
|
||||
Stars, notes, usage and the layout are kept in
|
||||
`~/.local/state/lazysecrets/state.json` (`$XDG_STATE_HOME`), readable only
|
||||
by you: one record per source, vault or secret, keyed by its
|
||||
`lazysecrets://` link, never with values. A star belongs to the account you
|
||||
starred the item under (a vault two of your accounts can see can be starred
|
||||
for one); a note describes the item, so it shows under every account. It's
|
||||
JSON so an editor plugin can read it with a built-in parser; several
|
||||
lazysecrets windows can save to it without losing each other's changes.
|
||||
|
||||
Revealing, copying, opening in the browser, sharing and jumping to an item
|
||||
count as using it: its record keeps when it was last used and how often,
|
||||
for Recent (`R`). Records kept only for usage are dropped after 90 days
|
||||
unused, and beyond the 100 most recent. `history: false` stops counting.
|
||||
|
||||
## Updates
|
||||
|
||||
Once a day (`updateCheck`), in the background, lazysecrets asks the
|
||||
release server for the latest release; within the day it reuses the
|
||||
answer from the state file. Offline, or with the server down, it says
|
||||
nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
|
||||
`lazysecrets update --check`) shows its changelog.
|
||||
|
||||
Updating (`u` there, or `lazysecrets update`) downloads the release for
|
||||
your OS and CPU, checks it against the release's `checksums.txt`, makes
|
||||
sure it runs, and only then replaces the binary, atomically: a
|
||||
lazysecrets that's running keeps working, and the next start is the new
|
||||
version. A binary a package manager owns (Homebrew, Nix, a distro
|
||||
package) is left to it. Development builds (`dev-…`) aren't offered
|
||||
releases.
|
||||
@@ -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.<name>`. 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.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Databricks
|
||||
|
||||
Workspaces come from the profiles in `~/.databrickscfg` (or
|
||||
`$DATABRICKS_CONFIG_FILE`); account-console profiles are skipped. Each
|
||||
workspace is a source, its secret scopes are the vaults.
|
||||
|
||||
Credentials are resolved the same way as the Databricks CLI, so
|
||||
`auth_type = azure-cli` uses your az login and OAuth profiles use
|
||||
`databricks auth login`. When a login expires, `L` runs the right one for
|
||||
that profile.
|
||||
|
||||
Secrets in Azure Key Vault-backed scopes are read through Databricks; if
|
||||
Databricks refuses, lazysecrets reads the backing vault directly with your
|
||||
az login (which then needs the [Key Vault roles](azure.md#permissions)).
|
||||
|
||||
Hide profiles you don't want in the [config](config.md):
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
databricks:
|
||||
hideProfiles: [test]
|
||||
```
|
||||
@@ -0,0 +1,63 @@
|
||||
# Development
|
||||
|
||||
```sh
|
||||
go run ./cmd/lazysecrets
|
||||
go run ./cmd/lazysecrets --demo # built-in sample data, no Azure needed
|
||||
go test ./...
|
||||
go test ./internal/ui/app -run TestGolden -update # after intentional UI changes
|
||||
```
|
||||
|
||||
A stripped binary is about 21 MB instead of 30 MB:
|
||||
|
||||
```sh
|
||||
go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets
|
||||
```
|
||||
|
||||
Development builds show `dev-<commit>` as their version (`-dirty` with
|
||||
uncommitted changes); `go install …@v0.2.0` shows the tag, releases the
|
||||
version they were built for.
|
||||
|
||||
## Pull requests
|
||||
|
||||
Pull requests into `main` are squash-merged, so each is one commit titled
|
||||
like the PR. The title is a [conventional commit](https://www.conventionalcommits.org)
|
||||
subject, checked by `pr-title.yml`:
|
||||
|
||||
| Type | For | In the changelog |
|
||||
|---|---|---|
|
||||
| `feat` | a new feature | Added |
|
||||
| `perf` | faster or smaller, same behaviour | Changed |
|
||||
| `fix` | a bug fix | Fixed |
|
||||
| `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no |
|
||||
|
||||
`feat!:` marks a breaking change, `feat(scope):` names a part. Brackets
|
||||
and a missing space work too: `[feat] x`, `[feat]: x` and `feat:x` all
|
||||
become `feat: x` in the changelog. `[skip changelog]` in the description
|
||||
leaves a pull request out.
|
||||
|
||||
CI (`ci.yml`) runs gofmt, vet, staticcheck, the tests and a build for each
|
||||
platform on pull requests into `main`. `main` is protected: a pull request
|
||||
merges only when it's up to date with `main` (Gitea's "Update branch"
|
||||
button, or a rebase, then CI again), so what lands on `main` is exactly
|
||||
what its pull request tested, and CI doesn't run again there.
|
||||
|
||||
## Releasing
|
||||
|
||||
Merge as many pull requests as you like, then release `main`:
|
||||
|
||||
```sh
|
||||
scripts/release.sh --dry-run v0.2.0 # the notes the release would have
|
||||
scripts/release.sh v0.2.0 # CHANGELOG.md, commit, tag, push
|
||||
```
|
||||
|
||||
It refuses unless you're on a clean `main` that matches `origin/main`.
|
||||
[git-cliff](https://git-cliff.org) (`cliff.toml`) writes `CHANGELOG.md`
|
||||
from the pull request titles. The tag runs the release workflow
|
||||
(`.gitea/workflows/release.yml`): it tests, then
|
||||
[GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the
|
||||
archives and publishes a Gitea release with them and the release's
|
||||
changelog section. `goreleaser release --snapshot --clean` builds the same
|
||||
archives locally, into `dist/`, without publishing.
|
||||
|
||||
A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md`
|
||||
alone.
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
# Keys
|
||||
|
||||
`?` lists every key with what it does, the focused pane's first; type to
|
||||
filter, `enter` runs one. The help bar at the bottom shows the most useful
|
||||
keys for where you are. Every key can be [rebound](#rebinding-keys).
|
||||
|
||||
The rule: a letter in the lists, ctrl+ that letter where you type a filter
|
||||
(`f` stars, `ctrl+f` stars while filtering).
|
||||
|
||||
## Moving around
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `j`/`k`, `↑`/`↓` | move |
|
||||
| `g`/`G` | top / bottom |
|
||||
| `tab`/`l`, `shift+tab`/`h` | next / previous pane |
|
||||
| `1`–`4` | jump to pane |
|
||||
| `enter` / `esc`, `⌫` | drill in / back (also clears a filter or leaves the versions view) |
|
||||
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
|
||||
| `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars |
|
||||
| `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it |
|
||||
| `r` | refresh the focused pane |
|
||||
| `q`, `ctrl+c` | quit |
|
||||
|
||||
## Searching
|
||||
|
||||
`/` in a pane, Go to, `?` and other popups with a text field all search
|
||||
the same way, with [fzf](https://github.com/junegunn/fzf)'s scoring (the
|
||||
same as snacks.nvim's picker): letters at the start of a word or in one
|
||||
run count far more than letters scattered through a name, matches in an
|
||||
item's own name more than in its path or note, and equally good matches
|
||||
put the shorter name first.
|
||||
|
||||
| Query | Finds |
|
||||
|---|---|
|
||||
| `dbpw` | fuzzy: `db-password` |
|
||||
| `pass prod` | both terms, in any order |
|
||||
| `smtp \| key` | either |
|
||||
| `'prod` | exactly `prod`, not fuzzy |
|
||||
| `^kv-` / `-dev$` | names starting / ending with it |
|
||||
| `!test` | without `test` |
|
||||
|
||||
Lowercase ignores case; typing an uppercase letter makes that term
|
||||
match case exactly.
|
||||
|
||||
## Secrets
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `space` | reveal / hide the selected value (public material for keys and certificates) |
|
||||
| `y` | copy the value without revealing it; cleared from the clipboard after 30s |
|
||||
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
||||
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
|
||||
| `o` | open the selected item in the browser (over SSH: copy the link) |
|
||||
| `s` / `S` | copy a [link](links.md) to the selected item / share menu |
|
||||
| `ctrl+o` | open a link or reference (or just paste one) |
|
||||
| `x` | [custom commands](custom-commands.md) |
|
||||
| `L` | sign in again (shown when a login has expired) |
|
||||
| `U` | a newer lazysecrets: its changelog, and update in place (restart to use it); or look for one |
|
||||
|
||||
A revealed value hides again after `revealTimeout` (60s) and when the
|
||||
terminal loses focus (`hideOnBlur`); see [config](config.md).
|
||||
|
||||
## Stars and notes
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list |
|
||||
| `n` | write a short note on it, shown dimmed on its row and in the detail pane |
|
||||
|
||||
A star belongs to the account you starred the item under; a note shows
|
||||
under every account. Both are kept in the [state file](config.md#state).
|
||||
|
||||
## Go to
|
||||
|
||||
`ctrl+s` opens Go to on its Search tab, `F` on Favourites (everything you
|
||||
starred) and `R` on Recent (what you used last). `enter` goes to the
|
||||
selected item.
|
||||
|
||||
Search has the scope on the left (All; all of Azure, Databricks or repos;
|
||||
or one source; with `○ ◐ ●` for how much of it is loaded) and what's in
|
||||
it on the right. All repos searches every repo without asking Azure or
|
||||
Databricks for anything. It opens on the
|
||||
source you're in. Typing a search loads what it covers: everything on
|
||||
All, else the source picked; results appear as lists arrive. That's once
|
||||
each time Go to opens, and only what isn't loaded yet; `ctrl+a` loads
|
||||
again, lists that failed included.
|
||||
|
||||
Like a pane, Go to is either browsing or typing a filter:
|
||||
|
||||
| Browsing | Typing | Action |
|
||||
|---|---|---|
|
||||
| `j`/`k` | `ctrl+j`/`ctrl+k` | move |
|
||||
| `h`/`l`, `tab` | `ctrl+h`/`ctrl+l`, `tab` | scope list / results |
|
||||
| `[` / `]` | | previous / next tab |
|
||||
| `f` | `ctrl+f` | star |
|
||||
| `r` | `ctrl+r` | load the selected item, or the scope when nothing is selected |
|
||||
| `a` | `ctrl+a` | load everything in the scope (again) |
|
||||
| `/` | `esc` | start typing / clear it and go back to browsing |
|
||||
| `esc`, `q` | | close |
|
||||
|
||||
Favourites and Recent come from the state file, so they work before
|
||||
anything loads.
|
||||
|
||||
## Layout
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `ctrl+←`/`ctrl+→` or `<`/`>` | move the divider between the left panes and the detail pane |
|
||||
| `ctrl+↓`/`ctrl+↑` or `}`/`{` | make the focused left pane taller / shorter |
|
||||
| `+` / `_` | zoom the focused pane: normal → half → full screen, and back |
|
||||
| `=` | reset the layout (divider, heights, zoom) |
|
||||
|
||||
The divider and pane heights are remembered (zoom isn't). macOS uses
|
||||
ctrl+arrows for Mission Control and switching desktops by default, hence
|
||||
the `<` `>` `{` `}` alternatives.
|
||||
|
||||
## Rebinding keys
|
||||
|
||||
Every action can be rebound in the [config](config.md), per context:
|
||||
|
||||
```yaml
|
||||
keybinding:
|
||||
lists: # the panes, when not typing
|
||||
star: [x] # replaces f
|
||||
versions: [] # unbound
|
||||
filter: # typing a filter in a pane
|
||||
star: [ctrl+x]
|
||||
popup: # popups with a text field: help, notes, open link, Go to while typing
|
||||
down: [ctrl+j, down]
|
||||
menu: # popups without a text field
|
||||
search: # Go to's own actions while typing (on top of popup)
|
||||
goto: # Go to while browsing
|
||||
```
|
||||
|
||||
An action you set gets exactly the keys you list, replacing its defaults;
|
||||
`[]` unbinds it. Actions you don't set keep their defaults, also across
|
||||
layered config files. `lazysecrets keybindings` prints every action with
|
||||
its keys (your config applied), ready to copy and edit. A key bound to two
|
||||
actions that apply together is an error at startup, naming both. The help
|
||||
bar, `?` and every hint show the keys you bound. Fixed: the letters inside
|
||||
menus (they're part of each entry), and `ctrl+c`, which always quits.
|
||||
|
||||
Without the kitty keyboard protocol (or tmux `extended-keys`), some
|
||||
combinations never reach a terminal app, such as `ctrl+shift+x`, `ctrl+1`
|
||||
or `ctrl+enter`.
|
||||
+118
@@ -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 <link>` (or `lazysecrets open <link>`) 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 <link>` 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.
|
||||
@@ -0,0 +1,75 @@
|
||||
# SOPS and .env files in git repos
|
||||
|
||||
lazysecrets shows git repositories under **Repos**: their
|
||||
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
|
||||
`.env`, INI) and plain env files as vaults, and the keys in them
|
||||
(`db.password`, `list[0]`) as secrets.
|
||||
|
||||
## Which repos
|
||||
|
||||
**Repos** lists every repo with secret files under the `roots` (`~/git`
|
||||
by default; one or two levels down, `repo` or `org/repo`), any listed in
|
||||
`paths`, and any you starred something in (the repo, a file or a key),
|
||||
wherever it is. They're checked in the background.
|
||||
|
||||
Started inside a repo, that repo gets a row of its own at the top,
|
||||
`Repo · <name>`, 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 <path>` starts somewhere
|
||||
else, for example in one project of a monorepo.
|
||||
|
||||
Repos load themselves as they're found, files and keys, since they're
|
||||
local and cheap: [Go to](keys.md#go-to) searches them without loading
|
||||
anything by hand.
|
||||
|
||||
## Which files
|
||||
|
||||
**Env files**: `.env`, `.env.local`, `prod.env` and the like, also when
|
||||
git-ignored (as they usually are), in the folders git tracks files in;
|
||||
not `.env.example`, `.sample`, `.template` and the like. They're read
|
||||
directly; a file with SOPS metadata is always treated as SOPS, whatever
|
||||
its name.
|
||||
|
||||
Folders of dependencies and caches are never looked in, tracked or not:
|
||||
`node_modules`, `vendor`, `.venv`, `venv`, `__pycache__`, `.terraform`,
|
||||
`.gradle`, `.yarn`, `.next` and the like.
|
||||
|
||||
**SOPS files**: those tracked by git, then the first of
|
||||
|
||||
1. `include`/`exclude` in the repo's own `.lazysecrets.yaml` (committed,
|
||||
so everyone sees the same files),
|
||||
2. `include`/`exclude` in the config,
|
||||
3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go,
|
||||
4. the whole repo.
|
||||
|
||||
`include`/`exclude` apply to env files too.
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
repos:
|
||||
roots: [~/git] # folders of repos (the default; [] turns it off)
|
||||
paths: [~/work/infra] # single repos elsewhere
|
||||
include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth
|
||||
exclude: ["**/testdata/**"]
|
||||
```
|
||||
|
||||
## Decrypting
|
||||
|
||||
Listing never decrypts: SOPS encrypts values only, so keys and metadata
|
||||
(encryption, last modified) are read straight from the files. Revealing,
|
||||
copying or `lazysecrets get` decrypts one value with your `sops` binary, so
|
||||
whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP
|
||||
KMS, PGP). When sops can't decrypt, its whole error message is shown.
|
||||
|
||||
## Links
|
||||
|
||||
Links name the repo by its git remote, so they open in a colleague's clone
|
||||
wherever it is:
|
||||
|
||||
```
|
||||
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
||||
```
|
||||
|
||||
See [links](links.md).
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/sh
|
||||
# Installs lazysecrets from its releases: the archive for this OS and CPU,
|
||||
# checked against the release's checksums.txt, into ~/.local/bin.
|
||||
#
|
||||
# curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
|
||||
# curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh -s -- v0.2.0
|
||||
#
|
||||
# LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64
|
||||
# and x86_64.
|
||||
set -eu
|
||||
|
||||
repo=https://git.kebcloud.no/keb/lazysecrets
|
||||
api=https://git.kebcloud.no/api/v1/repos/keb/lazysecrets
|
||||
dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
fail() {
|
||||
echo "lazysecrets install: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
fetch() { curl -fsSL "$1"; }
|
||||
download() { curl -fsSL -o "$2" "$1"; }
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
fetch() { wget -qO- "$1"; }
|
||||
download() { wget -qO "$2" "$1"; }
|
||||
else
|
||||
fail "needs curl or wget"
|
||||
fi
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin) os=darwin ;;
|
||||
Linux) os=linux ;;
|
||||
*) fail "no release for $(uname -s); build it with: go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest" ;;
|
||||
esac
|
||||
case "$(uname -m)" in
|
||||
x86_64 | amd64) arch=amd64 ;;
|
||||
arm64 | aarch64) arch=arm64 ;;
|
||||
*) fail "no release for $(uname -m)" ;;
|
||||
esac
|
||||
|
||||
tag="${1:-}"
|
||||
if [ -z "$tag" ]; then
|
||||
tag=$(fetch "$api/releases/latest" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p') ||
|
||||
fail "couldn't look up the latest release"
|
||||
[ -n "$tag" ] || fail "couldn't look up the latest release"
|
||||
fi
|
||||
case "$tag" in
|
||||
v*) ;;
|
||||
*) tag="v$tag" ;;
|
||||
esac
|
||||
version="${tag#v}"
|
||||
|
||||
name="lazysecrets_${version}_${os}_${arch}"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
echo "Downloading lazysecrets $tag for $os/$arch"
|
||||
download "$repo/releases/download/$tag/$name.tar.gz" "$tmp/$name.tar.gz" ||
|
||||
fail "couldn't download $tag for $os/$arch: see $repo/releases"
|
||||
download "$repo/releases/download/$tag/checksums.txt" "$tmp/checksums.txt" ||
|
||||
fail "couldn't download $tag's checksums.txt"
|
||||
|
||||
want=$(awk -v f="$name.tar.gz" '$2 == f { print $1 }' "$tmp/checksums.txt")
|
||||
[ -n "$want" ] || fail "$name.tar.gz isn't in checksums.txt"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
got=$(sha256sum "$tmp/$name.tar.gz" | awk '{ print $1 }')
|
||||
else
|
||||
got=$(shasum -a 256 "$tmp/$name.tar.gz" | awk '{ print $1 }')
|
||||
fi
|
||||
[ "$got" = "$want" ] || fail "checksum mismatch for $name.tar.gz: the download is damaged or not the release"
|
||||
|
||||
tar -xzf "$tmp/$name.tar.gz" -C "$tmp"
|
||||
mkdir -p "$dir"
|
||||
# Replaced, not overwritten: a running lazysecrets keeps its binary.
|
||||
cp "$tmp/$name/lazysecrets" "$dir/.lazysecrets.new"
|
||||
chmod 755 "$dir/.lazysecrets.new"
|
||||
mv -f "$dir/.lazysecrets.new" "$dir/lazysecrets"
|
||||
echo "Installed $("$dir/lazysecrets" --version) in $dir"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$dir:"*) ;;
|
||||
*) echo "$dir isn't on your PATH: add it to your shell's profile, e.g. export PATH=\"$dir:\$PATH\"" ;;
|
||||
esac
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Package config loads the optional YAML config file.
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Icons *bool `yaml:"icons"`
|
||||
ClipboardClear *time.Duration `yaml:"clipboardClear"`
|
||||
// RevealTimeout hides a revealed value after this long; 0s never.
|
||||
// Defaults to 60s.
|
||||
RevealTimeout *time.Duration `yaml:"revealTimeout"`
|
||||
// ExpiryWarningDays marks secrets, keys and certificates expiring
|
||||
// within this many days; 0 marks only expired ones. Defaults to 30.
|
||||
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
|
||||
// HideOnBlur hides a revealed value when the terminal loses focus.
|
||||
// Defaults to true.
|
||||
HideOnBlur *bool `yaml:"hideOnBlur"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
// LoadDelay is how long the cursor must rest on a row before its
|
||||
// contents load; 0s loads at once. Defaults to 150ms.
|
||||
LoadDelay *time.Duration `yaml:"loadDelay"`
|
||||
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
|
||||
SourcesView string `yaml:"sourcesView"`
|
||||
// History counts uses of items for the Recent list; defaults to true.
|
||||
History *bool `yaml:"history"`
|
||||
// UpdateCheck looks for a newer release once a day, in the
|
||||
// background; defaults to true. U and lazysecrets update work either
|
||||
// way.
|
||||
UpdateCheck *bool `yaml:"updateCheck"`
|
||||
// CollapseGroups starts the Sources pane with its groups folded;
|
||||
// defaults to true.
|
||||
CollapseGroups *bool `yaml:"collapseGroups"`
|
||||
Providers Providers `yaml:"providers"`
|
||||
// LinkHandler sets how lazysecrets:// links open; see linkhandler.
|
||||
LinkHandler LinkHandler `yaml:"linkHandler"`
|
||||
// CustomCommands run shell commands on the selected item; see
|
||||
// package commands.
|
||||
CustomCommands []commands.Command `yaml:"customCommands"`
|
||||
// Keybinding sets keys per context and action, replacing the
|
||||
// defaults of each action given; see keys.Map.Apply.
|
||||
Keybinding map[string]map[string][]string `yaml:"keybinding"`
|
||||
// Theme maps roles to colours, like lazygit's gui.theme; see
|
||||
// styles.Roles.
|
||||
Theme map[string][]string `yaml:"theme"`
|
||||
}
|
||||
|
||||
type LinkHandler struct {
|
||||
// Terminal to open links in, overriding the one handler install
|
||||
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
|
||||
// gnome-terminal, konsole or foot.
|
||||
Terminal string `yaml:"terminal"`
|
||||
// Command opens any other terminal: its arguments, with {cmd} where
|
||||
// the lazysecrets command goes, e.g. [foot, "{cmd}"].
|
||||
Command []string `yaml:"command"`
|
||||
// Reuse hands links to a running lazysecrets, the one last used that
|
||||
// can be brought into view, before opening a new window. Defaults to
|
||||
// true.
|
||||
Reuse *bool `yaml:"reuse"`
|
||||
// Prompt offers to set up the link handler at startup when links
|
||||
// don't open lazysecrets. Defaults to true.
|
||||
Prompt *bool `yaml:"prompt"`
|
||||
}
|
||||
|
||||
type Providers struct {
|
||||
Azure Azure `yaml:"azure"`
|
||||
Databricks Databricks `yaml:"databricks"`
|
||||
Repos Repos `yaml:"repos"`
|
||||
}
|
||||
|
||||
// Repos are git repositories' secret files (SOPS, .env). Inside a repo,
|
||||
// that repo is the only one shown; elsewhere, the ones in Paths and under
|
||||
// Roots.
|
||||
type Repos struct {
|
||||
// Enabled defaults to true.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
Paths []string `yaml:"paths"`
|
||||
// Roots are folders of repos. Defaults to ~/git; an empty list
|
||||
// turns it off.
|
||||
Roots *[]string `yaml:"roots"`
|
||||
// Include and Exclude are globs relative to a repo's root (** for any
|
||||
// depth), for repos without their own .lazysecrets.yaml. Without them,
|
||||
// a repo's .sops.yaml creation_rules say where to look.
|
||||
Include []string `yaml:"include"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
}
|
||||
|
||||
type Azure struct {
|
||||
// Enabled defaults to true.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
// HideSubscriptions lists subscriptions to leave out, by name or ID.
|
||||
HideSubscriptions []string `yaml:"hideSubscriptions"`
|
||||
// Vaults are shown under "Pinned vaults", for vaults that can't be
|
||||
// discovered (e.g. data-plane access only).
|
||||
Vaults []string `yaml:"vaults"`
|
||||
}
|
||||
|
||||
type Databricks struct {
|
||||
// Enabled defaults to true.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
HideProfiles []string `yaml:"hideProfiles"`
|
||||
}
|
||||
|
||||
// Dir is $XDG_CONFIG_HOME/lazysecrets, defaulting to ~/.config/lazysecrets
|
||||
// on every platform, like most terminal tools.
|
||||
func Dir() (string, error) {
|
||||
return xdgDir("XDG_CONFIG_HOME", ".config")
|
||||
}
|
||||
|
||||
// StateDir is $XDG_STATE_HOME/lazysecrets, defaulting to
|
||||
// ~/.local/state/lazysecrets. It holds data the app writes, like favourites.
|
||||
func StateDir() (string, error) {
|
||||
return xdgDir("XDG_STATE_HOME", filepath.Join(".local", "state"))
|
||||
}
|
||||
|
||||
func xdgDir(env, fallback string) (string, error) {
|
||||
base := os.Getenv(env)
|
||||
if base == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base = filepath.Join(home, fallback)
|
||||
}
|
||||
return filepath.Join(base, "lazysecrets"), nil
|
||||
}
|
||||
|
||||
// Load reads path. A missing file is an empty config. Unknown keys are an
|
||||
// error, so typos don't silently do nothing.
|
||||
func Load(path string) (Config, error) {
|
||||
return LoadAll([]string{path})
|
||||
}
|
||||
|
||||
// LoadAll reads several config files in order, each on top of the ones
|
||||
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the
|
||||
// settings it sets, and theme roles merge one by one. That lets an editor
|
||||
// plugin add a generated theme file to the user's own config. Missing
|
||||
// files are skipped.
|
||||
func LoadAll(paths []string) (Config, error) {
|
||||
var c Config
|
||||
keybinding := map[string]map[string][]string{}
|
||||
for _, path := range paths {
|
||||
// Keybindings layer per action: a later file setting one action
|
||||
// in a context keeps the others set before it.
|
||||
c.Keybinding = nil
|
||||
if err := decodeInto(&c, path); err != nil {
|
||||
return c, err
|
||||
}
|
||||
for ctx, actions := range c.Keybinding {
|
||||
if keybinding[ctx] == nil {
|
||||
keybinding[ctx] = map[string][]string{}
|
||||
}
|
||||
for name, keys := range actions {
|
||||
keybinding[ctx][name] = keys
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Keybinding = keybinding
|
||||
return c, c.validate(strings.Join(paths, ", "))
|
||||
}
|
||||
|
||||
func decodeInto(c *Config, path string) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dec := yaml.NewDecoder(bytes.NewReader(b))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(c); err != nil && !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) validate(path string) error {
|
||||
if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" {
|
||||
return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v)
|
||||
}
|
||||
for _, cmd := range c.CustomCommands {
|
||||
if err := cmd.Validate(); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
}
|
||||
if d := c.ExpiryWarningDays; d != nil && *d < 0 {
|
||||
return fmt.Errorf("%s: expiryWarningDays: %d is negative", path, *d)
|
||||
}
|
||||
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
|
||||
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
|
||||
}
|
||||
for i, v := range c.Providers.Azure.Vaults {
|
||||
if !strings.HasPrefix(v, "https://") {
|
||||
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// On reports whether an optional enabled flag is unset or true.
|
||||
func On(enabled *bool) bool { return enabled == nil || *enabled }
|
||||
|
||||
// Hidden reports whether name or id appears in list, ignoring case.
|
||||
func Hidden(list []string, name, id string) bool {
|
||||
for _, h := range list {
|
||||
if strings.EqualFold(h, name) || strings.EqualFold(h, id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func write(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
c, err := Load(write(t, `
|
||||
icons: false
|
||||
sourcesView: flat
|
||||
clipboardClear: 45s
|
||||
timeout: 1m
|
||||
providers:
|
||||
azure:
|
||||
hideSubscriptions: [platform-mgmt]
|
||||
vaults:
|
||||
- https://kv-foo.vault.azure.net/
|
||||
databricks:
|
||||
enabled: false
|
||||
hideProfiles: [test]
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Icons == nil || *c.Icons || c.ClipboardClear == nil || *c.ClipboardClear != 45*time.Second ||
|
||||
c.Timeout != time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
|
||||
t.Errorf("config = %+v", c)
|
||||
}
|
||||
if c.SourcesView != "flat" {
|
||||
t.Errorf("sourcesView = %q", c.SourcesView)
|
||||
}
|
||||
if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) {
|
||||
t.Error("enabled flags wrong")
|
||||
}
|
||||
az := c.Providers.Azure
|
||||
if !Hidden(az.HideSubscriptions, "PLATFORM-MGMT", "id") || Hidden(az.HideSubscriptions, "other", "id") {
|
||||
t.Error("Hidden matching wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingAndEmpty(t *testing.T) {
|
||||
for name, path := range map[string]string{
|
||||
"missing": filepath.Join(t.TempDir(), "nope.yaml"),
|
||||
"empty": write(t, ""),
|
||||
} {
|
||||
c, err := Load(path)
|
||||
if err != nil || c.Icons != nil || c.ClipboardClear != nil {
|
||||
t.Errorf("%s: %+v, %v", name, c, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMistakes(t *testing.T) {
|
||||
for name, content := range map[string]string{
|
||||
"typo": "icon: true\n",
|
||||
"bad vault": "providers:\n azure:\n vaults: [kv-foo]\n",
|
||||
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
|
||||
"duration": "timeout: soon\n",
|
||||
"view": "sourcesView: tree\n",
|
||||
} {
|
||||
if _, err := Load(write(t, content)); err == nil {
|
||||
t.Errorf("%s: no error", name)
|
||||
} else if !strings.Contains(err.Error(), "config.yaml") {
|
||||
t.Errorf("%s: error doesn't name the file: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllLayers(t *testing.T) {
|
||||
user := write(t, `
|
||||
icons: false
|
||||
theme:
|
||||
azureColor: [blue]
|
||||
errorColor: [red, bold]
|
||||
providers:
|
||||
azure:
|
||||
hideSubscriptions: [a]
|
||||
`)
|
||||
theme := write(t, `
|
||||
theme:
|
||||
azureColor: ["#82aaff"]
|
||||
activeBorderColor: ["#ff966c", bold]
|
||||
`)
|
||||
c, err := LoadAll([]string{user, filepath.Join(t.TempDir(), "missing.yaml"), theme})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Icons == nil || *c.Icons || len(c.Providers.Azure.HideSubscriptions) != 1 {
|
||||
t.Errorf("user settings lost: %+v", c)
|
||||
}
|
||||
want := map[string][]string{
|
||||
"azureColor": {"#82aaff"}, // overridden by the later file
|
||||
"errorColor": {"red", "bold"}, // kept from the user's file
|
||||
"activeBorderColor": {"#ff966c", "bold"},
|
||||
}
|
||||
for role, v := range want {
|
||||
if strings.Join(c.Theme[role], ",") != strings.Join(v, ",") {
|
||||
t.Errorf("theme %s = %v, want %v", role, c.Theme[role], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDelay(t *testing.T) {
|
||||
c, err := Load(write(t, "loadDelay: 300ms\n"))
|
||||
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
|
||||
t.Errorf("loadDelay = %v, %v", c.LoadDelay, err)
|
||||
}
|
||||
if _, err := Load(write(t, "loadDelay: 1m\n")); err == nil {
|
||||
t.Error("a minute's delay accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeybindingLayers(t *testing.T) {
|
||||
a := write(t, "keybinding:\n lists:\n star: [x]\n note: [N]\n")
|
||||
b := write(t, "keybinding:\n lists:\n note: [m]\n filter:\n star: [ctrl+x]\n")
|
||||
c, err := LoadAll([]string{a, b})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kb := c.Keybinding
|
||||
if got := kb["lists"]["star"]; len(got) != 1 || got[0] != "x" {
|
||||
t.Errorf("lists.star from the first file lost: %v", kb["lists"])
|
||||
}
|
||||
if got := kb["lists"]["note"]; len(got) != 1 || got[0] != "m" {
|
||||
t.Errorf("lists.note = %v, want the later file's", got)
|
||||
}
|
||||
if got := kb["filter"]["star"]; len(got) != 1 || got[0] != "ctrl+x" {
|
||||
t.Errorf("filter.star = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 <link>" 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// account is one az CLI identity in one tenant.
|
||||
type account struct {
|
||||
user, tenant string
|
||||
tenantName string
|
||||
anchor string // a subscription az maps to this account, to pin it
|
||||
anchorEnabled bool
|
||||
isDefault bool
|
||||
subscriptions int // how many subscriptions az maps to it
|
||||
}
|
||||
|
||||
// azProfilePath is az's account list. It holds no tokens.
|
||||
func azProfilePath() (string, error) {
|
||||
dir := os.Getenv("AZURE_CONFIG_DIR")
|
||||
if dir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir = filepath.Join(home, ".azure")
|
||||
}
|
||||
return filepath.Join(dir, "azureProfile.json"), nil
|
||||
}
|
||||
|
||||
// readAccounts lists the accounts signed in to az, default first.
|
||||
func readAccounts(path string) ([]account, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf")) // az writes a BOM
|
||||
var profile struct {
|
||||
Subscriptions []struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantName string `json:"tenantDisplayName"`
|
||||
User struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
} `json:"subscriptions"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &profile); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
|
||||
var out []account
|
||||
for _, s := range profile.Subscriptions {
|
||||
if s.User.Name == "" || s.TenantID == "" {
|
||||
continue
|
||||
}
|
||||
i := slices.IndexFunc(out, func(a account) bool { return a.user == s.User.Name && a.tenant == s.TenantID })
|
||||
if i < 0 {
|
||||
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName})
|
||||
i = len(out) - 1
|
||||
}
|
||||
a := &out[i]
|
||||
a.subscriptions++
|
||||
// Anchor on az's default subscription if it is this account's,
|
||||
// otherwise on an enabled one.
|
||||
enabled := s.State == "Enabled"
|
||||
switch {
|
||||
case s.IsDefault:
|
||||
a.anchor, a.anchorEnabled, a.isDefault = s.ID, enabled, true
|
||||
case a.isDefault:
|
||||
case a.anchor == "" || enabled && !a.anchorEnabled:
|
||||
a.anchor, a.anchorEnabled = s.ID, enabled
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(out, func(a, b account) int {
|
||||
if a.isDefault != b.isDefault {
|
||||
if a.isDefault {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return cmp.Compare(a.user, b.user)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// accountLabels names accounts as briefly as stays unambiguous: the user
|
||||
// name before the @, the full user if another user shares that, plus the
|
||||
// tenant if one user is signed in to several.
|
||||
func accountLabels(accounts []account) []string {
|
||||
short := func(u string) string { s, _, _ := strings.Cut(u, "@"); return s }
|
||||
labels := make([]string, len(accounts))
|
||||
for i, a := range accounts {
|
||||
l, tenants := short(a.user), 0
|
||||
for _, b := range accounts {
|
||||
if b.user != a.user && short(b.user) == short(a.user) {
|
||||
l = a.user
|
||||
}
|
||||
if b.user == a.user {
|
||||
tenants++
|
||||
}
|
||||
}
|
||||
if tenants > 1 {
|
||||
l += " · " + cmp.Or(a.tenantName, a.tenant)
|
||||
}
|
||||
labels[i] = l
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// NewAccounts returns one provider per account signed in to az, the default
|
||||
// first, or a single provider if AZURE_* configures a service principal or
|
||||
// az has no accounts yet. Pinned vaults go to the first.
|
||||
func NewAccounts(s Settings) ([]*Provider, error) {
|
||||
if os.Getenv("AZURE_CLIENT_ID") != "" && os.Getenv("AZURE_TENANT_ID") != "" {
|
||||
cred, err := azidentity.NewEnvironmentCredential(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := newWithOptions(authCredential{cred: cred}, options{Settings: s})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*Provider{p}, nil
|
||||
}
|
||||
|
||||
path, err := azProfilePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accounts, err := readAccounts(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
// Not signed in yet: the first call fails with an AuthError and
|
||||
// the UI offers az login.
|
||||
cred := authCredential{cred: newCLICredential("", ""), login: []string{"az", "login"}}
|
||||
p, err := newWithOptions(cred, options{Settings: s})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*Provider{p}, nil
|
||||
}
|
||||
|
||||
labels := accountLabels(accounts)
|
||||
var out []*Provider
|
||||
for i, a := range accounts {
|
||||
settings := s
|
||||
if i > 0 {
|
||||
settings.Vaults = nil
|
||||
}
|
||||
label := ""
|
||||
if len(accounts) > 1 {
|
||||
label = string(provider.KindAzure) + " · " + labels[i]
|
||||
}
|
||||
cred := authCredential{
|
||||
cred: newCLICredential(a.anchor, a.tenant),
|
||||
login: []string{"az", "login", "--tenant", a.tenant},
|
||||
account: label, // so "sign-in required" says which account
|
||||
}
|
||||
p, err := newWithOptions(cred, options{Settings: settings})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if label != "" {
|
||||
p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant
|
||||
p.label = label
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
)
|
||||
|
||||
const profileJSON = "\xef\xbb\xbf" + `{"installationId": "x", "subscriptions": [
|
||||
{"id": "s-admin-1", "name": "platform", "state": "Enabled", "isDefault": false, "tenantId": "t1", "tenantDisplayName": "Contoso",
|
||||
"user": {"name": "admin.alice@contoso.onmicrosoft.com", "type": "user"}},
|
||||
{"id": "s-admin-2", "name": "old", "state": "Disabled", "isDefault": false, "tenantId": "t1",
|
||||
"user": {"name": "admin.alice@contoso.onmicrosoft.com", "type": "user"}},
|
||||
{"id": "s-alice-1", "name": "app-dev", "state": "Enabled", "isDefault": false, "tenantId": "t1",
|
||||
"user": {"name": "alice@contoso.com", "type": "user"}},
|
||||
{"id": "s-alice-2", "name": "app-prod", "state": "Enabled", "isDefault": true, "tenantId": "t1",
|
||||
"user": {"name": "alice@contoso.com", "type": "user"}}
|
||||
]}`
|
||||
|
||||
func TestReadAccounts(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "azureProfile.json")
|
||||
if err := os.WriteFile(path, []byte(profileJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readAccounts(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("accounts = %+v", got)
|
||||
}
|
||||
// Default account first, anchored on the default subscription.
|
||||
if got[0].user != "alice@contoso.com" || got[0].anchor != "s-alice-2" || !got[0].isDefault {
|
||||
t.Errorf("default account = %+v", got[0])
|
||||
}
|
||||
// Anchored on an enabled subscription, not the disabled one.
|
||||
if got[1].user != "admin.alice@contoso.onmicrosoft.com" || got[1].anchor != "s-admin-1" || got[1].subscriptions != 2 {
|
||||
t.Errorf("second account = %+v", got[1])
|
||||
}
|
||||
|
||||
if accts, err := readAccounts(filepath.Join(t.TempDir(), "missing.json")); err != nil || accts != nil {
|
||||
t.Errorf("missing profile: %v, %v", accts, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountLabels(t *testing.T) {
|
||||
got := accountLabels([]account{
|
||||
{user: "alice@contoso.com", tenant: "t1"},
|
||||
{user: "admin@a.example", tenant: "t1"},
|
||||
{user: "admin@b.example", tenant: "t1"},
|
||||
{user: "guest@x.example", tenant: "t1", tenantName: "Home"},
|
||||
{user: "guest@x.example", tenant: "t2", tenantName: "Partner"},
|
||||
})
|
||||
want := []string{"alice", "admin@a.example", "admin@b.example", "guest · Home", "guest · Partner"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("labels = %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLICredentialPinsAccount(t *testing.T) {
|
||||
var calls [][]string
|
||||
c := newCLICredential("s-alice-2", "t1")
|
||||
c.run = func(_ context.Context, args ...string) ([]byte, error) {
|
||||
calls = append(calls, args)
|
||||
exp := time.Now().Add(time.Hour).Unix()
|
||||
return []byte(fmt.Sprintf(`{"accessToken": "tok", "expires_on": %d}`, exp)), nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
vault := policy.TokenRequestOptions{Scopes: []string{"https://vault.azure.net/.default"}}
|
||||
|
||||
// Same tenant (Key Vault's challenge names it): pin with --subscription only.
|
||||
vault.TenantID = "t1"
|
||||
if tok, err := c.GetToken(ctx, vault); err != nil || tok.Token != "tok" {
|
||||
t.Fatalf("token: %v, %v", tok, err)
|
||||
}
|
||||
if got := strings.Join(calls[0], " "); !strings.Contains(got, "--subscription s-alice-2") || strings.Contains(got, "--tenant") {
|
||||
t.Errorf("same tenant args: %s", got)
|
||||
}
|
||||
|
||||
// Cached: no second az call.
|
||||
if _, err := c.GetToken(ctx, vault); err != nil || len(calls) != 1 {
|
||||
t.Errorf("token not cached: %d calls", len(calls))
|
||||
}
|
||||
|
||||
// Another tenant: --tenant only, since az refuses both.
|
||||
vault.TenantID = "t2"
|
||||
if _, err := c.GetToken(ctx, vault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(calls[1], " "); !strings.Contains(got, "--tenant t2") || strings.Contains(got, "--subscription") {
|
||||
t.Errorf("other tenant args: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLICredentialRenewsAndReportsErrors(t *testing.T) {
|
||||
n := 0
|
||||
c := newCLICredential("", "")
|
||||
c.run = func(context.Context, ...string) ([]byte, error) {
|
||||
n++
|
||||
if n == 2 {
|
||||
return nil, errors.New("AADSTS700082: The refresh token has expired. Please run 'az login'")
|
||||
}
|
||||
// Expires inside the refresh margin, so the next call renews.
|
||||
return []byte(`{"accessToken": "tok", "expiresOn": "` + time.Now().Add(time.Minute).Format("2006-01-02 15:04:05.000000") + `"}`), nil
|
||||
}
|
||||
opts := policy.TokenRequestOptions{Scopes: []string{"https://management.azure.com/.default"}}
|
||||
if _, err := c.GetToken(context.Background(), opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.GetToken(context.Background(), opts); err == nil || !strings.Contains(err.Error(), "AADSTS700082") {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
// Package azure is the Azure Key Vault provider. Sources are subscriptions,
|
||||
// containers are vaults (found via Resource Graph), and objects are secrets,
|
||||
// keys and certificates.
|
||||
package azure
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
|
||||
|
||||
// Provider serves one Azure identity: an az CLI account or a service
|
||||
// principal. Creating it doesn't contact Azure; authentication problems
|
||||
// surface as *provider.AuthError from the first call.
|
||||
type Provider struct {
|
||||
id, label string // "" for the single-account defaults
|
||||
|
||||
cred azcore.TokenCredential
|
||||
opts options
|
||||
subs *armsubscriptions.Client
|
||||
graph *armresourcegraph.Client
|
||||
|
||||
mu sync.Mutex
|
||||
vaults map[string]*vaultClients // by vault URL
|
||||
tenants map[string]string // tenant ID by subscription ID, for portal links
|
||||
}
|
||||
|
||||
type vaultClients struct {
|
||||
secrets *azsecrets.Client
|
||||
keys *azkeys.Client
|
||||
certs *azcertificates.Client
|
||||
}
|
||||
|
||||
var _ provider.Provider = (*Provider)(nil)
|
||||
|
||||
// Settings come from the config file.
|
||||
type Settings struct {
|
||||
// HideSubscriptions lists subscriptions to leave out, by name or ID.
|
||||
HideSubscriptions []string
|
||||
// Vaults are listed under a "Pinned vaults" source.
|
||||
Vaults []string
|
||||
}
|
||||
|
||||
// pinnedID identifies the source holding vaults from the config file.
|
||||
const pinnedID = "pinned-vaults"
|
||||
|
||||
// options lets tests point the SDK clients at a local server.
|
||||
type options struct {
|
||||
Settings
|
||||
|
||||
arm *arm.ClientOptions
|
||||
client azcore.ClientOptions
|
||||
skipChallengeValidation bool
|
||||
}
|
||||
|
||||
func newWithOptions(cred azcore.TokenCredential, opts options) (*Provider, error) {
|
||||
subs, err := armsubscriptions.NewClient(cred, opts.arm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
graph, err := armresourcegraph.NewClient(cred, opts.arm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Provider{
|
||||
cred: cred, opts: opts, subs: subs, graph: graph,
|
||||
vaults: map[string]*vaultClients{}, tenants: map[string]string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Kind() provider.Kind { return provider.KindAzure }
|
||||
|
||||
func (p *Provider) ID() string { return cmp.Or(p.id, string(provider.KindAzure)) }
|
||||
|
||||
func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindAzure)) }
|
||||
|
||||
// Sources lists the subscriptions the signed-in identity can see, skipping
|
||||
// disabled and deleted ones.
|
||||
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
var out []provider.Source
|
||||
pager := p.subs.NewListPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, s := range page.Value {
|
||||
switch deref(s.State) {
|
||||
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
|
||||
continue
|
||||
}
|
||||
id, tenant := deref(s.SubscriptionID), deref(s.TenantID)
|
||||
if config.Hidden(p.opts.HideSubscriptions, deref(s.DisplayName), id) {
|
||||
continue
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.tenants[id] = tenant
|
||||
p.mu.Unlock()
|
||||
out = append(out, provider.Source{
|
||||
Kind: provider.KindAzure,
|
||||
ID: id,
|
||||
Name: deref(s.DisplayName),
|
||||
Detail: "subscription " + id,
|
||||
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
|
||||
})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||
if len(p.opts.Vaults) > 0 {
|
||||
out = append(out, provider.Source{
|
||||
Kind: provider.KindAzure, ID: pinnedID, Name: "Pinned vaults", Detail: "from the config file",
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pinnedVaults lists the vaults from the config file. Without a resource ID
|
||||
// they have no portal link.
|
||||
func (p *Provider) pinnedVaults(src provider.Source) []provider.Container {
|
||||
out := make([]provider.Container, 0, len(p.opts.Vaults))
|
||||
for _, u := range p.opts.Vaults {
|
||||
if !strings.HasSuffix(u, "/") {
|
||||
u += "/"
|
||||
}
|
||||
name, _, _ := strings.Cut(strings.TrimPrefix(u, "https://"), ".")
|
||||
out = append(out, provider.Container{Source: src, ID: u, Name: name, Detail: u, Types: objectTypes})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Containers finds the subscription's vaults with Resource Graph, falling
|
||||
// back to the Key Vault management API if the query fails.
|
||||
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
if src.ID == pinnedID {
|
||||
return p.pinnedVaults(src), nil
|
||||
}
|
||||
vaults, err := p.graphVaults(ctx, src)
|
||||
if err != nil {
|
||||
var auth *provider.AuthError
|
||||
if errors.As(err, &auth) || ctx.Err() != nil {
|
||||
return nil, err
|
||||
}
|
||||
vaults, err = p.armVaults(ctx, src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
tenant := p.tenant(src.ID)
|
||||
for i := range vaults {
|
||||
vaults[i].URL = PortalResourceURL(tenant, vaults[i].ID, "overview")
|
||||
}
|
||||
slices.SortFunc(vaults, func(a, b provider.Container) int {
|
||||
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
})
|
||||
return vaults, nil
|
||||
}
|
||||
|
||||
func (p *Provider) tenant(subscriptionID string) string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.tenants[subscriptionID]
|
||||
}
|
||||
|
||||
const vaultQuery = `resources
|
||||
| where type =~ 'microsoft.keyvault/vaults'
|
||||
| project id, name, uri = tostring(properties.vaultUri)`
|
||||
|
||||
func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
var out []provider.Container
|
||||
req := armresourcegraph.QueryRequest{
|
||||
Query: to.Ptr(vaultQuery),
|
||||
Subscriptions: []*string{to.Ptr(src.ID)},
|
||||
Options: &armresourcegraph.QueryRequestOptions{ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray)},
|
||||
}
|
||||
for {
|
||||
resp, err := p.graph.Resources(ctx, req, nil)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
rows, ok := resp.Data.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("resource graph: unexpected result %T", resp.Data)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if c, ok := graphVault(src, r); ok {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
if resp.SkipToken == nil || *resp.SkipToken == "" {
|
||||
return out, nil
|
||||
}
|
||||
req.Options.SkipToken = resp.SkipToken
|
||||
}
|
||||
}
|
||||
|
||||
func graphVault(src provider.Source, row any) (provider.Container, bool) {
|
||||
m, ok := row.(map[string]any)
|
||||
if !ok {
|
||||
return provider.Container{}, false
|
||||
}
|
||||
id, _ := m["id"].(string)
|
||||
name, _ := m["name"].(string)
|
||||
uri, _ := m["uri"].(string)
|
||||
if id == "" || name == "" || uri == "" {
|
||||
return provider.Container{}, false
|
||||
}
|
||||
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes}, true
|
||||
}
|
||||
|
||||
func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
client, err := armkeyvault.NewVaultsClient(src.ID, p.cred, p.opts.arm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Container
|
||||
pager := client.NewListBySubscriptionPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, v := range page.Value {
|
||||
if v.Properties == nil || v.Properties.VaultURI == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, provider.Container{
|
||||
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
||||
Detail: *v.Properties.VaultURI, Types: objectTypes,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) clients(vaultURL string) (*vaultClients, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if c, ok := p.vaults[vaultURL]; ok {
|
||||
return c, nil
|
||||
}
|
||||
skip := p.opts.skipChallengeValidation
|
||||
s, err := azsecrets.NewClient(vaultURL, p.cred, &azsecrets.ClientOptions{
|
||||
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, err := azkeys.NewClient(vaultURL, p.cred, &azkeys.ClientOptions{
|
||||
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := azcertificates.NewClient(vaultURL, p.cred, &azcertificates.ClientOptions{
|
||||
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vc := &vaultClients{secrets: s, keys: k, certs: c}
|
||||
p.vaults[vaultURL] = vc
|
||||
return vc, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
vc, err := p.clients(c.Detail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Object
|
||||
switch t {
|
||||
case provider.TypeSecret:
|
||||
pager := vc.secrets.NewListSecretPropertiesPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, s := range page.Value {
|
||||
// Certificate-backing secrets appear under Certificates.
|
||||
if s.ID == nil || deref(s.Managed) {
|
||||
continue
|
||||
}
|
||||
out = append(out, secretObject(c, s))
|
||||
}
|
||||
}
|
||||
case provider.TypeKey:
|
||||
pager := vc.keys.NewListKeyPropertiesPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, k := range page.Value {
|
||||
if k.KID == nil || deref(k.Managed) {
|
||||
continue
|
||||
}
|
||||
out = append(out, keyObject(c, k))
|
||||
}
|
||||
}
|
||||
case provider.TypeCertificate:
|
||||
pager := vc.certs.NewListCertificatePropertiesPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, cp := range page.Value {
|
||||
if cp.ID == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, certObject(c, cp))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Describe fetches key and certificate details. Secrets are returned as-is:
|
||||
// the only way to get more is GetSecret, which returns the value.
|
||||
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
|
||||
vc, err := p.clients(o.Container.Detail)
|
||||
if err != nil {
|
||||
return o, err
|
||||
}
|
||||
switch o.Type {
|
||||
case provider.TypeKey:
|
||||
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return o, cleanError(err)
|
||||
}
|
||||
if resp.Key != nil {
|
||||
if resp.Key.KID != nil {
|
||||
o.Version = resp.Key.KID.Version()
|
||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID))
|
||||
}
|
||||
o.Attrs = keyAttrs(resp.Key)
|
||||
}
|
||||
case provider.TypeCertificate:
|
||||
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return o, cleanError(err)
|
||||
}
|
||||
if resp.ID != nil {
|
||||
o.Version = resp.ID.Version()
|
||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID))
|
||||
}
|
||||
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
|
||||
o.ContentType = deref(pol.SecretProperties.ContentType)
|
||||
}
|
||||
if cert, err := x509.ParseCertificate(resp.CER); err == nil {
|
||||
o.Attrs = certAttrs(cert, resp.X509Thumbprint)
|
||||
}
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
|
||||
vc, err := p.clients(o.Container.Detail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Object
|
||||
switch o.Type {
|
||||
case provider.TypeSecret:
|
||||
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, s := range page.Value {
|
||||
if s.ID != nil {
|
||||
out = append(out, secretObject(o.Container, s))
|
||||
}
|
||||
}
|
||||
}
|
||||
case provider.TypeKey:
|
||||
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, k := range page.Value {
|
||||
if k.KID != nil {
|
||||
out = append(out, keyObject(o.Container, k))
|
||||
}
|
||||
}
|
||||
}
|
||||
case provider.TypeCertificate:
|
||||
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, cp := range page.Value {
|
||||
if cp.ID != nil {
|
||||
out = append(out, certObject(o.Container, cp))
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Reference = versionedReference(out[i])
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// versionedReference pins an object's reference to its version, e.g.
|
||||
// @Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/name/v1).
|
||||
func versionedReference(o provider.Object) string {
|
||||
if o.Version == "" {
|
||||
return o.Reference
|
||||
}
|
||||
if ref, ok := strings.CutSuffix(o.Reference, ")"); ok && o.Type == provider.TypeSecret {
|
||||
return ref + "/" + o.Version + ")"
|
||||
}
|
||||
return o.Reference + "/" + o.Version
|
||||
}
|
||||
|
||||
// Value returns a secret's value, a key's public key or a certificate's
|
||||
// public certificate, as of o.Version (latest if empty).
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
vc, err := p.clients(o.Container.Detail)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
switch o.Type {
|
||||
case provider.TypeSecret:
|
||||
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, cleanError(err)
|
||||
}
|
||||
return provider.NewSecretValue([]byte(deref(resp.Value))), nil
|
||||
case provider.TypeKey:
|
||||
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, cleanError(err)
|
||||
}
|
||||
pemKey, err := publicKeyPEM(resp.Key)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
return provider.NewSecretValue([]byte(pemKey)), nil
|
||||
case provider.TypeCertificate:
|
||||
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, cleanError(err)
|
||||
}
|
||||
if len(resp.CER) == 0 {
|
||||
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
|
||||
}
|
||||
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
|
||||
}
|
||||
return provider.SecretValue{}, provider.ErrUnsupported
|
||||
}
|
||||
|
||||
var _ provider.Locator = (*Provider)(nil)
|
||||
|
||||
// validHost guards the Resource Graph query against odd input.
|
||||
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
|
||||
|
||||
// Locate finds the subscription of the vault with the given host: a pinned
|
||||
// vault, or one Resource Graph query across the account's subscriptions.
|
||||
// Vaults in hidden subscriptions aren't found.
|
||||
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
|
||||
host = strings.ToLower(host)
|
||||
if !validHost.MatchString(host) {
|
||||
return "", false, fmt.Errorf("not a vault host: %q", host)
|
||||
}
|
||||
url := "https://" + host + "/"
|
||||
for _, v := range p.opts.Vaults {
|
||||
if strings.EqualFold(strings.TrimSuffix(v, "/")+"/", url) {
|
||||
return pinnedID, true, nil
|
||||
}
|
||||
}
|
||||
resp, err := p.graph.Resources(ctx, armresourcegraph.QueryRequest{
|
||||
Query: to.Ptr(`resources
|
||||
| where type =~ 'microsoft.keyvault/vaults' and tostring(properties.vaultUri) =~ '` + url + `'
|
||||
| project subscriptionId`),
|
||||
Options: &armresourcegraph.QueryRequestOptions{ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray)},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", false, cleanError(err)
|
||||
}
|
||||
rows, _ := resp.Data.([]any)
|
||||
for _, r := range rows {
|
||||
m, _ := r.(map[string]any)
|
||||
sub, _ := m["subscriptionId"].(string)
|
||||
if sub != "" && !config.Hidden(p.opts.HideSubscriptions, "", sub) {
|
||||
return sub, true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
type staticCred struct{ err error }
|
||||
|
||||
func (c staticCred) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
|
||||
if c.err != nil {
|
||||
return azcore.AccessToken{}, c.err
|
||||
}
|
||||
return azcore.AccessToken{Token: "token", ExpiresOn: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
|
||||
// fakeAzure serves just enough of ARM and the Key Vault data plane over TLS,
|
||||
// including Key Vault's 401 auth challenge.
|
||||
func fakeAzure(t *testing.T, cred azcore.TokenCredential, handler http.HandlerFunc) (*Provider, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
w.Header().Set("WWW-Authenticate",
|
||||
`Bearer authorization="https://login.microsoftonline.com/tenant", resource="https://vault.azure.net"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
handler(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
client := azcore.ClientOptions{
|
||||
Transport: srv.Client(),
|
||||
Retry: policy.RetryOptions{MaxRetries: -1},
|
||||
Cloud: cloud.Configuration{
|
||||
ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/",
|
||||
Services: map[cloud.ServiceName]cloud.ServiceConfiguration{
|
||||
cloud.ResourceManager: {Endpoint: srv.URL, Audience: "https://management.azure.com"},
|
||||
},
|
||||
},
|
||||
}
|
||||
p, err := newWithOptions(cred, options{
|
||||
arm: &arm.ClientOptions{ClientOptions: client},
|
||||
client: client,
|
||||
skipChallengeValidation: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p, srv
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func vault(srv *httptest.Server) provider.Container {
|
||||
return provider.Container{ID: "/subscriptions/s/vault", Name: "kv", Detail: srv.URL + "/", Types: objectTypes}
|
||||
}
|
||||
|
||||
func TestTokenFailureIsAuthError(t *testing.T) {
|
||||
login := []string{"az", "login"}
|
||||
cred := authCredential{cred: staticCred{err: errors.New("AADSTS700082: refresh token expired")}, login: login}
|
||||
p, srv := fakeAzure(t, cred, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("request sent without a token: %s", r.URL)
|
||||
})
|
||||
|
||||
check := func(name string, err error) {
|
||||
t.Helper()
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) {
|
||||
t.Fatalf("%s: error %v is not an AuthError", name, err)
|
||||
}
|
||||
if !reflect.DeepEqual(auth.Login, login) || !strings.Contains(auth.Error(), "AADSTS700082") {
|
||||
t.Errorf("%s: unexpected AuthError %+v", name, auth)
|
||||
}
|
||||
}
|
||||
_, err := p.Sources(context.Background())
|
||||
check("Sources (ARM)", err)
|
||||
_, err = p.Objects(context.Background(), vault(srv), provider.TypeSecret)
|
||||
check("Objects (Key Vault)", err)
|
||||
_, err = p.Containers(context.Background(), provider.Source{ID: "s"})
|
||||
check("Containers (no ARM fallback on auth errors)", err)
|
||||
}
|
||||
|
||||
func TestAuthErrorNamesAccount(t *testing.T) {
|
||||
cred := authCredential{cred: staticCred{err: errors.New("AADSTS53003")}, account: "Azure · admin"}
|
||||
_, err := cred.GetToken(context.Background(), policy.TokenRequestOptions{Scopes: []string{"x"}})
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) || auth.Who() != "Azure · admin" || !strings.HasPrefix(auth.Error(), "Azure · admin:") {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSecretsSkipsManagedAndPages(t *testing.T) {
|
||||
var srvURL string
|
||||
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/secrets" && r.URL.Query().Get("page") == "":
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"value": []any{
|
||||
map[string]any{"id": srvURL + "/secrets/b-secret", "contentType": "text/plain",
|
||||
"attributes": map[string]any{"enabled": true, "updated": 1767225600}, "tags": map[string]any{"env": "prod"}},
|
||||
map[string]any{"id": srvURL + "/secrets/tls-cert", "managed": true},
|
||||
},
|
||||
"nextLink": srvURL + "/secrets?page=2&api-version=" + r.URL.Query().Get("api-version"),
|
||||
})
|
||||
case r.URL.Path == "/secrets":
|
||||
writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": srvURL + "/secrets/a-secret"}}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
srvURL = srv.URL
|
||||
|
||||
got, err := p.Objects(context.Background(), vault(srv), provider.TypeSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, o := range got {
|
||||
names = append(names, o.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"a-secret", "b-secret"}) {
|
||||
t.Fatalf("names = %v, want sorted and without the managed secret", names)
|
||||
}
|
||||
b := got[1]
|
||||
if b.ContentType != "text/plain" || b.Tags["env"] != "prod" || b.Enabled == nil || !*b.Enabled ||
|
||||
b.Updated.IsZero() || b.Type != provider.TypeSecret {
|
||||
t.Errorf("metadata not mapped: %+v", b)
|
||||
}
|
||||
if want := "@Microsoft.KeyVault(SecretUri=" + srv.URL + "/secrets/b-secret)"; b.Reference != want {
|
||||
t.Errorf("Reference = %q, want %q", b.Reference, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForbiddenIsShortened(t *testing.T) {
|
||||
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 403, map[string]any{"error": map[string]any{
|
||||
"code": "Forbidden", "message": "Caller is not authorized to perform action on resource.",
|
||||
}})
|
||||
})
|
||||
_, err := p.Objects(context.Background(), vault(srv), provider.TypeKey)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource."; got != want {
|
||||
t.Errorf("error = %q, want %q", got, want)
|
||||
}
|
||||
var re *azcore.ResponseError
|
||||
if !errors.As(err, &re) || re.StatusCode != 403 {
|
||||
t.Error("original ResponseError lost from chain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortenDenied(t *testing.T) {
|
||||
msg := "Caller is not authorized to perform action on resource.\r\nIf role assignments, deny assignments or role definitions were changed recently, please observe propagation time.\r\nCaller: appid=04b07795;oid=1234\r\nAction: 'Microsoft.KeyVault/vaults/secrets/readMetadata/action'\r\nResource: '/subscriptions/x/vaults/kv'"
|
||||
want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action"
|
||||
for _, variant := range []string{msg, strings.ReplaceAll(msg, "\r\n", " ")} {
|
||||
if got := shortenDenied(variant); got != want {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
if got := shortenDenied("Public network access is disabled."); got != "Public network access is disabled." {
|
||||
t.Errorf("unrelated message changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretValue(t *testing.T) {
|
||||
var srvURL string
|
||||
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/secrets/db-password/v2" && r.URL.Path != "/secrets/db-password/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"id": srvURL + r.URL.Path, "value": "value-of-" + r.URL.Path})
|
||||
})
|
||||
srvURL = srv.URL
|
||||
o := provider.Object{Container: vault(srv), Type: provider.TypeSecret, Name: "db-password"}
|
||||
for version, want := range map[string]string{"": "value-of-/secrets/db-password/", "v2": "value-of-/secrets/db-password/v2"} {
|
||||
o.Version = version
|
||||
v, err := p.Value(context.Background(), o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Reveal() != want {
|
||||
t.Errorf("version %q: got %q", version, v.Reveal())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeCertificate(t *testing.T) {
|
||||
der, cert := selfSigned(t)
|
||||
thumb := sha1.Sum(der)
|
||||
var srvURL string
|
||||
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"id": srvURL + "/certificates/app-tls/abc123",
|
||||
"cer": base64.StdEncoding.EncodeToString(der),
|
||||
"x5t": base64.RawURLEncoding.EncodeToString(thumb[:]),
|
||||
"policy": map[string]any{"secret_props": map[string]any{"contentType": "application/x-pem-file"}},
|
||||
})
|
||||
})
|
||||
srvURL = srv.URL
|
||||
o, err := p.Describe(context.Background(), provider.Object{Container: vault(srv), Type: provider.TypeCertificate, Name: "app-tls"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attrs := map[string]string{}
|
||||
for _, a := range o.Attrs {
|
||||
attrs[a.Label] = a.Value
|
||||
}
|
||||
if o.Version != "abc123" || o.ContentType != "application/x-pem-file" {
|
||||
t.Errorf("version/content type = %q/%q", o.Version, o.ContentType)
|
||||
}
|
||||
if attrs["subject"] != "CN=app.example.com" || attrs["SANs"] != "app.example.com, 10.0.0.1" ||
|
||||
!strings.HasPrefix(attrs["thumbprint"], hexColon(thumb[:2])) {
|
||||
t.Errorf("attrs = %v", attrs)
|
||||
}
|
||||
|
||||
v, err := p.Value(context.Background(), o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block, _ := pem.Decode([]byte(v.Reveal()))
|
||||
if block == nil || block.Type != "CERTIFICATE" || !cert.Equal(mustParse(t, block.Bytes)) {
|
||||
t.Errorf("certificate PEM does not round-trip: %q", v.Reveal())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyPEM(t *testing.T) {
|
||||
rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
ecKey, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
ecX, ecY := ecPoint(t, ecKey)
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
jwk azkeys.JSONWebKey
|
||||
pub any
|
||||
wantDesc string
|
||||
}{
|
||||
"rsa": {
|
||||
jwk: azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeRSAHSM), N: rsaKey.N.Bytes(),
|
||||
E: big.NewInt(int64(rsaKey.E)).Bytes(), KeyOps: []*azkeys.KeyOperation{to.Ptr(azkeys.KeyOperationSign)}},
|
||||
pub: &rsaKey.PublicKey, wantDesc: "RSA-HSM 2048",
|
||||
},
|
||||
"ec": {
|
||||
jwk: azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeEC), Crv: to.Ptr(azkeys.CurveNameP384), X: ecX, Y: ecY},
|
||||
pub: &ecKey.PublicKey, wantDesc: "EC P-384",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
out, err := publicKeyPEM(&tc.jwk)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block, _ := pem.Decode([]byte(out))
|
||||
got, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.(interface{ Equal(crypto.PublicKey) bool }).Equal(tc.pub) {
|
||||
t.Error("public key does not round-trip")
|
||||
}
|
||||
if desc := keyAttrs(&tc.jwk)[0].Value; desc != tc.wantDesc {
|
||||
t.Errorf("key type = %q, want %q", desc, tc.wantDesc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := publicKeyPEM(&azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeOct)}); err == nil {
|
||||
t.Error("symmetric key should have no public key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortalURLs(t *testing.T) {
|
||||
id := "/subscriptions/s/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv"
|
||||
c := provider.Container{URL: PortalResourceURL("t1", id, "overview")}
|
||||
if want := "https://portal.azure.com/#@t1/resource" + id + "/overview"; c.URL != want {
|
||||
t.Errorf("vault URL = %q", c.URL)
|
||||
}
|
||||
if got := ObjectListURL(c, provider.TypeCertificate); got != "https://portal.azure.com/#@t1/resource"+id+"/certificates" {
|
||||
t.Errorf("certificates URL = %q", got)
|
||||
}
|
||||
if got := ObjectListURL(provider.Container{}, provider.TypeSecret); got != "" {
|
||||
t.Errorf("URL without vault page = %q", got)
|
||||
}
|
||||
if got := PortalResourceURL("", "", "overview"); got != "" {
|
||||
t.Errorf("URL without resource = %q", got)
|
||||
}
|
||||
if got := portalAssetURL("", "Key", "https://kv.vault.azure.net/keys/k/v1"); got !=
|
||||
"https://portal.azure.com/#asset/Microsoft_Azure_KeyVault/Key/https://kv.vault.azure.net/keys/k/v1" {
|
||||
t.Errorf("asset URL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionedReference(t *testing.T) {
|
||||
sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
|
||||
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
|
||||
if got := versionedReference(sec); got != "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s/v1)" {
|
||||
t.Errorf("secret: %q", got)
|
||||
}
|
||||
key := provider.Object{Type: provider.TypeKey, Version: "v2", Reference: "https://kv.vault.azure.net/keys/k"}
|
||||
if got := versionedReference(key); got != "https://kv.vault.azure.net/keys/k/v2" {
|
||||
t.Errorf("key: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenSubscriptionsAndPinnedVaults(t *testing.T) {
|
||||
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"value": []any{
|
||||
map[string]any{"subscriptionId": "s1", "displayName": "keep-me", "state": "Enabled", "tenantId": "t"},
|
||||
map[string]any{"subscriptionId": "s2", "displayName": "platform-mgmt", "state": "Enabled", "tenantId": "t"},
|
||||
map[string]any{"subscriptionId": "s3", "displayName": "by-id", "state": "Enabled", "tenantId": "t"},
|
||||
}})
|
||||
})
|
||||
p.opts.Settings = Settings{
|
||||
HideSubscriptions: []string{"PLATFORM-MGMT", "s3"},
|
||||
Vaults: []string{"https://kv-pinned.vault.azure.net"},
|
||||
}
|
||||
|
||||
srcs, err := p.Sources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range srcs {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"keep-me", "Pinned vaults"}) {
|
||||
t.Fatalf("sources = %v", names)
|
||||
}
|
||||
|
||||
vaults, err := p.Containers(context.Background(), srcs[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vaults) != 1 || vaults[0].Name != "kv-pinned" || vaults[0].Detail != "https://kv-pinned.vault.azure.net/" ||
|
||||
len(vaults[0].Types) != 3 {
|
||||
t.Errorf("pinned vaults = %+v", vaults)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphVault(t *testing.T) {
|
||||
src := provider.Source{ID: "sub"}
|
||||
c, ok := graphVault(src, map[string]any{"id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/"})
|
||||
if !ok || c.Name != "kv" || c.Detail != "https://kv.vault.azure.net/" || len(c.Types) != 3 {
|
||||
t.Errorf("graphVault = %+v, %v", c, ok)
|
||||
}
|
||||
if _, ok := graphVault(src, map[string]any{"id": "/x/kv", "name": "kv"}); ok {
|
||||
t.Error("row without uri accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func selfSigned(t *testing.T) ([]byte, *x509.Certificate) {
|
||||
t.Helper()
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "app.example.com"},
|
||||
DNSNames: []string{"app.example.com"},
|
||||
IPAddresses: []net.IP{net.ParseIP("10.0.0.1")},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return der, mustParse(t, der)
|
||||
}
|
||||
|
||||
func mustParse(t *testing.T, der []byte) *x509.Certificate {
|
||||
t.Helper()
|
||||
c, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) {
|
||||
t.Helper()
|
||||
b, err := k.PublicKey.Bytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
size := (len(b) - 1) / 2
|
||||
return b[1 : 1+size], b[1+size:]
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
)
|
||||
|
||||
// cliCredential gets tokens from the az CLI for one signed-in account.
|
||||
//
|
||||
// az can hold several accounts, but `az account get-access-token` can't be
|
||||
// told which user to use: it picks the account a subscription belongs to.
|
||||
// So each account is pinned with --subscription. az refuses --subscription
|
||||
// together with --tenant, which the SDK's AzureCLICredential would pass for
|
||||
// every Key Vault request (the vault's auth challenge names a tenant), so
|
||||
// this sends --tenant only for vaults in another tenant.
|
||||
//
|
||||
// Tokens are cached in memory until shortly before they expire, so the
|
||||
// many SDK clients don't each start an az process.
|
||||
type cliCredential struct {
|
||||
subscription string // pins the account; "" for az's default account
|
||||
tenant string // the account's tenant, if known
|
||||
run func(ctx context.Context, args ...string) ([]byte, error)
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]azcore.AccessToken // by resource and tenant argument
|
||||
}
|
||||
|
||||
func newCLICredential(subscription, tenant string) *cliCredential {
|
||||
return &cliCredential{subscription: subscription, tenant: tenant, run: runAz, cache: map[string]azcore.AccessToken{}}
|
||||
}
|
||||
|
||||
// refreshMargin renews tokens this long before they expire.
|
||||
const refreshMargin = 5 * time.Minute
|
||||
|
||||
func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
|
||||
if len(opts.Scopes) != 1 {
|
||||
return azcore.AccessToken{}, errors.New("az: exactly one scope is supported")
|
||||
}
|
||||
if opts.Claims != "" {
|
||||
return azcore.AccessToken{}, errors.New("az: this request needs multifactor authentication; run az login again")
|
||||
}
|
||||
resource := strings.TrimSuffix(opts.Scopes[0], "/.default")
|
||||
args := []string{"account", "get-access-token", "--output", "json", "--resource", resource}
|
||||
switch {
|
||||
case opts.TenantID != "" && opts.TenantID != c.tenant:
|
||||
// Another tenant: az picks the account itself.
|
||||
args = append(args, "--tenant", opts.TenantID)
|
||||
case c.subscription != "":
|
||||
args = append(args, "--subscription", c.subscription)
|
||||
}
|
||||
key := strings.Join(args[5:], " ")
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if tok, ok := c.cache[key]; ok && time.Until(tok.ExpiresOn) > refreshMargin {
|
||||
return tok, nil
|
||||
}
|
||||
out, err := c.run(ctx, args...)
|
||||
if err != nil {
|
||||
return azcore.AccessToken{}, err
|
||||
}
|
||||
tok, err := parseCLIToken(out)
|
||||
if err != nil {
|
||||
return azcore.AccessToken{}, err
|
||||
}
|
||||
c.cache[key] = tok
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func parseCLIToken(b []byte) (azcore.AccessToken, error) {
|
||||
var t struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpiresOn int64 `json:"expires_on"` // az 2.54+
|
||||
ExpiresOnS string `json:"expiresOn"` // older az: local time
|
||||
}
|
||||
if err := json.Unmarshal(b, &t); err != nil {
|
||||
return azcore.AccessToken{}, fmt.Errorf("az: unexpected output: %w", err)
|
||||
}
|
||||
if t.AccessToken == "" {
|
||||
return azcore.AccessToken{}, errors.New("az: no access token in output")
|
||||
}
|
||||
exp := time.Unix(t.ExpiresOn, 0)
|
||||
if t.ExpiresOn == 0 {
|
||||
var err error
|
||||
exp, err = time.ParseInLocation("2006-01-02 15:04:05.999999", t.ExpiresOnS, time.Local)
|
||||
if err != nil {
|
||||
return azcore.AccessToken{}, fmt.Errorf("az: unexpected expiry %q", t.ExpiresOnS)
|
||||
}
|
||||
}
|
||||
return azcore.AccessToken{Token: t.AccessToken, ExpiresOn: exp}, nil
|
||||
}
|
||||
|
||||
// azTimeout bounds one az call; a hung az would otherwise block a pane.
|
||||
const azTimeout = 30 * time.Second
|
||||
|
||||
func runAz(ctx context.Context, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, azTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "az", args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return nil, errors.New("az CLI not found; install it or set AZURE_* for a service principal")
|
||||
}
|
||||
if err != nil {
|
||||
if msg := strings.TrimSpace(stderr.String()); msg != "" {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
func deref[T any](p *T) T {
|
||||
var zero T
|
||||
if p == nil {
|
||||
return zero
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func tags(m map[string]*string) map[string]string {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = deref(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const portal = "https://portal.azure.com/#"
|
||||
|
||||
// PortalResourceURL links to a blade of an Azure resource in the portal,
|
||||
// e.g. "overview" or "secrets". tenant may be empty.
|
||||
func PortalResourceURL(tenant, resourceID, blade string) string {
|
||||
if resourceID == "" {
|
||||
return ""
|
||||
}
|
||||
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
|
||||
}
|
||||
|
||||
// portalAssetURL links to a Key Vault object's page; objectID must include
|
||||
// the version.
|
||||
func portalAssetURL(tenant, kind, objectID string) string {
|
||||
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
|
||||
}
|
||||
|
||||
func tenantPrefix(tenant string) string {
|
||||
if tenant == "" {
|
||||
return ""
|
||||
}
|
||||
return "@" + tenant + "/"
|
||||
}
|
||||
|
||||
// ObjectListURL links to the vault's list of the given object type,
|
||||
// derived from the container's overview URL.
|
||||
func ObjectListURL(c provider.Container, t provider.ObjectType) string {
|
||||
base, ok := strings.CutSuffix(c.URL, "/overview")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch t {
|
||||
case provider.TypeKey:
|
||||
return base + "/keys"
|
||||
case provider.TypeCertificate:
|
||||
return base + "/certificates"
|
||||
}
|
||||
return base + "/secrets"
|
||||
}
|
||||
|
||||
// objectURL is the data-plane URL of an object, e.g.
|
||||
// https://kv.vault.azure.net/secrets/name.
|
||||
func objectURL(c provider.Container, collection, name string) string {
|
||||
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
|
||||
}
|
||||
|
||||
func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object {
|
||||
name := p.ID.Name()
|
||||
url := objectURL(c, "secrets", name)
|
||||
o := provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeSecret,
|
||||
ID: url,
|
||||
Name: name,
|
||||
Version: p.ID.Version(),
|
||||
ContentType: deref(p.ContentType),
|
||||
Tags: tags(p.Tags),
|
||||
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
|
||||
URL: ObjectListURL(c, provider.TypeSecret),
|
||||
}
|
||||
if a := p.Attributes; a != nil {
|
||||
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
|
||||
name := p.KID.Name()
|
||||
url := objectURL(c, "keys", name)
|
||||
o := provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeKey,
|
||||
ID: url,
|
||||
Name: name,
|
||||
Version: p.KID.Version(),
|
||||
Tags: tags(p.Tags),
|
||||
Reference: url,
|
||||
URL: ObjectListURL(c, provider.TypeKey),
|
||||
}
|
||||
if a := p.Attributes; a != nil {
|
||||
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
|
||||
name := p.ID.Name()
|
||||
url := objectURL(c, "certificates", name)
|
||||
o := provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeCertificate,
|
||||
ID: url,
|
||||
Name: name,
|
||||
Version: p.ID.Version(),
|
||||
Tags: tags(p.Tags),
|
||||
Reference: url,
|
||||
URL: ObjectListURL(c, provider.TypeCertificate),
|
||||
}
|
||||
if a := p.Attributes; a != nil {
|
||||
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
|
||||
}
|
||||
if len(p.X509Thumbprint) > 0 {
|
||||
o.Attrs = []provider.Attr{{Label: "thumbprint", Value: hexColon(p.X509Thumbprint)}}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// keyAttrs describes a JSON Web Key, e.g. "RSA-HSM 3072" or "EC P-256".
|
||||
func keyAttrs(k *azkeys.JSONWebKey) []provider.Attr {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
kty := string(deref(k.Kty))
|
||||
desc := kty
|
||||
switch {
|
||||
case len(k.N) > 0:
|
||||
desc = fmt.Sprintf("%s %d", kty, new(big.Int).SetBytes(k.N).BitLen())
|
||||
case k.Crv != nil:
|
||||
desc = kty + " " + string(*k.Crv)
|
||||
}
|
||||
attrs := []provider.Attr{{Label: "key type", Value: desc}}
|
||||
var ops []string
|
||||
for _, op := range k.KeyOps {
|
||||
ops = append(ops, string(deref(op)))
|
||||
}
|
||||
if len(ops) > 0 {
|
||||
attrs = append(attrs, provider.Attr{Label: "ops", Value: strings.Join(ops, ", ")})
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// publicKeyPEM encodes the public half of a JSON Web Key as PEM. Key Vault
|
||||
// never returns private material for keys, and this ignores it regardless.
|
||||
func publicKeyPEM(k *azkeys.JSONWebKey) (string, error) {
|
||||
if k == nil {
|
||||
return "", errors.New("key has no key material")
|
||||
}
|
||||
var pub any
|
||||
switch {
|
||||
case len(k.N) > 0 && len(k.E) > 0:
|
||||
pub = &rsa.PublicKey{N: new(big.Int).SetBytes(k.N), E: int(new(big.Int).SetBytes(k.E).Int64())}
|
||||
case len(k.X) > 0 && len(k.Y) > 0:
|
||||
var curve elliptic.Curve
|
||||
switch deref(k.Crv) {
|
||||
case azkeys.CurveNameP256:
|
||||
curve = elliptic.P256()
|
||||
case azkeys.CurveNameP384:
|
||||
curve = elliptic.P384()
|
||||
case azkeys.CurveNameP521:
|
||||
curve = elliptic.P521()
|
||||
default:
|
||||
return "", fmt.Errorf("cannot encode curve %s as PEM", deref(k.Crv))
|
||||
}
|
||||
size := (curve.Params().BitSize + 7) / 8
|
||||
point := make([]byte, 1+2*size)
|
||||
point[0] = 4 // uncompressed
|
||||
copy(point[1:], leftPad(k.X, size))
|
||||
copy(point[1+size:], leftPad(k.Y, size))
|
||||
ec, err := ecdsa.ParseUncompressedPublicKey(curve, point)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pub = ec
|
||||
default:
|
||||
return "", fmt.Errorf("%s keys have no public key", deref(k.Kty))
|
||||
}
|
||||
der, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))), nil
|
||||
}
|
||||
|
||||
func leftPad(b []byte, n int) []byte {
|
||||
if len(b) >= n {
|
||||
return b
|
||||
}
|
||||
return append(make([]byte, n-len(b)), b...)
|
||||
}
|
||||
|
||||
// certAttrs describes an X.509 certificate.
|
||||
func certAttrs(cert *x509.Certificate, thumbprint []byte) []provider.Attr {
|
||||
attrs := []provider.Attr{
|
||||
{Label: "subject", Value: cert.Subject.String()},
|
||||
{Label: "issuer", Value: cert.Issuer.String()},
|
||||
}
|
||||
if len(thumbprint) > 0 {
|
||||
attrs = append(attrs, provider.Attr{Label: "thumbprint", Value: hexColon(thumbprint)})
|
||||
}
|
||||
var sans []string
|
||||
sans = append(sans, cert.DNSNames...)
|
||||
sans = append(sans, cert.EmailAddresses...)
|
||||
for _, ip := range cert.IPAddresses {
|
||||
sans = append(sans, ip.String())
|
||||
}
|
||||
for _, u := range cert.URIs {
|
||||
sans = append(sans, u.String())
|
||||
}
|
||||
if len(sans) > 0 {
|
||||
attrs = append(attrs, provider.Attr{Label: "SANs", Value: strings.Join(sans, ", ")})
|
||||
}
|
||||
attrs = append(attrs, provider.Attr{Label: "valid from", Value: cert.NotBefore.Local().Format("2006-01-02 15:04")})
|
||||
return attrs
|
||||
}
|
||||
|
||||
func certPEM(der []byte) string {
|
||||
return strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})))
|
||||
}
|
||||
|
||||
func hexColon(b []byte) string {
|
||||
parts := make([]string, len(b))
|
||||
for i, x := range b {
|
||||
parts[i] = fmt.Sprintf("%02X", x)
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
// cleanError shortens the SDK's multi-line HTTP errors to "403 Forbidden:
|
||||
// <service message>", keeping the original in the chain.
|
||||
func cleanError(err error) error {
|
||||
var re *azcore.ResponseError
|
||||
if !errors.As(err, &re) {
|
||||
return err
|
||||
}
|
||||
msg := re.ErrorCode
|
||||
if re.RawResponse != nil {
|
||||
if body, rerr := runtime.Payload(re.RawResponse); rerr == nil {
|
||||
var payload struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &payload) == nil && payload.Error.Message != "" {
|
||||
msg = payload.Error.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
|
||||
}
|
||||
|
||||
// shortenDenied reduces Key Vault's RBAC denial, which runs to several
|
||||
// sentences plus caller and resource IDs, to its first sentence and the
|
||||
// missing action.
|
||||
func shortenDenied(msg string) string {
|
||||
const marker = "Action: '"
|
||||
i := strings.Index(msg, marker)
|
||||
if i < 0 {
|
||||
return msg
|
||||
}
|
||||
action, _, ok := strings.Cut(msg[i+len(marker):], "'")
|
||||
if !ok || action == "" {
|
||||
return msg
|
||||
}
|
||||
first := msg
|
||||
if k := strings.IndexAny(first, "\r\n"); k >= 0 {
|
||||
first = first[:k]
|
||||
}
|
||||
first, _, _ = strings.Cut(first, ". ")
|
||||
return strings.TrimSuffix(first, ".") + "; missing " + action
|
||||
}
|
||||
|
||||
type serviceError struct {
|
||||
status int
|
||||
msg string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *serviceError) Error() string {
|
||||
s := fmt.Sprintf("%d %s", e.status, http.StatusText(e.status))
|
||||
if e.msg != "" {
|
||||
s += ": " + e.msg
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (e *serviceError) Unwrap() error { return e.err }
|
||||
@@ -0,0 +1,367 @@
|
||||
// Package databricks is the Databricks provider. Sources are workspace
|
||||
// profiles from ~/.databrickscfg, containers are secret scopes, and objects
|
||||
// are secrets. Secrets in Azure Key Vault-backed scopes are read through
|
||||
// Databricks, falling back to the vault itself.
|
||||
package databricks
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/databricks/databricks-sdk-go/apierr"
|
||||
"github.com/databricks/databricks-sdk-go/client"
|
||||
dbconfig "github.com/databricks/databricks-sdk-go/config"
|
||||
"github.com/databricks/databricks-sdk-go/service/workspace"
|
||||
"gopkg.in/ini.v1"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/azure"
|
||||
)
|
||||
|
||||
// KeyVault reads Azure Key Vault secrets directly; the Azure provider
|
||||
// implements it. Containers passed to it only have Detail (the vault URL)
|
||||
// set.
|
||||
type KeyVault interface {
|
||||
Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error)
|
||||
Value(ctx context.Context, o provider.Object) (provider.SecretValue, error)
|
||||
}
|
||||
|
||||
type profile struct {
|
||||
name string
|
||||
host string
|
||||
authType string
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
profiles []profile
|
||||
keyVault KeyVault // nil disables the fallback
|
||||
newClient func(profile) (*workspace.SecretsAPI, error)
|
||||
|
||||
mu sync.Mutex
|
||||
clients map[string]*workspace.SecretsAPI // by profile name
|
||||
vaults map[string]string // AKV-backed scope ID → vault URL
|
||||
}
|
||||
|
||||
var _ provider.Provider = (*Provider)(nil)
|
||||
|
||||
// New reads workspace profiles from the Databricks config file
|
||||
// ($DATABRICKS_CONFIG_FILE or ~/.databrickscfg), leaving out hideProfiles.
|
||||
// A missing file yields a provider with no sources.
|
||||
func New(kv KeyVault, hideProfiles []string) (*Provider, error) {
|
||||
path := os.Getenv("DATABRICKS_CONFIG_FILE")
|
||||
if path == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path = filepath.Join(home, ".databrickscfg")
|
||||
}
|
||||
all, err := readProfiles(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var profiles []profile
|
||||
for _, pr := range all {
|
||||
if !config.Hidden(hideProfiles, pr.name, pr.name) {
|
||||
profiles = append(profiles, pr)
|
||||
}
|
||||
}
|
||||
return &Provider{
|
||||
profiles: profiles,
|
||||
keyVault: kv,
|
||||
newClient: profileClient,
|
||||
clients: map[string]*workspace.SecretsAPI{},
|
||||
vaults: map[string]string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// profileClient is a client for the secrets API alone. The SDK's
|
||||
// WorkspaceClient would link in every Databricks service, some 10 MB.
|
||||
func profileClient(p profile) (*workspace.SecretsAPI, error) {
|
||||
return secretsClient(&dbconfig.Config{Profile: p.name})
|
||||
}
|
||||
|
||||
func secretsClient(cfg *dbconfig.Config) (*workspace.SecretsAPI, error) {
|
||||
c, err := client.New(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workspace.NewSecrets(c), nil
|
||||
}
|
||||
|
||||
// readProfiles returns the workspace profiles in file order, skipping
|
||||
// account-console profiles, which have no secret scopes.
|
||||
func readProfiles(path string) ([]profile, error) {
|
||||
f, err := ini.Load(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
var out []profile
|
||||
for _, s := range f.Sections() {
|
||||
host := s.Key("host").String()
|
||||
if host == "" || s.HasKey("account_id") || strings.Contains(host, "accounts.") {
|
||||
continue
|
||||
}
|
||||
out = append(out, profile{name: s.Name(), host: host, authType: s.Key("auth_type").String()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loginCommand is what signs the user back in for a profile, or nil if
|
||||
// there is nothing to run (e.g. a personal access token).
|
||||
func loginCommand(p profile) []string {
|
||||
switch p.authType {
|
||||
case "azure-cli":
|
||||
return []string{"az", "login"}
|
||||
case "pat":
|
||||
return nil
|
||||
default:
|
||||
return []string{"databricks", "auth", "login", "--profile", p.name}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Kind() provider.Kind { return provider.KindDatabricks }
|
||||
func (p *Provider) ID() string { return string(provider.KindDatabricks) }
|
||||
func (p *Provider) Label() string { return string(provider.KindDatabricks) }
|
||||
|
||||
func (p *Provider) Sources(context.Context) ([]provider.Source, error) {
|
||||
out := make([]provider.Source, 0, len(p.profiles))
|
||||
for _, pr := range p.profiles {
|
||||
host := strings.TrimPrefix(pr.host, "https://")
|
||||
out = append(out, provider.Source{
|
||||
Kind: provider.KindDatabricks, ID: pr.name, Name: pr.name,
|
||||
Detail: host, URL: "https://" + host,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) profile(name string) (profile, bool) {
|
||||
for _, pr := range p.profiles {
|
||||
if pr.name == name {
|
||||
return pr, true
|
||||
}
|
||||
}
|
||||
return profile{}, false
|
||||
}
|
||||
|
||||
func (p *Provider) client(name string) (*workspace.SecretsAPI, profile, error) {
|
||||
pr, ok := p.profile(name)
|
||||
if !ok {
|
||||
return nil, pr, fmt.Errorf("unknown profile %q", name)
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if c, ok := p.clients[name]; ok {
|
||||
return c, pr, nil
|
||||
}
|
||||
c, err := p.newClient(pr)
|
||||
if err != nil {
|
||||
return nil, pr, classify(pr, err)
|
||||
}
|
||||
p.clients[name] = c
|
||||
return c, pr, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
w, pr, err := p.client(src.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes, err := w.ListScopesAll(ctx)
|
||||
if err != nil {
|
||||
return nil, classify(pr, err)
|
||||
}
|
||||
out := make([]provider.Container, 0, len(scopes))
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, s := range scopes {
|
||||
c := provider.Container{Source: src, ID: src.ID + "/" + s.Name, Name: s.Name, Detail: "Databricks-backed"}
|
||||
if s.BackendType == workspace.ScopeBackendTypeAzureKeyvault && s.KeyvaultMetadata != nil {
|
||||
c.Detail = akvDetail + s.KeyvaultMetadata.DnsName
|
||||
c.URL = azure.PortalResourceURL("", s.KeyvaultMetadata.ResourceId, "overview")
|
||||
p.vaults[c.ID] = s.KeyvaultMetadata.DnsName
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Container) int {
|
||||
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls
|
||||
// back to the scope's Detail, so a scope remembered from an earlier session
|
||||
// (a favourite) works before its workspace's scopes are listed.
|
||||
func (p *Provider) vaultURL(c provider.Container) string {
|
||||
p.mu.Lock()
|
||||
url := p.vaults[c.ID]
|
||||
p.mu.Unlock()
|
||||
if url == "" {
|
||||
url, _ = strings.CutPrefix(c.Detail, akvDetail)
|
||||
if url == c.Detail {
|
||||
url = ""
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const akvDetail = "Azure Key Vault-backed: "
|
||||
|
||||
func secretObject(c provider.Container, key string, updated time.Time) provider.Object {
|
||||
return provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeSecret,
|
||||
ID: c.ID + "/" + key,
|
||||
Name: key,
|
||||
Updated: updated,
|
||||
Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key),
|
||||
URL: azure.ObjectListURL(c, provider.TypeSecret), // AKV-backed only
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
if t != provider.TypeSecret {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
w, pr, err := p.client(c.Source.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Object
|
||||
secrets, err := w.ListSecretsAll(ctx, workspace.ListSecretsRequest{Scope: c.Name})
|
||||
if err == nil {
|
||||
for _, s := range secrets {
|
||||
var updated time.Time
|
||||
if s.LastUpdatedTimestamp > 0 {
|
||||
updated = time.UnixMilli(s.LastUpdatedTimestamp)
|
||||
}
|
||||
out = append(out, secretObject(c, s.Key, updated))
|
||||
}
|
||||
} else {
|
||||
dbxErr := classify(pr, err)
|
||||
out, err = p.vaultObjects(ctx, c)
|
||||
if err != nil {
|
||||
return nil, combine(dbxErr, err)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// vaultObjects lists an AKV-backed scope's secrets from the vault itself.
|
||||
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
|
||||
url := p.vaultURL(c)
|
||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
||||
return nil, errNoFallback
|
||||
}
|
||||
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]provider.Object, 0, len(secrets))
|
||||
for _, s := range secrets {
|
||||
out = append(out, secretObject(c, s.Name, s.Updated))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
|
||||
return o, ctx.Err()
|
||||
}
|
||||
|
||||
func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object, error) {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
w, pr, err := p.client(o.Container.Source.ID)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
resp, err := w.GetSecret(ctx, workspace.GetSecretRequest{Scope: o.Container.Name, Key: o.Name})
|
||||
if err == nil {
|
||||
b, derr := base64.StdEncoding.DecodeString(resp.Value)
|
||||
if derr != nil {
|
||||
return provider.SecretValue{}, fmt.Errorf("decoding secret: %w", derr)
|
||||
}
|
||||
return provider.NewSecretValue(b), nil
|
||||
}
|
||||
dbxErr := classify(pr, err)
|
||||
|
||||
url := p.vaultURL(o.Container)
|
||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
||||
return provider.SecretValue{}, dbxErr
|
||||
}
|
||||
v, kvErr := p.keyVault.Value(ctx, provider.Object{
|
||||
Container: provider.Container{Detail: url},
|
||||
Type: provider.TypeSecret,
|
||||
Name: o.Name,
|
||||
})
|
||||
if kvErr != nil {
|
||||
return provider.SecretValue{}, combine(dbxErr, kvErr)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
var errNoFallback = errors.New("no Key Vault fallback")
|
||||
|
||||
// combine reports both the Databricks and the Key Vault failure. An auth
|
||||
// error from either stays reachable with errors.As.
|
||||
func combine(dbxErr, kvErr error) error {
|
||||
if errors.Is(kvErr, errNoFallback) {
|
||||
return dbxErr
|
||||
}
|
||||
return fmt.Errorf("%w; Key Vault fallback: %w", dbxErr, kvErr)
|
||||
}
|
||||
|
||||
// classify turns authentication failures into *provider.AuthError: a 401
|
||||
// from the API, or failing to get a token before the request was sent.
|
||||
func classify(pr profile, err error) error {
|
||||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
var api *apierr.APIError
|
||||
if errors.As(err, &api) {
|
||||
if api.StatusCode != 401 {
|
||||
return err
|
||||
}
|
||||
} else if !strings.Contains(err.Error(), "cannot get access token") &&
|
||||
!strings.Contains(err.Error(), " auth: ") {
|
||||
return err
|
||||
}
|
||||
return &provider.AuthError{Kind: provider.KindDatabricks, Login: loginCommand(pr), Err: err}
|
||||
}
|
||||
|
||||
var _ provider.Locator = (*Provider)(nil)
|
||||
|
||||
// Locate finds the profile for a workspace host. It doesn't contact the
|
||||
// workspace.
|
||||
func (p *Provider) Locate(_ context.Context, host string) (string, bool, error) {
|
||||
want := normalizeHost(host)
|
||||
for _, pr := range p.profiles {
|
||||
if normalizeHost(pr.host) == want {
|
||||
return pr.name, true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func normalizeHost(h string) string {
|
||||
h = strings.ToLower(strings.TrimSpace(h))
|
||||
h = strings.TrimPrefix(h, "https://")
|
||||
return strings.TrimSuffix(h, "/")
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package databricks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dbconfig "github.com/databricks/databricks-sdk-go/config"
|
||||
"github.com/databricks/databricks-sdk-go/service/workspace"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
func TestReadProfiles(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "databrickscfg")
|
||||
cfg := `[DEFAULT]
|
||||
host = https://adb-1.1.azuredatabricks.net
|
||||
auth_type = azure-cli
|
||||
|
||||
[INFRA-DEV]
|
||||
host = adb-2.2.azuredatabricks.net
|
||||
auth_type = azure-cli
|
||||
|
||||
[ACCOUNT]
|
||||
host = https://accounts.azuredatabricks.net
|
||||
auth_type = azure-cli
|
||||
|
||||
[acct-by-id]
|
||||
host = https://example.cloud.databricks.com
|
||||
account_id = 123
|
||||
|
||||
[oauth]
|
||||
host = https://adb-3.3.azuredatabricks.net
|
||||
auth_type = databricks-cli
|
||||
|
||||
[no-host]
|
||||
token = x
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readProfiles(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []profile{
|
||||
{name: "DEFAULT", host: "https://adb-1.1.azuredatabricks.net", authType: "azure-cli"},
|
||||
{name: "INFRA-DEV", host: "adb-2.2.azuredatabricks.net", authType: "azure-cli"},
|
||||
{name: "oauth", host: "https://adb-3.3.azuredatabricks.net", authType: "databricks-cli"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("profiles = %+v\nwant %+v", got, want)
|
||||
}
|
||||
|
||||
if got, err := readProfiles(filepath.Join(t.TempDir(), "missing")); err != nil || got != nil {
|
||||
t.Errorf("missing file: %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCommand(t *testing.T) {
|
||||
for authType, want := range map[string][]string{
|
||||
"azure-cli": {"az", "login"},
|
||||
"databricks-cli": {"databricks", "auth", "login", "--profile", "p"},
|
||||
"": {"databricks", "auth", "login", "--profile", "p"},
|
||||
"pat": nil,
|
||||
} {
|
||||
if got := loginCommand(profile{name: "p", authType: authType}); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%q: %v, want %v", authType, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeKeyVault stands in for the Azure provider.
|
||||
type fakeKeyVault struct {
|
||||
secrets map[string]string // name → value, for vault URL kvURL
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
const kvURL = "https://kv-app-prod.vault.azure.net/"
|
||||
|
||||
func (k *fakeKeyVault) Objects(_ context.Context, c provider.Container, _ provider.ObjectType) ([]provider.Object, error) {
|
||||
k.calls++
|
||||
if k.err != nil {
|
||||
return nil, k.err
|
||||
}
|
||||
if c.Detail != kvURL {
|
||||
return nil, errors.New("wrong vault " + c.Detail)
|
||||
}
|
||||
var out []provider.Object
|
||||
for name := range k.secrets {
|
||||
out = append(out, provider.Object{Name: name})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *fakeKeyVault) Value(_ context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
k.calls++
|
||||
if k.err != nil {
|
||||
return provider.SecretValue{}, k.err
|
||||
}
|
||||
if o.Container.Detail != kvURL {
|
||||
return provider.SecretValue{}, errors.New("wrong vault " + o.Container.Detail)
|
||||
}
|
||||
return provider.NewSecretValue([]byte(k.secrets[o.Name])), nil
|
||||
}
|
||||
|
||||
// workspaceAPI serves the secrets API. The "akv" scope behaves like an Azure
|
||||
// Key Vault-backed scope whose secrets Databricks refuses to read.
|
||||
func workspaceAPI(t *testing.T, status int) *httptest.Server {
|
||||
t.Helper()
|
||||
write := func(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if status != 0 {
|
||||
write(w, status, map[string]any{"error_code": "UNAUTHENTICATED", "message": "Token is expired"})
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
switch {
|
||||
case r.URL.Path == "/api/2.0/secrets/scopes/list":
|
||||
write(w, 200, map[string]any{"scopes": []any{
|
||||
map[string]any{"name": "app", "backend_type": "DATABRICKS"},
|
||||
map[string]any{"name": "akv", "backend_type": "AZURE_KEYVAULT",
|
||||
"keyvault_metadata": map[string]any{"dns_name": kvURL, "resource_id": "/x/kv-app-prod"}},
|
||||
}})
|
||||
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "app":
|
||||
write(w, 200, map[string]any{"secrets": []any{
|
||||
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
|
||||
map[string]any{"key": "client-secret"},
|
||||
}})
|
||||
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
|
||||
write(w, 200, map[string]any{"key": q.Get("key"),
|
||||
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
|
||||
case strings.HasPrefix(r.URL.Path, "/api/2.0/secrets/") && q.Get("scope") == "akv":
|
||||
write(w, 400, map[string]any{"error_code": "BAD_REQUEST",
|
||||
"message": "Reading secrets from Azure KeyVault-backed scopes is not supported"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newTestProvider(t *testing.T, srv *httptest.Server, kv KeyVault) *Provider {
|
||||
t.Helper()
|
||||
return &Provider{
|
||||
profiles: []profile{{name: "prod", host: srv.URL, authType: "azure-cli"}},
|
||||
keyVault: kv,
|
||||
newClient: func(profile) (*workspace.SecretsAPI, error) {
|
||||
return secretsClient(&dbconfig.Config{Host: srv.URL, Token: "token"})
|
||||
},
|
||||
clients: map[string]*workspace.SecretsAPI{},
|
||||
vaults: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func scopes(t *testing.T, p *Provider) map[string]provider.Container {
|
||||
t.Helper()
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
cs, err := p.Containers(context.Background(), srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]provider.Container{}
|
||||
for _, c := range cs {
|
||||
out[c.Name] = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestScopesSecretsAndValues(t *testing.T) {
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), nil)
|
||||
sc := scopes(t, p)
|
||||
if sc["app"].Detail != "Databricks-backed" || sc["akv"].Detail != "Azure Key Vault-backed: "+kvURL {
|
||||
t.Errorf("scope details = %q / %q", sc["app"].Detail, sc["akv"].Detail)
|
||||
}
|
||||
|
||||
objs, err := p.Objects(context.Background(), sc["app"], provider.TypeSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(objs) != 2 || objs[0].Name != "client-secret" || objs[1].Name != "storage-key" {
|
||||
t.Fatalf("objects = %+v", objs)
|
||||
}
|
||||
if objs[1].Updated.IsZero() || objs[1].Reference != "{{secrets/app/storage-key}}" {
|
||||
t.Errorf("metadata = %+v", objs[1])
|
||||
}
|
||||
|
||||
v, err := p.Value(context.Background(), objs[1])
|
||||
if err != nil || v.Reveal() != "value-of-storage-key" {
|
||||
t.Errorf("value = %q, %v", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKVScopeFallsBackToKeyVault(t *testing.T) {
|
||||
kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), kv)
|
||||
akv := scopes(t, p)["akv"]
|
||||
|
||||
objs, err := p.Objects(context.Background(), akv, provider.TypeSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(objs) != 1 || objs[0].Name != "db-password" || objs[0].Reference != "{{secrets/akv/db-password}}" {
|
||||
t.Fatalf("objects = %+v", objs)
|
||||
}
|
||||
v, err := p.Value(context.Background(), objs[0])
|
||||
if err != nil || v.Reveal() != "from-the-vault" {
|
||||
t.Errorf("value = %q, %v", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKVFallbackForRememberedScope(t *testing.T) {
|
||||
// A favourite from an earlier session: scopes not listed yet.
|
||||
kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), kv)
|
||||
akv := provider.Container{
|
||||
Source: provider.Source{Kind: provider.KindDatabricks, ID: "prod"},
|
||||
ID: "prod/akv", Name: "akv", Detail: "Azure Key Vault-backed: " + kvURL,
|
||||
}
|
||||
v, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"})
|
||||
if err != nil || v.Reveal() != "from-the-vault" {
|
||||
t.Errorf("value = %q, %v", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKVFallbackFailureReportsBoth(t *testing.T) {
|
||||
kvAuth := &provider.AuthError{Kind: provider.KindAzure, Login: []string{"az", "login"}, Err: errors.New("expired")}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), &fakeKeyVault{err: kvAuth})
|
||||
akv := scopes(t, p)["akv"]
|
||||
|
||||
_, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"})
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported") || !strings.Contains(err.Error(), "Key Vault fallback") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) {
|
||||
t.Error("Key Vault auth error not reachable for the sign-in prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoFallbackForDatabricksBackedScopes(t *testing.T) {
|
||||
kv := &fakeKeyVault{}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), kv)
|
||||
app := scopes(t, p)["app"]
|
||||
if _, err := p.Value(context.Background(), provider.Object{Container: app, Name: "missing"}); err != nil {
|
||||
// app/get returns a value for any key; just make sure the vault wasn't asked
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kv.calls != 0 {
|
||||
t.Errorf("Key Vault consulted for a Databricks-backed scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorizedIsAuthError(t *testing.T) {
|
||||
p := newTestProvider(t, workspaceAPI(t, 401), nil)
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
_, err := p.Containers(context.Background(), srcs[0])
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) {
|
||||
t.Fatalf("err = %v, want AuthError", err)
|
||||
}
|
||||
if auth.Kind != provider.KindDatabricks || !reflect.DeepEqual(auth.Login, []string{"az", "login"}) {
|
||||
t.Errorf("auth = %+v", auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
pr := profile{name: "p", authType: "databricks-cli"}
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(classify(pr, errors.New("default auth: azure-cli: cannot get access token: run az login")), &auth) {
|
||||
t.Error("token failure not classified as auth")
|
||||
}
|
||||
if errors.As(classify(pr, errors.New("dial tcp: no such host")), &auth) {
|
||||
t.Error("network error classified as auth")
|
||||
}
|
||||
if errors.As(classify(pr, context.Canceled), &auth) {
|
||||
t.Error("cancellation classified as auth")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// Package fake is an in-memory provider used by tests and --demo mode.
|
||||
package fake
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Provider serves a fixed tree of sources, containers and objects,
|
||||
// optionally sleeping before every call to simulate network latency.
|
||||
type Provider struct {
|
||||
kind provider.Kind
|
||||
id, label string
|
||||
delay time.Duration
|
||||
sources []provider.Source
|
||||
containers map[string][]provider.Container // by source ID
|
||||
objects map[listKey][]provider.Object
|
||||
errs map[listKey]error
|
||||
values map[string]string // by object ID
|
||||
versions map[string][]provider.Object // by object ID
|
||||
}
|
||||
|
||||
type listKey struct {
|
||||
container string
|
||||
typ provider.ObjectType
|
||||
}
|
||||
|
||||
var _ provider.Provider = (*Provider)(nil)
|
||||
|
||||
func newProvider(kind provider.Kind, delay time.Duration) *Provider {
|
||||
return &Provider{
|
||||
kind: kind,
|
||||
delay: delay,
|
||||
containers: map[string][]provider.Container{},
|
||||
objects: map[listKey][]provider.Object{},
|
||||
errs: map[listKey]error{},
|
||||
values: map[string]string{},
|
||||
versions: map[string][]provider.Object{},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Kind() provider.Kind { return p.kind }
|
||||
|
||||
func (p *Provider) ID() string {
|
||||
if p.id != "" {
|
||||
return p.id
|
||||
}
|
||||
return string(p.kind)
|
||||
}
|
||||
|
||||
func (p *Provider) Label() string {
|
||||
if p.label != "" {
|
||||
return p.label
|
||||
}
|
||||
return string(p.kind)
|
||||
}
|
||||
|
||||
// As renames the provider, to simulate several accounts of one kind.
|
||||
func (p *Provider) As(id, label string) *Provider {
|
||||
p.id, p.label = id, label
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.sources, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.containers[src.ID], nil
|
||||
}
|
||||
|
||||
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k := listKey{p.containerID(c), t}
|
||||
if err := p.errs[k]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.objects[k], nil
|
||||
}
|
||||
|
||||
// Describe returns o unchanged; the fixtures already carry their details.
|
||||
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
|
||||
return o, ctx.Err()
|
||||
}
|
||||
|
||||
func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.kind == provider.KindDatabricks {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
vs := p.versions[o.ID]
|
||||
if len(vs) == 0 { // every object has at least its current version
|
||||
return []provider.Object{o}, nil
|
||||
}
|
||||
out := make([]provider.Object, len(vs))
|
||||
for i, v := range vs {
|
||||
v.Container, v.Type, v.ID, v.Name, v.Reference, v.URL = o.Container, o.Type, o.ID, o.Name, o.Reference, o.URL
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
if o.ID == "" { // built from a link, like the real providers read it
|
||||
o.ID = p.containerID(o.Container) + "/" + strings.ToLower(string(cmp.Or(o.Type, provider.TypeSecret))) + "/" + o.Name
|
||||
}
|
||||
v, ok := p.values[o.ID+"@"+o.Version]
|
||||
if !ok {
|
||||
v, ok = p.values[o.ID]
|
||||
}
|
||||
if !ok {
|
||||
return provider.SecretValue{}, fmt.Errorf("%s %q not found", strings.ToLower(string(o.Type)), o.Name)
|
||||
}
|
||||
return provider.NewSecretValue([]byte(v)), nil
|
||||
}
|
||||
|
||||
// Locate finds the source holding the vault (by host) or the workspace.
|
||||
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
for _, src := range p.sources {
|
||||
if p.kind == provider.KindDatabricks && src.Detail == host {
|
||||
return src.ID, true, nil
|
||||
}
|
||||
for _, c := range p.containers[src.ID] {
|
||||
if c.Detail == "https://"+host {
|
||||
return src.ID, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func (p *Provider) wait(ctx context.Context) error {
|
||||
if p.delay <= 0 {
|
||||
return ctx.Err()
|
||||
}
|
||||
t := time.NewTimer(p.delay)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
const portal = "https://portal.azure.com/#resource"
|
||||
|
||||
// containerID finds a container by ID, or by vault URL like the real Azure
|
||||
// provider, which only needs the URL (vaults opened from links).
|
||||
func (p *Provider) containerID(c provider.Container) string {
|
||||
for _, cs := range p.containers {
|
||||
for _, x := range cs {
|
||||
if x.ID == c.ID {
|
||||
return x.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not one listed here (built from a link): by vault URL, or a scope
|
||||
// by name in its workspace.
|
||||
for _, cs := range p.containers {
|
||||
for _, x := range cs {
|
||||
if c.Detail != "" && strings.TrimSuffix(x.Detail, "/") == strings.TrimSuffix(c.Detail, "/") {
|
||||
return x.ID
|
||||
}
|
||||
if c.Detail == "" && c.Name == x.Name && c.Source.ID != "" && c.Source.ID == x.Source.ID {
|
||||
return x.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.ID
|
||||
}
|
||||
|
||||
func (p *Provider) addSource(id, name, detail string) provider.Source {
|
||||
s := provider.Source{Kind: p.kind, ID: id, Name: name, Detail: detail}
|
||||
if p.kind == provider.KindAzure {
|
||||
s.URL = portal + "/subscriptions/" + id + "/overview"
|
||||
} else {
|
||||
s.URL = "https://" + detail
|
||||
}
|
||||
p.sources = append(p.sources, s)
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *Provider) addContainer(src provider.Source, name, detail string, types ...provider.ObjectType) provider.Container {
|
||||
c := provider.Container{Source: src, ID: src.ID + "/" + name, Name: name, Detail: detail, Types: types}
|
||||
vault := name
|
||||
if p.kind == provider.KindDatabricks {
|
||||
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
|
||||
}
|
||||
if p.kind == provider.KindAzure || vault != detail {
|
||||
c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview"
|
||||
}
|
||||
p.containers[src.ID] = append(p.containers[src.ID], c)
|
||||
return c
|
||||
}
|
||||
|
||||
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
|
||||
o.Container = c
|
||||
o.Type = t
|
||||
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
|
||||
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
|
||||
o.URL = base + map[provider.ObjectType]string{
|
||||
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
|
||||
}[t]
|
||||
}
|
||||
k := listKey{c.ID, t}
|
||||
p.objects[k] = append(p.objects[k], o)
|
||||
p.values[o.ID] = value
|
||||
return o
|
||||
}
|
||||
|
||||
func (p *Provider) fail(c provider.Container, err error, types ...provider.ObjectType) {
|
||||
for _, t := range types {
|
||||
p.errs[listKey{c.ID, t}] = err
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
|
||||
yes = true
|
||||
no = false
|
||||
)
|
||||
|
||||
// ErrForbidden mimics a data-plane permission error from a backend.
|
||||
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
|
||||
|
||||
// ErrForbiddenKeys mimics a vault where the caller can read secrets but not keys.
|
||||
var ErrForbiddenKeys = errors.New("403 Forbidden: caller lacks permission to list keys")
|
||||
|
||||
const (
|
||||
demoPublicKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAdemo\n-----END PUBLIC KEY-----"
|
||||
demoCert = "-----BEGIN CERTIFICATE-----\nMIIBszCCAVmgAwIBAgIUDEMO\nDEMODEMODEMO\n-----END CERTIFICATE-----"
|
||||
)
|
||||
|
||||
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
|
||||
|
||||
// Azure returns a fake Azure provider with two subscriptions.
|
||||
func Azure(delay time.Duration) *Provider {
|
||||
p := newProvider(provider.KindAzure, delay)
|
||||
secretRef := func(vault, name string) string {
|
||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
||||
}
|
||||
objectURL := func(vault, kind, name string) string {
|
||||
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
|
||||
}
|
||||
|
||||
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
||||
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
|
||||
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
|
||||
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
|
||||
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
|
||||
}, "correct-horse-battery-staple")
|
||||
p.versions[dbPassword.ID] = []provider.Object{
|
||||
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
|
||||
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
|
||||
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
|
||||
}
|
||||
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
|
||||
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
||||
Reference: secretRef("kv-app-prod", "api-key"),
|
||||
}, "sk_live_51HxDEMO0000000000000000")
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
|
||||
Reference: secretRef("kv-app-prod", "legacy-token"),
|
||||
}, "deprecated")
|
||||
|
||||
p.add(kv, provider.TypeKey, provider.Object{
|
||||
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "key type", Value: "RSA 2048"},
|
||||
{Label: "ops", Value: "sign, verify"},
|
||||
},
|
||||
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
|
||||
}, demoPublicKey)
|
||||
p.add(kv, provider.TypeKey, provider.Object{
|
||||
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "key type", Value: "EC P-256"},
|
||||
{Label: "ops", Value: "wrapKey, unwrapKey"},
|
||||
},
|
||||
Reference: objectURL("kv-app-prod", "keys", "data-encryption"),
|
||||
}, demoPublicKey)
|
||||
|
||||
p.add(kv, provider.TypeCertificate, provider.Object{
|
||||
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0),
|
||||
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "subject", Value: "CN=app.example.com"},
|
||||
{Label: "issuer", Value: "CN=Example Issuing CA"},
|
||||
{Label: "thumbprint", Value: "3B:1F:9A:0C:44:E2:7D:15"},
|
||||
{Label: "SANs", Value: "app.example.com, www.app.example.com"},
|
||||
{Label: "valid from", Value: "2026-08-01 09:30"},
|
||||
},
|
||||
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
|
||||
}, demoCert)
|
||||
p.add(kv, provider.TypeCertificate, provider.Object{
|
||||
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
||||
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "subject", Value: "CN=old.example.com"},
|
||||
{Label: "issuer", Value: "CN=Example Issuing CA"},
|
||||
{Label: "thumbprint", Value: "A0:77:12:DE:9B:03:C6:58"},
|
||||
},
|
||||
Reference: objectURL("kv-app-prod", "certificates", "old-tls"),
|
||||
}, demoCert)
|
||||
|
||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||
p.add(shared, provider.TypeSecret, provider.Object{
|
||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||
}, "smtp-demo-password")
|
||||
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
|
||||
|
||||
locked := p.addContainer(prod, "kv-restricted", "https://kv-restricted.vault.azure.net", allTypes...)
|
||||
p.fail(locked, ErrForbidden, allTypes...)
|
||||
|
||||
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
|
||||
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
|
||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
|
||||
}, "dev-password")
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// AzureOps returns a second fake Azure account. It shares platform-prod
|
||||
// with Azure but sees a different vault there, and has a subscription of
|
||||
// its own, like an admin account next to a personal one.
|
||||
func AzureOps(delay time.Duration) *Provider {
|
||||
p := newProvider(provider.KindAzure, delay)
|
||||
secretRef := func(vault, name string) string {
|
||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
||||
}
|
||||
|
||||
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
||||
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
|
||||
p.add(breakGlass, provider.TypeSecret, provider.Object{
|
||||
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
|
||||
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
||||
}, "break-glass-demo-password")
|
||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||
p.add(shared, provider.TypeSecret, provider.Object{
|
||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||
}, "smtp-demo-password")
|
||||
// Unlike the first account, ops may read keys here.
|
||||
p.add(shared, provider.TypeKey, provider.Object{
|
||||
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
||||
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
|
||||
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
|
||||
}, demoPublicKey)
|
||||
|
||||
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
|
||||
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
|
||||
}, "pd-demo-token")
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Databricks returns a fake Databricks provider with two workspace profiles,
|
||||
// including an Azure Key Vault-backed scope. Scopes hold secrets only.
|
||||
func Databricks(delay time.Duration) *Provider {
|
||||
p := newProvider(provider.KindDatabricks, delay)
|
||||
secret := func(c provider.Container, key, value string) {
|
||||
p.add(c, provider.TypeSecret, provider.Object{
|
||||
Name: key, Updated: base, Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key),
|
||||
}, value)
|
||||
}
|
||||
|
||||
prod := p.addSource("dbx-prod", "prod", "adb-1111111111111111.11.azuredatabricks.net")
|
||||
app := p.addContainer(prod, "app", "Databricks-backed")
|
||||
secret(app, "storage-key", "storage-demo-key")
|
||||
secret(app, "sp-client-secret", "sp-demo-secret")
|
||||
akv := p.addContainer(prod, "kv-app-prod", "Azure Key Vault-backed: kv-app-prod")
|
||||
secret(akv, "db-password", "correct-horse-battery-staple")
|
||||
|
||||
dev := p.addSource("dbx-dev", "dev", "adb-2222222222222222.2.azuredatabricks.net")
|
||||
devApp := p.addContainer(dev, "app", "Databricks-backed")
|
||||
secret(devApp, "storage-key", "dev-storage-key")
|
||||
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Package provider defines the read-only interface every secret backend
|
||||
// implements, and the types the UI works with.
|
||||
//
|
||||
// All backends map onto the same hierarchy:
|
||||
//
|
||||
// Source Azure subscription | Databricks workspace profile
|
||||
// Container Key Vault | secret scope
|
||||
// Object secret, key or certificate | secret
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kind identifies a backend.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindAzure Kind = "Azure"
|
||||
KindDatabricks Kind = "Databricks"
|
||||
// KindRepo is a git repository's encrypted secret files (SOPS).
|
||||
KindRepo Kind = "Repo"
|
||||
)
|
||||
|
||||
// AuthError reports that the user must (re)authenticate with a backend, for
|
||||
// example because their az CLI login has expired.
|
||||
type AuthError struct {
|
||||
Kind Kind
|
||||
// Account names the identity when a provider has several, e.g.
|
||||
// "Azure · admin.alice"; empty otherwise.
|
||||
Account string
|
||||
// Login is the command that signs the user in, e.g. ["az", "login"].
|
||||
Login []string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *AuthError) Error() string {
|
||||
return fmt.Sprintf("%s: not signed in or session expired: %v", e.Who(), e.Err)
|
||||
}
|
||||
|
||||
// Who is the account, or the kind if there is just one.
|
||||
func (e *AuthError) Who() string {
|
||||
if e.Account != "" {
|
||||
return e.Account
|
||||
}
|
||||
return string(e.Kind)
|
||||
}
|
||||
|
||||
func (e *AuthError) Unwrap() error { return e.Err }
|
||||
|
||||
// ErrUnsupported is returned by optional operations a backend cannot perform,
|
||||
// such as listing versions of a Databricks secret.
|
||||
var ErrUnsupported = errors.New("not supported by this provider")
|
||||
|
||||
// Source is a top-level account: an Azure subscription or a Databricks
|
||||
// workspace profile.
|
||||
type Source struct {
|
||||
Kind Kind
|
||||
// Provider is the ID of the provider instance the source came from.
|
||||
// The app sets it; it routes requests when several providers share a
|
||||
// Kind, e.g. one per Azure account.
|
||||
Provider string
|
||||
// ProviderLabel is that provider's Label, also set by the app.
|
||||
ProviderLabel string
|
||||
ID string
|
||||
Name string
|
||||
Detail string // e.g. tenant or workspace host
|
||||
URL string // web page for the source, if any
|
||||
// Here marks the source lazysecrets was started in (the current git
|
||||
// repo): listed first, starred or not, and labelled.
|
||||
Here bool
|
||||
}
|
||||
|
||||
// ObjectType is the kind of object stored in a container.
|
||||
type ObjectType string
|
||||
|
||||
const (
|
||||
TypeSecret ObjectType = "Secret"
|
||||
TypeKey ObjectType = "Key"
|
||||
TypeCertificate ObjectType = "Certificate"
|
||||
)
|
||||
|
||||
// Container holds objects: a Key Vault or a Databricks secret scope.
|
||||
type Container struct {
|
||||
Source Source
|
||||
ID string
|
||||
Name string
|
||||
Detail string // e.g. vault URL, or "Azure Key Vault-backed: kv-name"
|
||||
// Types lists the object types the container holds; empty means
|
||||
// secrets only.
|
||||
Types []ObjectType
|
||||
URL string // web page for the container, if any
|
||||
}
|
||||
|
||||
// ObjectTypes returns the container's object types, defaulting to secrets.
|
||||
func (c Container) ObjectTypes() []ObjectType {
|
||||
if len(c.Types) == 0 {
|
||||
return []ObjectType{TypeSecret}
|
||||
}
|
||||
return c.Types
|
||||
}
|
||||
|
||||
// Attr is a type-specific metadata field, shown in provider order.
|
||||
type Attr struct {
|
||||
Label string
|
||||
Value string
|
||||
}
|
||||
|
||||
// Object describes a secret, key or certificate without its value.
|
||||
type Object struct {
|
||||
Container Container
|
||||
Type ObjectType
|
||||
ID string
|
||||
Name string
|
||||
Version string
|
||||
Enabled *bool
|
||||
Updated time.Time
|
||||
Expires time.Time
|
||||
ContentType string
|
||||
Tags map[string]string
|
||||
// Attrs holds type-specific metadata, e.g. key type and size, or a
|
||||
// certificate's subject, issuer and thumbprint.
|
||||
Attrs []Attr
|
||||
// Reference is how the object is referenced from config, e.g.
|
||||
// "@Microsoft.KeyVault(SecretUri=...)" or "{{secrets/scope/key}}".
|
||||
Reference string
|
||||
URL string // web page for the object (or its list), if any
|
||||
}
|
||||
|
||||
// Provider is a read-only secret backend. Every method may block on the
|
||||
// network and must honour ctx cancellation.
|
||||
type Provider interface {
|
||||
Kind() Kind
|
||||
// ID identifies the instance, stable across runs, e.g. "Azure" or
|
||||
// "Azure:user@example.com/tenant".
|
||||
ID() string
|
||||
// Label names the instance in the UI, e.g. "Azure" or "Azure · user".
|
||||
Label() string
|
||||
Sources(ctx context.Context) ([]Source, error)
|
||||
Containers(ctx context.Context, src Source) ([]Container, error)
|
||||
// Objects lists objects of type t, one of c.ObjectTypes(). It returns
|
||||
// metadata only; values are fetched with Value.
|
||||
Objects(ctx context.Context, c Container, t ObjectType) ([]Object, error)
|
||||
// Describe returns o with details that listing doesn't include, such as
|
||||
// a key's type or a certificate's subject. It never fetches secret
|
||||
// values; it may return o unchanged.
|
||||
Describe(ctx context.Context, o Object) (Object, error)
|
||||
// Versions may return ErrUnsupported.
|
||||
Versions(ctx context.Context, o Object) ([]Object, error)
|
||||
// Value returns a secret's value, or the public material of a key (PEM
|
||||
// public key) or certificate (PEM certificate). Private key material is
|
||||
// never returned for keys.
|
||||
Value(ctx context.Context, o Object) (SecretValue, error)
|
||||
}
|
||||
|
||||
// Current is implemented by providers that know the source lazysecrets
|
||||
// was started in (the current git repo) and can find it faster than
|
||||
// Sources lists everything, so it shows first.
|
||||
type Current interface {
|
||||
Current(ctx context.Context) (Source, bool)
|
||||
}
|
||||
|
||||
// Locator is implemented by providers that can find where a container lives,
|
||||
// to open links. address is a vault host (kv.vault.azure.net) or a
|
||||
// workspace host. It returns the ID of the source holding it.
|
||||
type Locator interface {
|
||||
Locate(ctx context.Context, address string) (sourceID string, ok bool, err error)
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
// Package repo browses the encrypted secret files in git repositories:
|
||||
// a repo is a source, its secret files the containers (vault level), and
|
||||
// their keys the secrets. SOPS files are the one type so far.
|
||||
//
|
||||
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
|
||||
// metadata are read directly. A value is decrypted when it's revealed or
|
||||
// copied, one at a time, by the sops binary, with the keys sops itself
|
||||
// uses (age, Azure Key Vault through az, AWS/GCP KMS, PGP).
|
||||
//
|
||||
// Which files: tracked by git, then, in order of preference, the include
|
||||
// and exclude globs of the repo's .lazysecrets.yaml, those of the config,
|
||||
// or where the repo's .sops.yaml creation_rules say SOPS files go; a repo
|
||||
// with none of those is searched whole.
|
||||
package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
// Dir is where lazysecrets started: the repo containing it, if any,
|
||||
// is listed first and marked Here, with its files under Dir first.
|
||||
Dir string
|
||||
// Paths are repos to show.
|
||||
Paths []string
|
||||
// Roots are folders of repos (like ~/git): their repos, and repos one
|
||||
// level further down (org/repo).
|
||||
Roots []string
|
||||
// Starred are repos the user starred something in, shown wherever
|
||||
// they are.
|
||||
Starred []string
|
||||
// Include and Exclude are globs relative to a repo's root (** matches
|
||||
// any depth), for repos without their own .lazysecrets.yaml.
|
||||
Include, Exclude []string
|
||||
// Sops is the sops binary; "sops" (on PATH) when empty.
|
||||
Sops string
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
s Settings
|
||||
repos []repoInfo
|
||||
err error
|
||||
once sync.Once
|
||||
|
||||
// here is the repo containing Dir, found on its own so it can show
|
||||
// before the roots are scanned.
|
||||
here *repoInfo
|
||||
hereOnce sync.Once
|
||||
}
|
||||
|
||||
var (
|
||||
_ provider.Provider = (*Provider)(nil)
|
||||
_ provider.Locator = (*Provider)(nil)
|
||||
_ provider.Current = (*Provider)(nil)
|
||||
)
|
||||
|
||||
type repoInfo struct {
|
||||
root string // absolute
|
||||
name string // the folder, or its path under a root (org/repo)
|
||||
id string // the remote without scheme or .git, else the root path
|
||||
web string // the remote's web page, if it has one
|
||||
prefix string // Dir relative to root ("" when Dir isn't in it)
|
||||
here bool // the repo lazysecrets started in
|
||||
}
|
||||
|
||||
func New(s Settings) *Provider {
|
||||
if s.Sops == "" {
|
||||
s.Sops = "sops"
|
||||
}
|
||||
return &Provider{s: s}
|
||||
}
|
||||
|
||||
func (p *Provider) Kind() provider.Kind { return provider.KindRepo }
|
||||
func (p *Provider) ID() string { return "Repos" }
|
||||
func (p *Provider) Label() string { return "Repos" }
|
||||
|
||||
func (p *Provider) load() {
|
||||
p.once.Do(func() {
|
||||
seen := map[string]bool{}
|
||||
var roots []string
|
||||
names := map[string]string{} // path under a root (org/repo)
|
||||
add := func(root string) {
|
||||
if !seen[root] {
|
||||
seen[root] = true
|
||||
roots = append(roots, root)
|
||||
}
|
||||
}
|
||||
// The repo lazysecrets started in, if any: marked, so the UI can
|
||||
// put it on top, with its files under Dir first.
|
||||
here, inRepo := p.current()
|
||||
if inRepo {
|
||||
seen[here.root] = true
|
||||
}
|
||||
for _, dir := range p.s.Paths {
|
||||
root, err := gitRoot(expandHome(dir))
|
||||
if err != nil {
|
||||
p.err = errors.Join(p.err, fmt.Errorf("%s: not a git repository", dir))
|
||||
continue
|
||||
}
|
||||
add(root)
|
||||
}
|
||||
for _, dir := range p.s.Roots {
|
||||
base, _ := filepath.EvalSymlinks(expandHome(dir))
|
||||
for _, root := range reposIn(expandHome(dir), 2) {
|
||||
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") {
|
||||
names[root] = filepath.ToSlash(name)
|
||||
}
|
||||
add(root)
|
||||
}
|
||||
}
|
||||
// Repos you starred something in, wherever they are.
|
||||
for _, dir := range p.s.Starred {
|
||||
if root, err := gitRoot(dir); err == nil {
|
||||
add(root)
|
||||
}
|
||||
}
|
||||
// A git call each for the remote: dozens under ~/git, so several
|
||||
// at a time.
|
||||
infos := make([]repoInfo, len(roots))
|
||||
parallel(len(roots), func(i int) {
|
||||
infos[i] = info(roots[i], "")
|
||||
if n := names[roots[i]]; n != "" {
|
||||
infos[i].name = n
|
||||
}
|
||||
})
|
||||
if inRepo {
|
||||
infos = append([]repoInfo{here}, infos...)
|
||||
}
|
||||
p.repos = infos
|
||||
})
|
||||
}
|
||||
|
||||
// current is the repo lazysecrets started in.
|
||||
func (p *Provider) current() (repoInfo, bool) {
|
||||
p.hereOnce.Do(func() {
|
||||
root, err := gitRoot(p.s.Dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rel, _ := filepath.Rel(root, absPath(p.s.Dir))
|
||||
if rel == "." {
|
||||
rel = ""
|
||||
}
|
||||
r := info(root, filepath.ToSlash(rel))
|
||||
r.here = true
|
||||
p.here = &r
|
||||
})
|
||||
if p.here == nil {
|
||||
return repoInfo{}, false
|
||||
}
|
||||
return *p.here, true
|
||||
}
|
||||
|
||||
// Current is the repo lazysecrets started in, if it has secret files:
|
||||
// one repo to check, so it can show before the others are.
|
||||
func (p *Provider) Current(ctx context.Context) (provider.Source, bool) {
|
||||
r, ok := p.current()
|
||||
if !ok {
|
||||
return provider.Source{}, false
|
||||
}
|
||||
src := p.source(r)
|
||||
if files, err := p.Containers(ctx, src); err == nil && len(files) == 0 {
|
||||
return provider.Source{}, false
|
||||
}
|
||||
return src, true
|
||||
}
|
||||
|
||||
func (p *Provider) source(r repoInfo) provider.Source {
|
||||
return provider.Source{
|
||||
Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(),
|
||||
ID: r.root, Name: r.name, Detail: r.id, URL: r.web, Here: r.here,
|
||||
}
|
||||
}
|
||||
|
||||
// parallel runs f for 0..n-1, several at a time.
|
||||
func parallel(n int, f func(i int)) {
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, max(8, runtime.NumCPU()))
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
f(i)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// reposIn finds git repositories in dir, depth levels down at most,
|
||||
// without looking inside a repo or hidden folders.
|
||||
func reposIn(dir string, depth int) []string {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || depth == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
if _, err := os.Stat(filepath.Join(path, ".git")); err == nil {
|
||||
if abs, err := filepath.EvalSymlinks(path); err == nil {
|
||||
path = abs
|
||||
}
|
||||
out = append(out, path)
|
||||
continue
|
||||
}
|
||||
out = append(out, reposIn(path, depth-1)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func info(root, prefix string) repoInfo {
|
||||
id, web := remoteID(root)
|
||||
if id == "" {
|
||||
id = root
|
||||
}
|
||||
return repoInfo{root: root, name: filepath.Base(root), id: id, web: web, prefix: prefix}
|
||||
}
|
||||
|
||||
// Sources lists the repos that have secret files: one without any isn't
|
||||
// worth a row.
|
||||
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
p.load()
|
||||
// Each repo is checked for secret files, several at a time: under
|
||||
// ~/git that's dozens of repos.
|
||||
keep := make([]bool, len(p.repos))
|
||||
srcs := make([]provider.Source, len(p.repos))
|
||||
for i, r := range p.repos {
|
||||
srcs[i] = p.source(r)
|
||||
}
|
||||
parallel(len(srcs), func(i int) {
|
||||
files, err := p.Containers(ctx, srcs[i])
|
||||
keep[i] = err != nil || len(files) > 0
|
||||
})
|
||||
var out []provider.Source
|
||||
for i, src := range srcs {
|
||||
if keep[i] {
|
||||
out = append(out, src)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 && p.err != nil {
|
||||
return nil, p.err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) repo(id string) (repoInfo, bool) {
|
||||
if r, ok := p.current(); ok && r.root == id {
|
||||
return r, true // without waiting for the others
|
||||
}
|
||||
p.load()
|
||||
for _, r := range p.repos {
|
||||
if r.root == id {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return repoInfo{}, false
|
||||
}
|
||||
|
||||
// Containers lists a repo's secret files: those under where lazysecrets
|
||||
// started first, then the rest, each by path.
|
||||
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
r, ok := p.repo(src.ID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown repo %s", src.ID)
|
||||
}
|
||||
files, err := tracked(ctx, r.root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, sopsWhere, err := p.selector(r.root, files)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// SOPS files are tracked, where .sops.yaml says they go. Env files
|
||||
// usually aren't tracked (they're in .gitignore), so they're looked
|
||||
// for by name among untracked and ignored files too.
|
||||
envs := envFiles(r.root, files)
|
||||
seen := map[string]bool{}
|
||||
var out []provider.Container
|
||||
add := func(f string) {
|
||||
if seen[f] || !user(f) {
|
||||
return
|
||||
}
|
||||
seen[f] = true
|
||||
path := filepath.Join(r.root, f)
|
||||
c := provider.Container{Source: src, ID: path, Name: f, Types: []provider.ObjectType{provider.TypeSecret}}
|
||||
switch meta, isSops := readMeta(path); {
|
||||
case isSops && sopsWhere(f):
|
||||
c.Detail = "sops · " + strings.Join(meta.methods, ", ")
|
||||
case isSops:
|
||||
return // a SOPS file where .sops.yaml says SOPS files don't go
|
||||
case isEnv(f):
|
||||
c.Detail = "env · plain text"
|
||||
default:
|
||||
return
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
for _, f := range envs {
|
||||
add(f)
|
||||
}
|
||||
for _, f := range files {
|
||||
if !inPackages(f) {
|
||||
add(f)
|
||||
}
|
||||
}
|
||||
under := func(c provider.Container) bool {
|
||||
return r.prefix == "" || strings.HasPrefix(c.Name, r.prefix+"/")
|
||||
}
|
||||
slices.SortStableFunc(out, func(a, b provider.Container) int {
|
||||
if ua, ub := under(a), under(b); ua != ub {
|
||||
if ua {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
if t != provider.TypeSecret {
|
||||
return nil, nil
|
||||
}
|
||||
b, err := os.ReadFile(c.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, isSops := readMeta(c.ID)
|
||||
keys, err := parseKeys(c.ID, b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", c.Name, err)
|
||||
}
|
||||
if !isSops { // a plain env file
|
||||
out := make([]provider.Object, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, provider.Object{
|
||||
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name,
|
||||
Attrs: []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
out := make([]provider.Object, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
o := provider.Object{
|
||||
Container: c, Type: provider.TypeSecret,
|
||||
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified,
|
||||
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
|
||||
}
|
||||
if !k.encrypted {
|
||||
o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Describe(_ context.Context, o provider.Object) (provider.Object, error) {
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object, error) {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
|
||||
// Value decrypts one key with sops.
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
file, extract, ok := strings.Cut(o.ID, "\x00")
|
||||
if !ok { // made from a link: the repo's root, the file's path, the key
|
||||
file, extract = o.Container.ID, extractFor(o.Name)
|
||||
if file == "" {
|
||||
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
|
||||
}
|
||||
}
|
||||
if _, isSops := readMeta(file); !isSops {
|
||||
return plainValue(file, o.Name)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
|
||||
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return provider.SecretValue{}, errors.New("sops isn't installed: decrypting needs it (brew install sops)")
|
||||
}
|
||||
return provider.SecretValue{}, errors.New(sopsError(stderr.String(), err))
|
||||
}
|
||||
return provider.NewSecretValue(stdout.Bytes()), nil
|
||||
}
|
||||
|
||||
// sopsError is sops' own message, whole: which keys it tried and why
|
||||
// each failed can all matter.
|
||||
func sopsError(stderr string, err error) string {
|
||||
msg := strings.TrimSpace(stderr)
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return "sops couldn't decrypt it:\n" + msg
|
||||
}
|
||||
|
||||
// Locate finds the repo a link names, by its remote (or root path).
|
||||
func (p *Provider) Locate(_ context.Context, host string) (string, bool, error) {
|
||||
p.load()
|
||||
for _, r := range p.repos {
|
||||
if strings.EqualFold(r.id, host) {
|
||||
return r.root, true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// --- git ---
|
||||
|
||||
func gitRoot(dir string) (string, error) {
|
||||
if dir == "" {
|
||||
return "", errors.New("no directory")
|
||||
}
|
||||
out, err := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func tracked(ctx context.Context, root string) ([]string, error) {
|
||||
out, err := exec.CommandContext(ctx, "git", "-C", root, "ls-files", "-z").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git ls-files in %s: %w", root, err)
|
||||
}
|
||||
var files []string
|
||||
for _, f := range strings.Split(string(out), "\x00") {
|
||||
if f != "" {
|
||||
files = append(files, f)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// remoteID turns the origin remote into an ID ("git.kebcloud.no/keb/repo")
|
||||
// and a web URL, both empty without a remote.
|
||||
func remoteID(root string) (string, string) {
|
||||
// From .git/config directly: a git process per repo is most of what
|
||||
// listing dozens of them costs. git answers for worktrees and
|
||||
// submodules (.git is a file) and remotes it rewrites (insteadOf).
|
||||
if url, ok := originURL(filepath.Join(root, ".git", "config")); ok {
|
||||
if url == "" {
|
||||
return "", ""
|
||||
}
|
||||
return normalizeRemote(url)
|
||||
}
|
||||
out, err := exec.Command("git", "-C", root, "remote", "get-url", "origin").Output()
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return normalizeRemote(strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// originURL reads [remote "origin"] url from a git config file. It isn't
|
||||
// ok when the file is missing or uses insteadOf, which only git resolves.
|
||||
func originURL(config string) (string, bool) {
|
||||
b, err := os.ReadFile(config)
|
||||
if err != nil || bytes.Contains(b, []byte("insteadOf")) || bytes.Contains(b, []byte("insteadof")) {
|
||||
return "", false
|
||||
}
|
||||
inOrigin := false
|
||||
for line := range strings.SplitSeq(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "[") {
|
||||
inOrigin = line == `[remote "origin"]`
|
||||
continue
|
||||
}
|
||||
if k, v, ok := strings.Cut(line, "="); ok && inOrigin && strings.TrimSpace(k) == "url" {
|
||||
return strings.TrimSpace(v), true
|
||||
}
|
||||
}
|
||||
return "", true // no origin
|
||||
}
|
||||
|
||||
// normalizeRemote handles https://host/path(.git), ssh://user@host[:port]/path
|
||||
// and scp-style user@host:path.
|
||||
func normalizeRemote(remote string) (string, string) {
|
||||
u := strings.TrimSuffix(remote, ".git")
|
||||
scheme := false
|
||||
for _, s := range []string{"https://", "http://", "ssh://", "git://"} {
|
||||
if rest, ok := strings.CutPrefix(u, s); ok {
|
||||
u, scheme = rest, true
|
||||
}
|
||||
}
|
||||
host, rest, _ := strings.Cut(u, "/")
|
||||
if !scheme {
|
||||
// scp-style: user@host:path
|
||||
if h, r, ok := strings.Cut(u, ":"); ok {
|
||||
host, rest = h, r
|
||||
}
|
||||
}
|
||||
if at := strings.LastIndex(host, "@"); at >= 0 {
|
||||
host = host[at+1:]
|
||||
}
|
||||
host, _, _ = strings.Cut(host, ":") // a port
|
||||
if host == "" || rest == "" {
|
||||
return "", ""
|
||||
}
|
||||
id := strings.ToLower(host + "/" + strings.Trim(rest, "/"))
|
||||
if strings.Contains(host, ".") {
|
||||
return id, "https://" + host + "/" + strings.Trim(rest, "/")
|
||||
}
|
||||
return id, ""
|
||||
}
|
||||
|
||||
// --- which files ---
|
||||
|
||||
// lazysecretsFile is a repo's own .lazysecrets.yaml.
|
||||
type lazysecretsFile struct {
|
||||
Include []string `yaml:"include"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
}
|
||||
|
||||
// selector says which files to look at: user (the include/exclude of the
|
||||
// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere
|
||||
// (.sops.yaml's creation_rules, when there's no include) for SOPS files.
|
||||
func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
|
||||
include, exclude := p.s.Include, p.s.Exclude
|
||||
if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil {
|
||||
var f lazysecretsFile
|
||||
if err := yaml.Unmarshal(b, &f); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
|
||||
}
|
||||
if len(f.Include) > 0 || len(f.Exclude) > 0 {
|
||||
include, exclude = f.Include, f.Exclude
|
||||
}
|
||||
}
|
||||
inc, err := globs(include)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
exc, err := globs(exclude)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var rules []sopsRule
|
||||
if len(inc) == 0 {
|
||||
rules = sopsRules(root, files)
|
||||
}
|
||||
user = func(f string) bool {
|
||||
return !anyMatch(exc, f) && (len(inc) == 0 || anyMatch(inc, f))
|
||||
}
|
||||
sopsWhere = func(f string) bool {
|
||||
return len(rules) == 0 || slices.ContainsFunc(rules, func(r sopsRule) bool { return r.matches(f) })
|
||||
}
|
||||
return user, sopsWhere, nil
|
||||
}
|
||||
|
||||
// envFiles lists env files by name (.env, .env.*, *.env), tracked or
|
||||
// not, ignored ones included, leaving out examples and dependencies.
|
||||
// tracked is the repo's tracked files. Untracked ones are looked for in
|
||||
// the folders that have tracked files, not by walking the whole tree: an
|
||||
// ignored .env sits next to the code it's for, and the walk would go
|
||||
// through node_modules and the like, which is most of the time a scan
|
||||
// takes.
|
||||
func envFiles(root string, tracked []string) []string {
|
||||
var out []string
|
||||
isTracked := make(map[string]bool, len(tracked))
|
||||
dirs := map[string]bool{".": true}
|
||||
for _, f := range tracked {
|
||||
isTracked[f] = true
|
||||
if isEnv(f) {
|
||||
out = append(out, f)
|
||||
}
|
||||
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
|
||||
dirs[d] = true
|
||||
}
|
||||
}
|
||||
for d := range dirs {
|
||||
entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(d)))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
f := path.Join(d, e.Name())
|
||||
if e.Type().IsRegular() && !isTracked[f] && isEnv(f) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// packages are folders of dependencies, caches and build output: never
|
||||
// looked in for secret files.
|
||||
var packages = map[string]bool{
|
||||
"node_modules": true, "bower_components": true, "jspm_packages": true,
|
||||
"vendor": true, "Pods": true, "Carthage": true,
|
||||
".venv": true, "venv": true, "__pycache__": true, ".tox": true, ".nox": true,
|
||||
".mypy_cache": true, ".pytest_cache": true, ".ruff_cache": true,
|
||||
".terraform": true, ".terragrunt-cache": true,
|
||||
".gradle": true, ".m2": true, ".yarn": true, ".pnpm-store": true,
|
||||
".npm": true, ".cache": true, ".next": true, ".nuxt": true, ".svelte-kit": true,
|
||||
".git": true,
|
||||
}
|
||||
|
||||
// inPackages reports whether f is inside one of the packages folders.
|
||||
func inPackages(f string) bool {
|
||||
for d := range strings.SplitSeq(path.Dir(f), "/") {
|
||||
if packages[d] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isEnv reports whether a file is named like an env file with real values:
|
||||
// .env, .env.local, prod.env; not .env.example and the like, nor one in
|
||||
// dependencies.
|
||||
func isEnv(f string) bool {
|
||||
if inPackages(f) {
|
||||
return false
|
||||
}
|
||||
base := strings.ToLower(path.Base(f))
|
||||
for _, ext := range []string{".example", ".sample", ".template", ".dist", ".tmpl"} {
|
||||
if strings.HasSuffix(base, ext) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env")
|
||||
}
|
||||
|
||||
// plainValue reads a key from a plain env file.
|
||||
func plainValue(file, name string) (provider.SecretValue, error) {
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if k, v, ok := envLine(line); ok && k == name {
|
||||
return provider.NewSecretValue([]byte(v)), nil
|
||||
}
|
||||
}
|
||||
return provider.SecretValue{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
|
||||
}
|
||||
|
||||
// envLine reads KEY=value (or export KEY=value), unquoting the value and
|
||||
// dropping a comment after an unquoted one.
|
||||
func envLine(line string) (string, string, bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
return "", "", false
|
||||
}
|
||||
line = strings.TrimPrefix(line, "export ")
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') {
|
||||
if end := strings.LastIndexByte(v, v[0]); end > 0 {
|
||||
inner := v[1:end]
|
||||
if v[0] == '"' {
|
||||
inner = strings.NewReplacer(`\n`, "\n", `\"`, `"`, `\\`, `\`).Replace(inner)
|
||||
}
|
||||
return k, inner, true
|
||||
}
|
||||
}
|
||||
if i := strings.Index(v, " #"); i >= 0 {
|
||||
v = strings.TrimSpace(v[:i])
|
||||
}
|
||||
return k, v, true
|
||||
}
|
||||
|
||||
// sopsRule is a creation rule of a .sops.yaml, which applies to files
|
||||
// under the directory it's in.
|
||||
type sopsRule struct {
|
||||
dir string // relative to the repo root, "" for the root
|
||||
re *regexp.Regexp
|
||||
}
|
||||
|
||||
func (r sopsRule) matches(f string) bool {
|
||||
rel := f
|
||||
if r.dir != "" {
|
||||
var ok bool
|
||||
if rel, ok = strings.CutPrefix(f, r.dir+"/"); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return r.re == nil || r.re.MatchString(rel)
|
||||
}
|
||||
|
||||
func sopsRules(root string, files []string) []sopsRule {
|
||||
var rules []sopsRule
|
||||
for _, f := range files {
|
||||
if path.Base(f) != ".sops.yaml" {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(root, f))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cfg struct {
|
||||
CreationRules []struct {
|
||||
PathRegex string `yaml:"path_regex"`
|
||||
} `yaml:"creation_rules"`
|
||||
}
|
||||
if yaml.Unmarshal(b, &cfg) != nil {
|
||||
continue
|
||||
}
|
||||
dir := path.Dir(f)
|
||||
if dir == "." {
|
||||
dir = ""
|
||||
}
|
||||
for _, cr := range cfg.CreationRules {
|
||||
r := sopsRule{dir: dir}
|
||||
if cr.PathRegex != "" {
|
||||
re, err := regexp.Compile(cr.PathRegex)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
r.re = re
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
// globs compiles globs: * and ? within a path segment, ** across them.
|
||||
func globs(patterns []string) ([]*regexp.Regexp, error) {
|
||||
var out []*regexp.Regexp
|
||||
for _, g := range patterns {
|
||||
var b strings.Builder
|
||||
b.WriteString("^")
|
||||
for i := 0; i < len(g); i++ {
|
||||
switch c := g[i]; {
|
||||
case strings.HasPrefix(g[i:], "**/"):
|
||||
b.WriteString("(.*/)?")
|
||||
i += 2
|
||||
case strings.HasPrefix(g[i:], "**"):
|
||||
b.WriteString(".*")
|
||||
i++
|
||||
case c == '*':
|
||||
b.WriteString("[^/]*")
|
||||
case c == '?':
|
||||
b.WriteString("[^/]")
|
||||
default:
|
||||
b.WriteString(regexp.QuoteMeta(string(c)))
|
||||
}
|
||||
}
|
||||
b.WriteString("$")
|
||||
re, err := regexp.Compile(b.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("glob %q: %w", g, err)
|
||||
}
|
||||
out = append(out, re)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func anyMatch(res []*regexp.Regexp, f string) bool {
|
||||
return slices.ContainsFunc(res, func(re *regexp.Regexp) bool { return re.MatchString(f) })
|
||||
}
|
||||
|
||||
// --- sops files ---
|
||||
|
||||
type sopsMeta struct {
|
||||
methods []string // age, azure_kv, …
|
||||
modified time.Time
|
||||
}
|
||||
|
||||
const maxFileSize = 4 << 20
|
||||
|
||||
// readMeta reads a file's sops metadata; ok is false if it isn't a sops
|
||||
// file of a supported format.
|
||||
func readMeta(file string) (sopsMeta, bool) {
|
||||
fi, err := os.Stat(file)
|
||||
if err != nil || fi.Size() > maxFileSize {
|
||||
return sopsMeta{}, false
|
||||
}
|
||||
if formatOf(file) == "" {
|
||||
return sopsMeta{}, false // not a format sops writes: skip reading
|
||||
}
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil || !bytes.Contains(b, []byte("sops")) {
|
||||
return sopsMeta{}, false // cheap: most files aren't sops, no parsing
|
||||
}
|
||||
var m sopsMeta
|
||||
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
|
||||
switch formatOf(file) {
|
||||
case "yaml", "json":
|
||||
var doc struct {
|
||||
Sops map[string]any `yaml:"sops"`
|
||||
}
|
||||
if yaml.Unmarshal(b, &doc) != nil || doc.Sops == nil || doc.Sops["mac"] == nil {
|
||||
return m, false
|
||||
}
|
||||
for _, k := range known {
|
||||
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
|
||||
m.methods = append(m.methods, k)
|
||||
}
|
||||
}
|
||||
if s, ok := doc.Sops["lastmodified"].(string); ok {
|
||||
m.modified, _ = time.Parse(time.RFC3339, s)
|
||||
}
|
||||
if t, ok := doc.Sops["lastmodified"].(time.Time); ok {
|
||||
m.modified = t
|
||||
}
|
||||
case "dotenv", "ini":
|
||||
text := string(b)
|
||||
if !strings.Contains(text, "sops_mac") && !strings.Contains(text, "sops_version") {
|
||||
return m, false
|
||||
}
|
||||
for _, k := range known {
|
||||
if strings.Contains(text, "sops_"+k+"__") {
|
||||
m.methods = append(m.methods, k)
|
||||
}
|
||||
}
|
||||
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
|
||||
line, _, _ := strings.Cut(text[i:], "\n")
|
||||
if _, v, ok := strings.Cut(line, "="); ok {
|
||||
m.modified, _ = time.Parse(time.RFC3339, strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return m, false
|
||||
}
|
||||
if len(m.methods) == 0 {
|
||||
m.methods = []string{"unknown keys"}
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
func formatOf(file string) string {
|
||||
switch strings.ToLower(filepath.Ext(file)) {
|
||||
case ".yaml", ".yml":
|
||||
return "yaml"
|
||||
case ".json":
|
||||
return "json"
|
||||
case ".env":
|
||||
return "dotenv"
|
||||
case ".ini":
|
||||
return "ini"
|
||||
}
|
||||
if strings.HasPrefix(filepath.Base(file), ".env") {
|
||||
return "dotenv"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type key struct {
|
||||
name string // db.password, list[0]
|
||||
extract string // sops --extract: ["db"]["password"]
|
||||
encrypted bool
|
||||
}
|
||||
|
||||
// parseKeys lists a sops file's keys without decrypting anything.
|
||||
func parseKeys(file string, b []byte) ([]key, error) {
|
||||
switch formatOf(file) {
|
||||
case "yaml", "json":
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal(b, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(doc.Content) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var out []key
|
||||
walk(doc.Content[0], "", "", true, &out)
|
||||
return out, nil
|
||||
case "dotenv":
|
||||
var out []key
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
k, v, ok := envLine(line)
|
||||
if !ok || strings.HasPrefix(k, "sops_") {
|
||||
continue
|
||||
}
|
||||
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC[")})
|
||||
}
|
||||
return out, nil
|
||||
case "ini":
|
||||
var out []key
|
||||
section := ""
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";"):
|
||||
case strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]"):
|
||||
section = strings.TrimSpace(line[1 : len(line)-1])
|
||||
case section != "sops":
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC[")})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported format")
|
||||
}
|
||||
|
||||
// walk collects the leaves of a YAML/JSON tree, skipping sops' own block.
|
||||
func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
|
||||
switch n.Kind {
|
||||
case yaml.MappingNode:
|
||||
for i := 0; i+1 < len(n.Content); i += 2 {
|
||||
k := n.Content[i].Value
|
||||
if top && k == "sops" {
|
||||
continue
|
||||
}
|
||||
child := k
|
||||
if name != "" {
|
||||
child = name + "." + k
|
||||
}
|
||||
walk(n.Content[i+1], child, extract+quote(k), false, out)
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
for i, c := range n.Content {
|
||||
walk(c, fmt.Sprintf("%s[%d]", name, i), fmt.Sprintf("%s[%d]", extract, i), false, out)
|
||||
}
|
||||
case yaml.AliasNode:
|
||||
if n.Alias != nil {
|
||||
walk(n.Alias, name, extract, false, out)
|
||||
}
|
||||
default:
|
||||
*out = append(*out, key{name: name, extract: extract, encrypted: strings.HasPrefix(n.Value, "ENC[")})
|
||||
}
|
||||
}
|
||||
|
||||
// quote is a sops --extract path element.
|
||||
func quote(k string) string {
|
||||
return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]`
|
||||
}
|
||||
|
||||
// extractFor rebuilds the extract path from a key name, for objects made
|
||||
// from a link (db.password, list[0]). Names with dots in keys can't be
|
||||
// told apart from nesting: those come from the listing instead.
|
||||
func extractFor(name string) string {
|
||||
var b strings.Builder
|
||||
for _, part := range strings.Split(name, ".") {
|
||||
base, idx, _ := strings.Cut(part, "[")
|
||||
if base != "" {
|
||||
b.WriteString(quote(base))
|
||||
}
|
||||
if idx != "" {
|
||||
b.WriteString("[" + idx)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func absPath(p string) string {
|
||||
if a, err := filepath.Abs(p); err == nil {
|
||||
return a
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func expandHome(p string) string {
|
||||
if rest, ok := strings.CutPrefix(p, "~/"); ok {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, rest)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// A sops-shaped YAML file: listing reads its keys and metadata only.
|
||||
const sopsYAML = `db:
|
||||
password: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
|
||||
port: ENC[AES256_GCM,data:x,iv:x,tag:x,type:int]
|
||||
list:
|
||||
- ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
|
||||
public_unencrypted: hello
|
||||
sops:
|
||||
age:
|
||||
- recipient: age1xxxx
|
||||
enc: x
|
||||
lastmodified: "2026-10-09T10:05:29Z"
|
||||
mac: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
|
||||
version: 3.13.3
|
||||
`
|
||||
|
||||
const sopsEnv = "API_KEY=ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]\n" +
|
||||
"sops_age__list_0__map_recipient=age1xxxx\nsops_mac=ENC[x]\nsops_lastmodified=2026-10-09T10:05:29Z\nsops_version=3.13.3\n"
|
||||
|
||||
// gitRepo makes a git repository with files, all committed, and returns
|
||||
// its root.
|
||||
func gitRepo(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
root := t.TempDir()
|
||||
root, _ = filepath.EvalSymlinks(root) // macOS: /var is /private/var
|
||||
for name, body := range files {
|
||||
p := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"init", "-q"}, {"add", "."},
|
||||
{"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "files"},
|
||||
{"remote", "add", "origin", "git@git.example.com:team/infra.git"},
|
||||
} {
|
||||
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v %s", args, err, out)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func names[T any](items []T, name func(T) string) []string {
|
||||
var out []string
|
||||
for _, it := range items {
|
||||
out = append(out, name(it))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRepoFilesAndKeys(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
"secrets/app.yaml": sopsYAML,
|
||||
"svc/billing/prod.env": sopsEnv,
|
||||
"svc/billing/plain.yaml": "not: sops\n",
|
||||
"README.md": "# hi\n",
|
||||
})
|
||||
p := New(Settings{Dir: filepath.Join(root, "svc", "billing")})
|
||||
ctx := context.Background()
|
||||
|
||||
srcs, err := p.Sources(ctx)
|
||||
if err != nil || len(srcs) != 1 {
|
||||
t.Fatalf("sources %v, %v", srcs, err)
|
||||
}
|
||||
src := srcs[0]
|
||||
if src.Name != filepath.Base(root) || src.Detail != "git.example.com/team/infra" || src.URL != "https://git.example.com/team/infra" {
|
||||
t.Errorf("source %+v", src)
|
||||
}
|
||||
|
||||
files, err := p.Containers(ctx, src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Only sops files; those under where it started first.
|
||||
if got := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(got, []string{"svc/billing/prod.env", "secrets/app.yaml"}) {
|
||||
t.Errorf("files %v", got)
|
||||
}
|
||||
if files[1].Detail != "sops · age" {
|
||||
t.Errorf("detail %q", files[1].Detail)
|
||||
}
|
||||
|
||||
keys, err := p.Objects(ctx, files[1], provider.TypeSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(keys, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"db.password", "db.port", "list[0]", "public_unencrypted"}) {
|
||||
t.Errorf("keys %v", got)
|
||||
}
|
||||
if keys[0].Updated.IsZero() {
|
||||
t.Error("lastmodified not read")
|
||||
}
|
||||
if _, extract, _ := strings.Cut(keys[2].ID, "\x00"); extract != `["list"][0]` {
|
||||
t.Errorf("extract %q", extract)
|
||||
}
|
||||
|
||||
env, _ := p.Objects(ctx, files[0], provider.TypeSecret)
|
||||
if got := names(env, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"API_KEY"}) {
|
||||
t.Errorf("env keys %v", got)
|
||||
}
|
||||
|
||||
if id, ok, _ := p.Locate(ctx, "git.example.com/team/infra"); !ok || id != root {
|
||||
t.Errorf("locate: %q %v", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhichFiles(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"secrets/a.yaml": sopsYAML,
|
||||
"other/b.yaml": sopsYAML,
|
||||
"other/testdata/c.yaml": sopsYAML,
|
||||
}
|
||||
list := func(root string, s Settings) []string {
|
||||
s.Dir = root
|
||||
p := New(s)
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
cs, err := p.Containers(context.Background(), srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(cs, func(c provider.Container) string { return c.Name })
|
||||
slices.Sort(got)
|
||||
return got
|
||||
}
|
||||
|
||||
// No rules: every tracked sops file.
|
||||
root := gitRepo(t, files)
|
||||
if got := list(root, Settings{}); len(got) != 3 {
|
||||
t.Errorf("whole repo: %v", got)
|
||||
}
|
||||
// The config's globs.
|
||||
if got := list(root, Settings{Include: []string{"**/*.yaml"}, Exclude: []string{"**/testdata/**"}}); !slices.Equal(got, []string{"other/b.yaml", "secrets/a.yaml"}) {
|
||||
t.Errorf("config globs: %v", got)
|
||||
}
|
||||
|
||||
// .sops.yaml says where sops files go.
|
||||
withRules := map[string]string{".sops.yaml": "creation_rules:\n - path_regex: secrets/.*\\.yaml$\n age: age1x\n"}
|
||||
for k, v := range files {
|
||||
withRules[k] = v
|
||||
}
|
||||
root = gitRepo(t, withRules)
|
||||
if got := list(root, Settings{}); !slices.Equal(got, []string{"secrets/a.yaml"}) {
|
||||
t.Errorf(".sops.yaml rules: %v", got)
|
||||
}
|
||||
|
||||
// The repo's own .lazysecrets.yaml wins over both.
|
||||
withRules[".lazysecrets.yaml"] = "include: [\"other/**\"]\nexclude: [\"**/testdata/**\"]\n"
|
||||
root = gitRepo(t, withRules)
|
||||
if got := list(root, Settings{Include: []string{"secrets/**"}}); !slices.Equal(got, []string{"other/b.yaml"}) {
|
||||
t.Errorf(".lazysecrets.yaml: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRemote(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"https://git.kebcloud.no/keb/nix-config.git": "git.kebcloud.no/keb/nix-config",
|
||||
"git@github.com:Team/Repo.git": "github.com/team/repo",
|
||||
"ssh://git@git.example.com:2222/team/infra": "git.example.com/team/infra",
|
||||
"https://user@dev.azure.com/org/proj/_git/x": "dev.azure.com/org/proj/_git/x",
|
||||
} {
|
||||
if got, _ := normalizeRemote(in); got != want {
|
||||
t.Errorf("%s: %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With sops and age installed: real encryption and decryption, with a
|
||||
// throwaway key.
|
||||
func TestDecrypt(t *testing.T) {
|
||||
for _, bin := range []string{"sops", "age-keygen"} {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
t.Skip("needs " + bin)
|
||||
}
|
||||
}
|
||||
dir := t.TempDir()
|
||||
keyFile := filepath.Join(dir, "key.txt")
|
||||
if out, err := exec.Command("age-keygen", "-o", keyFile).CombinedOutput(); err != nil {
|
||||
t.Fatalf("age-keygen: %v %s", err, out)
|
||||
}
|
||||
b, _ := os.ReadFile(keyFile)
|
||||
var recipient string
|
||||
for _, f := range strings.Fields(string(b)) {
|
||||
if strings.HasPrefix(f, "age1") {
|
||||
recipient = f
|
||||
}
|
||||
}
|
||||
plain := filepath.Join(dir, "plain.yaml")
|
||||
os.WriteFile(plain, []byte("db:\n password: hunter2\nlist:\n - first\n"), 0o600)
|
||||
enc, err := exec.Command("sops", "encrypt", "--age", recipient, plain).Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := gitRepo(t, map[string]string{"secrets.yaml": string(enc)})
|
||||
t.Setenv("SOPS_AGE_KEY_FILE", keyFile)
|
||||
|
||||
p := New(Settings{Dir: root})
|
||||
ctx := context.Background()
|
||||
srcs, _ := p.Sources(ctx)
|
||||
cs, _ := p.Containers(ctx, srcs[0])
|
||||
keys, _ := p.Objects(ctx, cs[0], provider.TypeSecret)
|
||||
for _, k := range keys {
|
||||
v, err := p.Value(ctx, k)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", k.Name, err)
|
||||
}
|
||||
want := map[string]string{"db.password": "hunter2", "list[0]": "first"}[k.Name]
|
||||
if v.Reveal() != want {
|
||||
t.Errorf("%s = %q, want %q", k.Name, v.Reveal(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// From a link, without the listing: the extract path from the name.
|
||||
v, err := p.Value(ctx, provider.Object{Container: cs[0], Name: "list[0]"})
|
||||
if err != nil || v.Reveal() != "first" {
|
||||
t.Errorf("from a link: %q %v", v.Reveal(), err)
|
||||
}
|
||||
|
||||
// Without the key: sops' own error.
|
||||
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
|
||||
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
|
||||
t.Errorf("no key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoWithoutSecretsIsntListed(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{"README.md": "# no secrets\n", "config.yaml": "plain: yaml\n"})
|
||||
srcs, err := New(Settings{Dir: root}).Sources(context.Background())
|
||||
if err != nil || len(srcs) != 0 {
|
||||
t.Errorf("sources %v, %v", srcs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainEnvFiles(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
".gitignore": ".env\n.env.local\n",
|
||||
".env.example": "TOKEN=changeme\n",
|
||||
"secrets/app.yaml": sopsYAML,
|
||||
})
|
||||
// Ignored, as they usually are: written after the commit.
|
||||
os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nexport TOKEN=\"s3cr3t value\"\nURL=https://x.example # the API\nQUOTED='a#b'\n"), 0o600)
|
||||
os.MkdirAll(filepath.Join(root, "node_modules", "pkg"), 0o755)
|
||||
os.WriteFile(filepath.Join(root, "node_modules", "pkg", ".env"), []byte("X=1\n"), 0o600)
|
||||
|
||||
p := New(Settings{Dir: root})
|
||||
ctx := context.Background()
|
||||
srcs, _ := p.Sources(ctx)
|
||||
files, err := p.Containers(ctx, srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, c := range files {
|
||||
got[c.Name] = c.Detail
|
||||
}
|
||||
if len(got) != 2 || got[".env"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
|
||||
t.Fatalf("files %v (want .env and the sops file; not the example or node_modules)", got)
|
||||
}
|
||||
|
||||
var env provider.Container
|
||||
for _, c := range files {
|
||||
if c.Name == ".env" {
|
||||
env = c
|
||||
}
|
||||
}
|
||||
keys, _ := p.Objects(ctx, env, provider.TypeSecret)
|
||||
want := map[string]string{"TOKEN": "s3cr3t value", "URL": "https://x.example", "QUOTED": "a#b"}
|
||||
if len(keys) != len(want) {
|
||||
t.Fatalf("keys %v", names(keys, func(o provider.Object) string { return o.Name }))
|
||||
}
|
||||
for _, k := range keys {
|
||||
v, err := p.Value(ctx, k) // no sops involved
|
||||
if err != nil || v.Reveal() != want[k.Name] {
|
||||
t.Errorf("%s = %q, %v; want %q", k.Name, v.Reveal(), err, want[k.Name])
|
||||
}
|
||||
}
|
||||
|
||||
// .sops.yaml rules limit SOPS files, not plain env files.
|
||||
os.WriteFile(filepath.Join(root, ".sops.yaml"), []byte("creation_rules:\n - path_regex: nowhere/.*\n"), 0o644)
|
||||
exec.Command("git", "-C", root, "add", ".sops.yaml").Run()
|
||||
files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0])
|
||||
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env"}) {
|
||||
t.Errorf("with rules elsewhere: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoots(t *testing.T) {
|
||||
withSecrets := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
|
||||
without := gitRepo(t, map[string]string{"README.md": "# none\n"})
|
||||
nested := gitRepo(t, map[string]string{".env.example": "X=y\n", "prod.env": sopsEnv})
|
||||
|
||||
// A folder of repos: two at the top, one under an org folder.
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
os.Rename(withSecrets, filepath.Join(root, "infra"))
|
||||
os.Rename(without, filepath.Join(root, "docs"))
|
||||
os.MkdirAll(filepath.Join(root, "acme"), 0o755)
|
||||
os.Rename(nested, filepath.Join(root, "acme", "billing"))
|
||||
|
||||
outside := t.TempDir() // not in a repo
|
||||
srcs, err := New(Settings{Dir: outside, Roots: []string{root}}).Sources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(srcs, func(s provider.Source) string { return s.Name })
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, []string{"acme/billing", "infra"}) {
|
||||
t.Errorf("repos under the root: %v (want the two with secret files)", got)
|
||||
}
|
||||
|
||||
// Inside a repo: the same repos, that one first and marked Here.
|
||||
srcs, _ = New(Settings{Dir: filepath.Join(root, "acme", "billing"), Roots: []string{root}}).Sources(context.Background())
|
||||
got = names(srcs, func(s provider.Source) string { return s.Name })
|
||||
if !slices.Equal(got, []string{"billing", "infra"}) || !srcs[0].Here || srcs[1].Here {
|
||||
t.Errorf("inside a repo: %v, want billing (here) then infra", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarredReposAlongside(t *testing.T) {
|
||||
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
|
||||
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
|
||||
other := gitRepo(t, map[string]string{"secrets/b.yaml": sopsYAML})
|
||||
_ = other
|
||||
|
||||
srcs, err := New(Settings{Dir: here, Starred: []string{starred, here}}).Sources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(srcs, func(s provider.Source) string { return s.ID })
|
||||
if !slices.Equal(got, []string{here, starred}) {
|
||||
t.Errorf("sources %v, want this repo then the starred one (not under a root)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// sops' output when no key opens the file, as sops 3.13 prints it.
|
||||
const noKeyStderr = `Failed to get the data key required to decrypt the SOPS file.
|
||||
|
||||
Group 0: FAILED
|
||||
age1el8zh94re2488t5rrpqxk5xcxhs2cg6xfcccntz6l4ggv0rsf9yq44c0cs: FAILED
|
||||
- | failed to create reader for decrypting sops data key with
|
||||
| age: no identity matched any of the recipients. Did not find
|
||||
| keys in locations 'SOPS_AGE_SSH_PRIVATE_KEY_FILE'.
|
||||
https://kv-app.vault.azure.net/keys/sops/abc: FAILED
|
||||
- | failed to decrypt sops data key with Azure Key Vault key
|
||||
| 'https://kv-app.vault.azure.net/keys/sops/abc': DefaultAzureCredential: failed to acquire a token.
|
||||
|
||||
Recovery failed because no master key was able to decrypt the file. In
|
||||
order for SOPS to recover the file, at least one key has to be successful,
|
||||
but none were.
|
||||
`
|
||||
|
||||
func TestSopsError(t *testing.T) {
|
||||
got := sopsError(noKeyStderr, errors.New("exit status 128"))
|
||||
if got != "sops couldn't decrypt it:\n"+strings.TrimSpace(noKeyStderr) {
|
||||
t.Errorf("not sops' whole message:\n%s", got)
|
||||
}
|
||||
if got := sopsError("", errors.New("exit status 1")); got != "sops couldn't decrypt it:\nexit status 1" {
|
||||
t.Errorf("without output: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(s string) string {
|
||||
p := filepath.Join(dir, "config")
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
return p
|
||||
}
|
||||
for _, c := range []struct {
|
||||
config, url string
|
||||
ok bool
|
||||
}{
|
||||
{"[core]\n\tbare = false\n[remote \"origin\"]\n\turl = git@git.example.com:team/infra.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n", "git@git.example.com:team/infra.git", true},
|
||||
{"[remote \"upstream\"]\n\turl = https://a/b\n[remote \"origin\"]\n\turl=https://git.example.com/x.git\n", "https://git.example.com/x.git", true},
|
||||
{"[core]\n\tbare = false\n", "", true}, // no origin
|
||||
{"[url \"git@host:\"]\n\tinsteadOf = https://host/\n[remote \"origin\"]\n\turl = https://host/x\n", "", false},
|
||||
} {
|
||||
url, ok := originURL(write(c.config))
|
||||
if url != c.url || ok != c.ok {
|
||||
t.Errorf("%q: %q %v, want %q %v", c.config, url, ok, c.url, c.ok)
|
||||
}
|
||||
}
|
||||
if _, ok := originURL(filepath.Join(dir, "missing")); ok {
|
||||
t.Error("a missing config should leave it to git")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvFilesSkipPackages(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
"app/main.go": "package main\n",
|
||||
"vendor/lib/x.go": "package lib\n",
|
||||
"web/node_modules/pkg/index.js": "x\n",
|
||||
"secrets/sops.yaml": sopsYAML,
|
||||
"web/node_modules/pkg/sops.yaml": sopsYAML,
|
||||
})
|
||||
// Untracked, as .env files usually are: next to tracked code, in a
|
||||
// package folder, and in a folder with nothing tracked.
|
||||
for f, body := range map[string]string{
|
||||
"app/.env": "A=1\n",
|
||||
"web/node_modules/pkg/.env": "B=2\n",
|
||||
"vendor/lib/.env": "C=3\n",
|
||||
"scratch/.env": "D=4\n",
|
||||
} {
|
||||
os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755)
|
||||
os.WriteFile(filepath.Join(root, f), []byte(body), 0o644)
|
||||
}
|
||||
p := New(Settings{Dir: root})
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
files, err := p.Containers(context.Background(), srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(files, func(c provider.Container) string { return c.Name })
|
||||
slices.Sort(got)
|
||||
if want := []string{"app/.env", "secrets/sops.yaml"}; !slices.Equal(got, want) {
|
||||
t.Errorf("files %v, want %v (nothing from node_modules or vendor)", got, want)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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/<subscription-id>
|
||||
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
|
||||
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
|
||||
// lazysecrets://repo/<repo>[/<file>[/<key>]]
|
||||
//
|
||||
// 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/<name> in a portal link
|
||||
portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`)
|
||||
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// Package store keeps what lazysecrets remembers between runs in one JSON
|
||||
// file in the XDG state directory: a record per subscription, vault or
|
||||
// secret the user has starred, annotated or used, keyed by its
|
||||
// lazysecrets:// link, and the layout. It never holds secret values.
|
||||
//
|
||||
// JSON rather than YAML (the config's format) because the app writes it,
|
||||
// and so an editor plugin can read it with a built-in parser
|
||||
// (vim.json.decode).
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Node is what's remembered about one subscription, vault or secret. Name,
|
||||
// Path, Source and Account are context, so lists of nodes (favourites,
|
||||
// recent) can show and jump to them before their subscription is loaded.
|
||||
type Node struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Path []string `json:"path,omitempty"` // parents' names: [source] or [source, vault]
|
||||
Source string `json:"source,omitempty"` // ID of the subscription or workspace profile
|
||||
Account string `json:"account,omitempty"` // provider ID that last read it
|
||||
|
||||
// StarredBy lists the accounts (provider IDs) the item is starred
|
||||
// for: a star is the item through that account. A note describes the
|
||||
// item itself, whichever account shows it.
|
||||
StarredBy []string `json:"starredBy,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
|
||||
LastUsed time.Time `json:"lastUsed,omitzero"`
|
||||
Uses int `json:"uses,omitempty"`
|
||||
}
|
||||
|
||||
// keep reports whether the record holds anything worth keeping; context
|
||||
// alone isn't.
|
||||
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
|
||||
|
||||
// StarredFor reports whether the item is starred for account.
|
||||
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
|
||||
|
||||
// ToggleStar stars or unstars the item for account, reporting whether
|
||||
// it's starred now.
|
||||
func (n *Node) ToggleStar(account string) bool {
|
||||
if i := slices.Index(n.StarredBy, account); i >= 0 {
|
||||
n.StarredBy = slices.Delete(n.StarredBy, i, i+1)
|
||||
return false
|
||||
}
|
||||
n.StarredBy = append(n.StarredBy, account)
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateCheck is the last check for a newer release.
|
||||
func (s *Store) UpdateCheck() UpdateCheck {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.Update
|
||||
}
|
||||
|
||||
// SetUpdateCheck records a check for a newer release.
|
||||
func (s *Store) SetUpdateCheck(c UpdateCheck) error {
|
||||
return s.save(func(d *file) { d.Update = c })
|
||||
}
|
||||
|
||||
// Layout is the user's pane sizing, as offsets from the default layout so
|
||||
// it adapts to the terminal size.
|
||||
type Layout struct {
|
||||
Split int `json:"split,omitempty"` // columns the divider moved
|
||||
Heights [3]int `json:"heights,omitzero"` // rows added to each left pane
|
||||
}
|
||||
|
||||
type file struct {
|
||||
Version int `json:"version"`
|
||||
Nodes map[string]Node `json:"nodes"`
|
||||
Layout Layout `json:"layout,omitzero"`
|
||||
// Flags are one-off choices, like "don't ask again".
|
||||
Flags map[string]bool `json:"flags,omitempty"`
|
||||
// Update is the last check for a newer release.
|
||||
Update UpdateCheck `json:"update,omitzero"`
|
||||
}
|
||||
|
||||
// UpdateCheck is the last look for a newer release: when, and what the
|
||||
// latest was (its tag, changelog and page), so the next start within a
|
||||
// day shows it without asking again.
|
||||
type UpdateCheck struct {
|
||||
At time.Time `json:"at,omitzero"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
const version = 1
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
data file
|
||||
}
|
||||
|
||||
// Load reads path; a missing file is an empty store. path "" gives a store
|
||||
// that never saves, for tests and --demo.
|
||||
func Load(path string) (*Store, error) {
|
||||
s := &Store{path: path}
|
||||
d, err := read(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.data = d
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func read(path string) (file, error) {
|
||||
d := file{Version: version, Nodes: map[string]Node{}}
|
||||
if path == "" {
|
||||
return d, nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return d, nil
|
||||
}
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &d); err != nil {
|
||||
return d, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if d.Version > version {
|
||||
return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version)
|
||||
}
|
||||
if d.Nodes == nil {
|
||||
d.Nodes = map[string]Node{}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Node returns the record for key, empty if there is none.
|
||||
func (s *Store) Node(key string) Node {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.Nodes[key]
|
||||
}
|
||||
|
||||
// Has reports whether there's a record for key.
|
||||
func (s *Store) Has(key string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, ok := s.data.Nodes[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Nodes returns a copy of all records.
|
||||
func (s *Store) Nodes() map[string]Node {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return maps.Clone(s.data.Nodes)
|
||||
}
|
||||
|
||||
// Update changes the record for key and saves. Another lazysecrets may
|
||||
// have saved in the meantime, so the file is read again first and only
|
||||
// this record is changed in it. A record left with nothing worth keeping
|
||||
// is removed.
|
||||
func (s *Store) Update(key string, change func(*Node)) error {
|
||||
return s.save(func(d *file) {
|
||||
n := d.Nodes[key]
|
||||
change(&n)
|
||||
if n.keep() {
|
||||
d.Nodes[key] = n
|
||||
} else {
|
||||
delete(d.Nodes, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Usage records — ones kept only for their use — are pruned when they
|
||||
// go unused this long, and beyond this many, oldest first.
|
||||
const (
|
||||
usageMaxAge = 90 * 24 * time.Hour
|
||||
usageMax = 100
|
||||
)
|
||||
|
||||
// Use counts a use of key at the given time, with change updating its
|
||||
// context, and prunes old usage records.
|
||||
func (s *Store) Use(key string, at time.Time, change func(*Node)) error {
|
||||
return s.save(func(d *file) {
|
||||
n := d.Nodes[key]
|
||||
change(&n)
|
||||
n.Uses++
|
||||
n.LastUsed = at
|
||||
d.Nodes[key] = n
|
||||
prune(d.Nodes, at)
|
||||
})
|
||||
}
|
||||
|
||||
// prune drops usage records unused since usageMaxAge before now, and the
|
||||
// oldest beyond usageMax. Starred and noted records stay.
|
||||
func prune(nodes map[string]Node, now time.Time) {
|
||||
var usage []string
|
||||
for k, n := range nodes {
|
||||
if len(n.StarredBy) > 0 || n.Note != "" {
|
||||
continue
|
||||
}
|
||||
if now.Sub(n.LastUsed) > usageMaxAge {
|
||||
delete(nodes, k)
|
||||
continue
|
||||
}
|
||||
usage = append(usage, k)
|
||||
}
|
||||
if len(usage) <= usageMax {
|
||||
return
|
||||
}
|
||||
slices.SortFunc(usage, func(a, b string) int { return nodes[b].LastUsed.Compare(nodes[a].LastUsed) })
|
||||
for _, k := range usage[usageMax:] {
|
||||
delete(nodes, k)
|
||||
}
|
||||
}
|
||||
|
||||
// Flag reports a one-off choice.
|
||||
func (s *Store) Flag(name string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.Flags[name]
|
||||
}
|
||||
|
||||
// SetFlag records a one-off choice.
|
||||
func (s *Store) SetFlag(name string, on bool) error {
|
||||
return s.save(func(d *file) {
|
||||
if d.Flags == nil {
|
||||
d.Flags = map[string]bool{}
|
||||
}
|
||||
if on {
|
||||
d.Flags[name] = true
|
||||
} else {
|
||||
delete(d.Flags, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) Layout() Layout {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.Layout
|
||||
}
|
||||
|
||||
func (s *Store) SetLayout(l Layout) error {
|
||||
return s.save(func(d *file) { d.Layout = l })
|
||||
}
|
||||
|
||||
// save applies change to the file as it is now on disk (or to memory, for
|
||||
// an unsaved store) and writes it atomically, readable by the user only.
|
||||
func (s *Store) save(change func(*file)) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
d := s.data
|
||||
if s.path != "" {
|
||||
var err error
|
||||
if d, err = read(s.path); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
d.Nodes = maps.Clone(d.Nodes)
|
||||
}
|
||||
change(&d)
|
||||
d.Version = version
|
||||
if s.path != "" {
|
||||
b, err := json.MarshalIndent(d, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(s.path, append(b, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.data = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAtomic(path string, b []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // no-op after a successful rename
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// cacheTTL is how long cached metadata is shown without reloading. Older
|
||||
// entries are still shown immediately, then reloaded in the background.
|
||||
const cacheTTL = 2 * time.Minute
|
||||
|
||||
// cache holds metadata for this session only. It never holds secret values
|
||||
// or errors, and is never written to disk.
|
||||
type cache struct {
|
||||
containers map[string]entry[[]provider.Container] // by source key
|
||||
objects map[string]entry[[]provider.Object] // by container key (ID|type)
|
||||
details map[string]entry[provider.Object] // Describe results by ID@version
|
||||
// cursor remembers the selected child per parent key, so returning to
|
||||
// a subscription or vault puts the cursor back where it was.
|
||||
cursor map[string]string
|
||||
}
|
||||
|
||||
type entry[T any] struct {
|
||||
value T
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func newCache() cache {
|
||||
return cache{
|
||||
containers: map[string]entry[[]provider.Container]{},
|
||||
objects: map[string]entry[[]provider.Object]{},
|
||||
details: map[string]entry[provider.Object]{},
|
||||
cursor: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (e entry[T]) fresh(now time.Time) bool { return now.Sub(e.at) < cacheTTL }
|
||||
|
||||
// detailKey includes the account: the result carries its container, and
|
||||
// later requests for the object are routed by it.
|
||||
func detailKey(o provider.Object) string {
|
||||
return containerKey(o.Container) + "|" + o.ID + "@" + o.Version
|
||||
}
|
||||
|
||||
func (c cache) rememberCursor(parent, child string) {
|
||||
if parent != "" && child != "" {
|
||||
c.cursor[parent] = child
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
// Custom commands (customCommands in the config) run a shell command on
|
||||
// the selected item: by their key in the pane they're for, or from the
|
||||
// commands menu (x). Prompts ask for text first; commands that get the
|
||||
// secret value ask before running. The value reaches the command on its
|
||||
// input or in an environment variable, never on its command line.
|
||||
|
||||
// commandTimeout bounds commands whose output is captured; terminal ones
|
||||
// run as long as the user likes.
|
||||
const commandTimeout = 5 * time.Minute
|
||||
|
||||
type commandValueMsg struct {
|
||||
cmd commands.Command
|
||||
rendered string
|
||||
value provider.SecretValue
|
||||
err error
|
||||
}
|
||||
|
||||
type commandDoneMsg struct {
|
||||
cmd commands.Command
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
// paneContext is the custom command context of a pane.
|
||||
func paneContext(p pane) string {
|
||||
switch p {
|
||||
case paneSources:
|
||||
return commands.Sources
|
||||
case paneContainers:
|
||||
return commands.Vaults
|
||||
}
|
||||
return commands.Secrets
|
||||
}
|
||||
|
||||
// commandsHere are the custom commands for the focused pane.
|
||||
func (m *Model) commandsHere() []commands.Command {
|
||||
var out []commands.Command
|
||||
for _, c := range m.commands {
|
||||
if c.Context == commands.Global || c.Context == paneContext(m.focus) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// commandForKey is the custom command a key runs here, if any.
|
||||
func (m *Model) commandForKey(msg tea.KeyPressMsg) (commands.Command, bool) {
|
||||
for _, c := range m.commandsHere() {
|
||||
if c.Key != "" && c.Key == msg.String() {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return commands.Command{}, false
|
||||
}
|
||||
|
||||
// showCommands lists the custom commands for the focused pane.
|
||||
func (m *Model) showCommands() tea.Cmd {
|
||||
cmds := m.commandsHere()
|
||||
if len(cmds) == 0 {
|
||||
m.flash = warning("no custom commands for the " + strings.ToLower(paneNames[m.focus]) + " pane: see customCommands in the config")
|
||||
return nil
|
||||
}
|
||||
var opts []option
|
||||
for _, c := range cmds {
|
||||
label := c.Description
|
||||
if label == "" {
|
||||
label = c.Command
|
||||
}
|
||||
if c.Key != "" {
|
||||
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
|
||||
}
|
||||
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
|
||||
}
|
||||
return m.showFilterMenu("Commands", opts)
|
||||
}
|
||||
|
||||
// startCommand runs c on the selection: prompts, then confirmation, then
|
||||
// the command.
|
||||
func (m *Model) startCommand(c commands.Command) tea.Cmd {
|
||||
it, obj, ok := m.commandItem(c.Context)
|
||||
if !ok {
|
||||
m.flash = warning(map[string]string{
|
||||
commands.Sources: "select a subscription or workspace to run " + c.Description + " on",
|
||||
commands.Vaults: "select a vault or scope to run " + c.Description + " on",
|
||||
commands.Secrets: "select a secret to run " + c.Description + " on",
|
||||
}[c.Context])
|
||||
return nil
|
||||
}
|
||||
it.Prompt = map[string]commands.Q{}
|
||||
return m.askPrompt(c, it, obj, 0)
|
||||
}
|
||||
|
||||
// askPrompt asks c's prompts in turn, then confirms or runs it.
|
||||
func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Object, i int) tea.Cmd {
|
||||
if i == len(c.Prompts) {
|
||||
return m.confirmCommand(c, it, obj)
|
||||
}
|
||||
p := c.Prompts[i]
|
||||
in := newTextInput(p.Title+": ", 48)
|
||||
in.SetValue(p.Default)
|
||||
in.CursorEnd()
|
||||
m.prompt = &modal{
|
||||
title: cmpOr(c.Description, "Command"), input: &in,
|
||||
submit: func(m *Model, text string) tea.Cmd {
|
||||
it.Prompt[p.Name] = commands.Q(text)
|
||||
return m.askPrompt(c, it, obj, i+1)
|
||||
},
|
||||
footer: footer(hint(m.keys.PopupSelect, "next"), hint(m.keys.PopupClose, "cancel")),
|
||||
}
|
||||
return m.prompt.input.Focus()
|
||||
}
|
||||
|
||||
func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provider.Object) tea.Cmd {
|
||||
rendered, err := c.Render(it)
|
||||
if err != nil {
|
||||
m.flash = flash{text: c.Description + ": " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
if !c.Confirms() {
|
||||
return m.runCommand(c, rendered, obj)
|
||||
}
|
||||
var lines []string
|
||||
for _, l := range strings.Split(ansi.Wrap(rendered, 70, " "), "\n") {
|
||||
lines = append(lines, m.st.Muted.Render(l))
|
||||
}
|
||||
switch {
|
||||
case c.Value == "stdin":
|
||||
lines = append(lines, "", "It gets the value of "+string(it.Name)+" on its input.")
|
||||
case strings.HasPrefix(c.Value, "env:"):
|
||||
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
|
||||
}
|
||||
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{
|
||||
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCommand fetches the value if the command takes it, then runs it.
|
||||
func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Object) tea.Cmd {
|
||||
if c.Value == "" || obj == nil {
|
||||
return m.execCommand(c, rendered, provider.SecretValue{})
|
||||
}
|
||||
o := *obj
|
||||
p := m.providerFor(o.Container.Source)
|
||||
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
|
||||
timeout := m.timeout
|
||||
m.usedObject(o)
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
v, err := p.Value(ctx, o)
|
||||
return commandValueMsg{cmd: c, rendered: rendered, value: v, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// execCommand runs the command as its output mode says.
|
||||
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
|
||||
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh")
|
||||
setValue := func(cmd *exec.Cmd) {
|
||||
switch {
|
||||
case c.Value == "stdin":
|
||||
cmd.Stdin = strings.NewReader(value.Reveal())
|
||||
case strings.HasPrefix(c.Value, "env:"):
|
||||
cmd.Env = append(os.Environ(), strings.TrimPrefix(c.Value, "env:")+"="+value.Reveal())
|
||||
}
|
||||
}
|
||||
if c.Out() == commands.Terminal {
|
||||
// Pause before coming back, reading the terminal itself: the
|
||||
// command's input may be taken.
|
||||
script := rendered + "\nprintf '\\n%s' 'Press enter to return to lazysecrets'; read -r _ </dev/tty"
|
||||
cmd := exec.Command(shell, "-c", script)
|
||||
setValue(cmd)
|
||||
m.flash = flash{}
|
||||
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
|
||||
}
|
||||
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"}
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, shell, "-c", rendered)
|
||||
setValue(cmd)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return commandDoneMsg{cmd: c, out: string(out), err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
m.flash = flash{text: msg.cmd.Description + ": " + msg.err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
return m.execCommand(msg.cmd, msg.rendered, msg.value)
|
||||
}
|
||||
|
||||
// maxOutputLines caps what a popup shows of a command's output.
|
||||
const maxOutputLines = 30
|
||||
|
||||
func (m *Model) handleCommandDone(msg commandDoneMsg) {
|
||||
name := cmpOr(msg.cmd.Description, "command")
|
||||
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
|
||||
switch {
|
||||
case msg.err != nil:
|
||||
out := strings.TrimSpace(msg.out)
|
||||
if i := strings.LastIndexByte(out, '\n'); i >= 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// jump opens the favourites list, filters it and jumps to the first match.
|
||||
func (d *driver) jump(filter string) {
|
||||
d.t.Helper()
|
||||
d.keys("F")
|
||||
if d.m.search == nil || d.m.search.scope().id != scopeFavs {
|
||||
d.t.Fatalf("F didn't open Go to on Favourites:\n%s", d.screen())
|
||||
}
|
||||
for _, r := range filter {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.keys("enter")
|
||||
}
|
||||
|
||||
func TestStarVaultSortsFirstAndJumps(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("2", "j", "f") // star kv-shared-prod
|
||||
if items := d.m.containers.Items(); items[0].Name != "kv-shared-prod" {
|
||||
t.Errorf("starred vault not first: %s", items[0].Name)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "★ kv-shared-prod") {
|
||||
t.Errorf("no star:\n%s", d.screen())
|
||||
}
|
||||
if strings.Contains(d.screen(), "Favourites (") {
|
||||
t.Error("the old Favourites row is back")
|
||||
}
|
||||
|
||||
d.keys("1", "j") // elsewhere
|
||||
d.keys("F")
|
||||
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod") {
|
||||
t.Fatalf("favourites list:\n%s", d.screen())
|
||||
}
|
||||
d.keys("esc")
|
||||
d.jump("shared")
|
||||
if src, c, _ := d.selected(); src != "platform-prod" || c != "kv-shared-prod" {
|
||||
t.Errorf("jumped to %s/%s", src, c)
|
||||
}
|
||||
if d.m.focus != paneContainers {
|
||||
t.Errorf("focus %d after jumping to a vault", d.m.focus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJumpToStarredSourceAndSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "j", "f") // star api-key in platform-prod / kv-app-prod
|
||||
d.keys("1", "j", "f") // star platform-dev (it moves to the top of its group)
|
||||
d.keys("j", "j", "j") // down to Databricks prod
|
||||
|
||||
d.jump("platform-dev")
|
||||
if src, _, _ := d.selected(); src != "platform-dev" || d.m.focus != paneContainers {
|
||||
t.Errorf("source jump: %s, focus %d", src, d.m.focus)
|
||||
}
|
||||
d.jump("api")
|
||||
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
|
||||
t.Errorf("secret jump: %s/%s/%s", src, c, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavouritesListEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("F")
|
||||
if d.m.search == nil || !strings.Contains(d.screen(), "nothing starred yet") {
|
||||
t.Errorf("empty favourites:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarSecretSortsFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "j", "f") // star api-key
|
||||
if _, _, s := d.selected(); s != "api-key" {
|
||||
t.Errorf("cursor left the starred item: %q", s)
|
||||
}
|
||||
rows := d.m.secrets.Items()
|
||||
if rows[0].Name != "api-key" || !strings.Contains(d.screen(), "★ api-key") {
|
||||
t.Errorf("starred secret not first: %v\n%s", rows[0].Name, d.screen())
|
||||
}
|
||||
// Still first after leaving and coming back (cached list).
|
||||
d.keys("2", "j", "k")
|
||||
if d.m.secrets.Items()[0].Name != "api-key" {
|
||||
t.Error("starred secret not first after returning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoStarringVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("v", "f")
|
||||
if d.m.store.Node(objectKey(*d.m.versionsOf)).StarredFor("Azure") || !strings.Contains(d.screen(), "rather than a version") {
|
||||
t.Errorf("starring a version:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavouritesPersist(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
load := func() *store.Store {
|
||||
s, err := store.Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
d := newDriverOpts(t, Options{Store: load()})
|
||||
d.keys("2", "f", "3", "j", "f")
|
||||
|
||||
d2 := newDriverOpts(t, Options{Store: load()})
|
||||
d2.keys("F")
|
||||
if !strings.Contains(d2.screen(), "platform-prod / kv-app-prod") ||
|
||||
!strings.Contains(d2.screen(), "platform-prod / kv-app-prod / api-key") {
|
||||
t.Errorf("favourites not restored:\n%s", d2.screen())
|
||||
}
|
||||
d2.keys("esc")
|
||||
if d2.m.secrets.Items()[0].Name != "api-key" {
|
||||
t.Error("secret favourite not restored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarSourceMovesItToTheTop(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("j", "f") // star platform-dev
|
||||
if src, _, _ := d.selected(); src != "platform-dev" {
|
||||
t.Errorf("cursor left the starred source: %q", src)
|
||||
}
|
||||
rows := d.m.sources.Items()
|
||||
if rows[1].source.Name != "platform-dev" || !strings.Contains(d.screen(), "★ platform-dev") {
|
||||
t.Errorf("not first in its group: %q\n%s", rows[1].source.Name, d.screen())
|
||||
}
|
||||
|
||||
// Flat: starred sources come before every other provider's.
|
||||
// platform-dev now leads its group: platform-prod, header, prod, dev.
|
||||
d.keys("j", "j", "j", "j", "f", "`") // also star Databricks dev
|
||||
var flat []string
|
||||
for _, r := range d.m.sources.Items() {
|
||||
flat = append(flat, r.source.Name)
|
||||
}
|
||||
if strings.Join(flat[:2], ",") != "platform-dev,dev" {
|
||||
t.Errorf("flat order = %v", flat)
|
||||
}
|
||||
|
||||
d.keys("`", "k") // grouped again; dev leads its group, so k is the header
|
||||
d.keys("f")
|
||||
if !strings.Contains(d.screen(), "select a subscription or workspace to star") {
|
||||
t.Errorf("starring a header:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
// denying refuses to list objects, like an account without access.
|
||||
type denying struct{ *counting }
|
||||
|
||||
func (d denying) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) {
|
||||
d.objects++
|
||||
return nil, fake.ErrForbidden
|
||||
}
|
||||
|
||||
// kvKey is kv-app-prod's store key, kvSharedKey kv-shared-prod's.
|
||||
const (
|
||||
kvKey = "lazysecrets://azure/kv-app-prod.vault.azure.net"
|
||||
kvSharedKey = "lazysecrets://azure/kv-shared-prod.vault.azure.net"
|
||||
)
|
||||
|
||||
// starredWithoutAccount is kv-app-prod starred with no account recorded,
|
||||
// like one starred by an account that's no longer signed in.
|
||||
func starredWithoutAccount(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
s, _ := store.Load("")
|
||||
if err := s.Update(kvKey, func(n *store.Node) {
|
||||
n.Name, n.Path, n.Source, n.StarredBy = "kv-app-prod", []string{"platform-prod"}, "sub-prod", []string{"Azure:carol"}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestOldFavouriteFindsTheAccountWithAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
|
||||
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
|
||||
mem := starredWithoutAccount(t)
|
||||
d := newDriverOpts(t, Options{Store: mem}, denying{alice}, bob)
|
||||
|
||||
d.jump("kv-app") // both accounts see platform-prod: which one?
|
||||
if !strings.Contains(d.screen(), "Several accounts can see kv-app-prod") {
|
||||
t.Fatalf("no account choice:\n%s", d.screen())
|
||||
}
|
||||
d.keys("2") // bob
|
||||
if _, c, s := d.selected(); c != "kv-app-prod" || s != "db-password" {
|
||||
t.Fatalf("favourite not browsable: %q/%q\n%s", c, s, d.screen())
|
||||
}
|
||||
if got := mem.Node(kvKey).Account; got != "Azure:bob" {
|
||||
t.Errorf("favourite bound to %q, want Azure:bob", got)
|
||||
}
|
||||
d.keys("R") // used through bob now
|
||||
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod · bob") {
|
||||
t.Errorf("recent should show the account:\n%s", d.screen())
|
||||
}
|
||||
d.keys("esc")
|
||||
|
||||
// Reads go through bob, and alice isn't asked again.
|
||||
before := alice.objects
|
||||
d.keys("space")
|
||||
if bob.values != 1 || alice.values != 0 {
|
||||
t.Errorf("reveal: alice %d, bob %d", alice.values, bob.values)
|
||||
}
|
||||
d.keys("2", "r")
|
||||
if alice.objects != before {
|
||||
t.Errorf("alice asked again after binding: %d → %d", before, alice.objects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldFavouriteNoAccountHasAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
|
||||
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
|
||||
mem := starredWithoutAccount(t)
|
||||
d := newDriverOpts(t, Options{Store: mem}, denying{alice}, denying{bob})
|
||||
|
||||
d.jump("kv-app")
|
||||
d.keys("1") // alice: no access
|
||||
if !strings.Contains(d.screen(), "403 Forbidden") {
|
||||
t.Errorf("no access error:\n%s", d.screen())
|
||||
}
|
||||
if mem.Node(kvKey).Account != "" {
|
||||
t.Error("favourite bound although no account worked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavouritesPopupKeepsItsSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("j", "f", "2", "j", "f", "3", "j", "f") // a subscription, a vault, a secret
|
||||
d.keys("F")
|
||||
size := func() (int, int) {
|
||||
box := d.m.searchViewBox()
|
||||
return lipgloss.Width(box), lipgloss.Height(box)
|
||||
}
|
||||
w0, h0 := size()
|
||||
for _, k := range []string{"down", "down", "up"} {
|
||||
d.send(tea.KeyPressMsg{Code: map[string]rune{"down": tea.KeyDown, "up": tea.KeyUp}[k]})
|
||||
if w, h := size(); w != w0 || h != h0 {
|
||||
t.Fatalf("popup changed from %dx%d to %dx%d moving %s", w0, h0, w, h, k)
|
||||
}
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: 'z', Text: "z"}) // filter down to nothing
|
||||
if w, h := size(); w != w0 || h != h0 {
|
||||
t.Errorf("popup changed from %dx%d to %dx%d while filtering", w0, h0, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreKeysAreLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("f", "2", "f", "3", "j", "f")
|
||||
var keys []string
|
||||
for k := range d.m.store.Nodes() {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
want := []string{
|
||||
"lazysecrets://azure/kv-app-prod.vault.azure.net",
|
||||
"lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key",
|
||||
"lazysecrets://azure/subscriptions/sub-prod",
|
||||
}
|
||||
if !slices.Equal(keys, want) {
|
||||
t.Errorf("keys = %v", keys)
|
||||
}
|
||||
n := d.m.store.Node(want[1])
|
||||
if n.Name != "api-key" || n.Source != "sub-prod" || n.Account != "Azure" || !slices.Equal(n.Path, []string{"platform-prod", "kv-app-prod"}) {
|
||||
t.Errorf("context = %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavouritesShowNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("2", "j", "f", "n") // star kv-shared-prod and note it
|
||||
for _, r := range "shared certs" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.keys("enter", "F")
|
||||
line := ""
|
||||
for l := range strings.SplitSeq(d.screen(), "\n") {
|
||||
if strings.Contains(l, "kv-shared-prod") && strings.Contains(l, "│") && strings.Contains(l, "/") {
|
||||
line = l
|
||||
}
|
||||
}
|
||||
if !strings.Contains(line, "shared certs") {
|
||||
t.Fatalf("no note in the favourites row %q:\n%s", line, d.screen())
|
||||
}
|
||||
for _, r := range "certs" { // the filter matches notes
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
if len(d.m.search.shown) != 1 {
|
||||
t.Errorf("filtering by note: %d entries", len(d.m.search.shown))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretWithoutContextJumpsThroughItsVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
mem, _ := store.Load("")
|
||||
_ = mem.Update(kvKey, func(n *store.Node) {
|
||||
n.Name, n.Path, n.Source, n.Account, n.StarredBy = "kv-app-prod", []string{"platform-prod"}, "sub-prod", "Azure", []string{"Azure"}
|
||||
})
|
||||
_ = mem.Update(kvKey+"/secrets/api-key", func(n *store.Node) { n.StarredBy = []string{"Azure"} })
|
||||
// counting hides Locate, like an account that can't find the vault
|
||||
// in Resource Graph: opening it as a link would land in Opened links.
|
||||
d := newDriverOpts(t, Options{Store: mem}, &counting{Provider: fake.Azure(0)})
|
||||
d.keys("j") // elsewhere
|
||||
d.jump("api-key")
|
||||
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
|
||||
t.Errorf("jumped to %s/%s/%s", src, c, s)
|
||||
}
|
||||
if strings.Contains(d.screen(), "Opened links") {
|
||||
t.Errorf("went through Opened links:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkedVaultKeepsKnownContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
n := store.Node{Source: "sub-prod", Path: []string{"platform-prod", "kv-app-prod"}}
|
||||
// A vault opened from a link: account known, subscription not.
|
||||
withContext("api-key", []string{"kv-app-prod.vault.azure.net", "kv-app-prod"},
|
||||
provider.Source{Kind: provider.KindAzure, Provider: "Azure:bob"})(&n)
|
||||
if n.Source != "sub-prod" || n.Path[0] != "platform-prod" || n.Account != "Azure:bob" {
|
||||
t.Errorf("context = %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleStarInGoTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("2", "j", "f") // star kv-shared-prod
|
||||
d.keys("3", "j", "space", "2") // use smtp-password, unstarred
|
||||
smtp := kvSharedKey + "/secrets/smtp-password"
|
||||
ctrlF := tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}
|
||||
|
||||
d.keys("R", "/")
|
||||
d.typeText("smtp")
|
||||
d.send(ctrlF) // star it from Recent, typing
|
||||
if !d.m.store.Node(smtp).StarredFor("Azure") || !strings.Contains(d.screen(), "★ ") {
|
||||
t.Fatalf("ctrl+f didn't star a recent item:\n%s", d.screen())
|
||||
}
|
||||
d.send(ctrlF) // and back
|
||||
if d.m.store.Node(smtp).StarredFor("Azure") {
|
||||
t.Error("second ctrl+f didn't unstar")
|
||||
}
|
||||
if d.m.search == nil || d.m.search.input.Value() != "smtp" || len(d.m.search.shown) == 0 {
|
||||
t.Fatalf("entry or query gone after toggling:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// Unstarring a favourite keeps it listed until Go to opens again.
|
||||
d.keys("esc", "esc", "F") // stop typing, close, open on Favourites
|
||||
v := d.m.search
|
||||
for v.res < len(v.shown)-1 && v.shown[v.res].star != kvSharedKey {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
}
|
||||
if v.shown[v.res].star != kvSharedKey {
|
||||
t.Fatalf("selected %q", v.shown[v.res].star)
|
||||
}
|
||||
d.keys("f") // browsing: f
|
||||
if d.m.store.Node(kvSharedKey).StarredFor("Azure") {
|
||||
t.Error("favourite still starred")
|
||||
}
|
||||
if v.shown[v.res].star != kvSharedKey || v.shown[v.res].starred {
|
||||
t.Error("unstarred favourite didn't stay in place, unstarred")
|
||||
}
|
||||
d.keys("esc")
|
||||
if strings.Contains(d.screen(), "★ kv-shared-prod") {
|
||||
t.Errorf("Vaults pane still shows the star:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarWhileFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
for _, r := range "api" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||
if !d.m.store.Node(kvKey + "/secrets/api-key").StarredFor("Azure") {
|
||||
t.Errorf("ctrl+f while filtering didn't star:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarIsPerAccount(t *testing.T) {
|
||||
t.Parallel()
|
||||
alice := fake.Azure(0).As("Azure:alice", "Azure · alice")
|
||||
bob := fake.Azure(0).As("Azure:bob", "Azure · bob")
|
||||
d := newDriverOpts(t, Options{}, alice, bob)
|
||||
// Rows: alice header, platform-prod, platform-dev, bob header, ….
|
||||
d.keys("f") // alice's platform-prod
|
||||
n := d.m.store.Node("lazysecrets://azure/subscriptions/sub-prod")
|
||||
if !n.StarredFor("Azure:alice") || n.StarredFor("Azure:bob") {
|
||||
t.Errorf("starred by %v, want alice only", n.StarredBy)
|
||||
}
|
||||
if c := strings.Count(d.screen(), "★ platform-prod"); c != 1 {
|
||||
t.Errorf("star shown %d times, want once:\n%s", c, d.screen())
|
||||
}
|
||||
// A note is about the thing: it shows under both.
|
||||
d.keys("n")
|
||||
d.typeText("shared note")
|
||||
d.keys("enter")
|
||||
if n := strings.Count(d.screen(), "shared note"); n != 2 {
|
||||
t.Errorf("note shown %d times, want 2:\n%s", n, d.screen())
|
||||
}
|
||||
d.keys("F")
|
||||
var labels []string
|
||||
for _, o := range d.m.search.shown {
|
||||
labels = append(labels, ansiStrip(o.label))
|
||||
}
|
||||
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
|
||||
t.Errorf("favourites should list alice's star only: %q", labels)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
// The help bar shows the keys that matter in the focused pane; ? lists all
|
||||
// of them in a menu (showKeybindings).
|
||||
|
||||
// newHelp styles the help bar to read clearly: keys like labels elsewhere,
|
||||
// descriptions in the normal text colour, only the separators dimmed. The
|
||||
// bubbles defaults are grey on grey.
|
||||
func newHelp(st styles.Styles) help.Model {
|
||||
h := help.New()
|
||||
h.Styles.ShortKey = st.Label
|
||||
h.Styles.ShortDesc = lipgloss.NewStyle()
|
||||
h.Styles.ShortSeparator = st.Muted
|
||||
h.Styles.Ellipsis = st.Muted
|
||||
h.Styles.FullKey, h.Styles.FullDesc, h.Styles.FullSeparator = h.Styles.ShortKey, h.Styles.ShortDesc, h.Styles.ShortSeparator
|
||||
return h
|
||||
}
|
||||
|
||||
// contextHelp is the help bar for the focused pane.
|
||||
type contextHelp struct {
|
||||
k keys.Map
|
||||
focus pane
|
||||
commands bool // custom commands apply here
|
||||
}
|
||||
|
||||
func (c contextHelp) ShortHelp() []key.Binding {
|
||||
k := c.k
|
||||
var b []key.Binding
|
||||
if k.Login.Enabled() {
|
||||
b = append(b, k.Login)
|
||||
}
|
||||
switch c.focus {
|
||||
case paneSources:
|
||||
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.SourcesView, k.Filter)
|
||||
case paneContainers:
|
||||
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Note, k.Open, k.Filter)
|
||||
default:
|
||||
b = append(b, k.Reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Note, k.Filter)
|
||||
}
|
||||
if c.commands {
|
||||
b = append(b, k.Commands)
|
||||
}
|
||||
return append(b, k.OpenRef, k.Help, k.Quit)
|
||||
}
|
||||
|
||||
func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.ShortHelp()} }
|
||||
|
||||
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail"}
|
||||
|
||||
// showKeybindings opens the ? menu, like lazygit's: every binding with
|
||||
// what it does, the focused pane's first, filterable by key, description
|
||||
// and group. Enter runs the selected one.
|
||||
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
|
||||
|
||||
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with
|
||||
// what it does, filterable by key, description and group; enter runs the
|
||||
// selected one. Opened from the panes, it lists the focused pane's keys
|
||||
// ("") first, then the rest; from a popup (keys.GoTo, keys.Menu), only
|
||||
// the keys that work there, and it comes back to the popup when closed.
|
||||
func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
|
||||
// Where it was opened from, to come back to.
|
||||
search, prompt := m.search, m.prompt
|
||||
m.search = nil
|
||||
|
||||
actions := m.keys.Actions()
|
||||
id := func(a keys.Action) string { return a.Context + "." + a.Name }
|
||||
here := map[string]bool{}
|
||||
var local []keys.Action
|
||||
switch ctx {
|
||||
case "":
|
||||
for _, b := range (contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}).ShortHelp() {
|
||||
for _, a := range actions {
|
||||
if a.Context == keys.Lists && !here[id(a)] && slices.Equal(a.Binding.Keys(), b.Keys()) && a.Desc == b.Help().Desc {
|
||||
here[id(a)] = true
|
||||
local = append(local, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
for _, a := range actions {
|
||||
if a.Context == ctx {
|
||||
here[id(a)] = true
|
||||
local = append(local, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
var rest []keys.Action
|
||||
for _, a := range actions {
|
||||
if !here[id(a)] && a.Binding.Enabled() && !(a.Context == keys.Lists && a.Name == "help") {
|
||||
rest = append(rest, a)
|
||||
}
|
||||
}
|
||||
var opts []option
|
||||
section := func(title string, list []keys.Action) {
|
||||
opts = append(opts, option{label: title, header: true})
|
||||
for _, a := range list {
|
||||
h := a.Binding.Help()
|
||||
if len(a.Binding.Keys()) == 0 {
|
||||
continue // unbound
|
||||
}
|
||||
msg := keyPress(a.Binding.Keys()[0])
|
||||
// Go to's typing keys need Go to typing: start it first.
|
||||
typing := ctx == keys.GoTo && (a.Context == keys.Popup || a.Context == keys.Search)
|
||||
label := m.st.Label.Render(fmt.Sprintf("%-14s", h.Key)) + fmt.Sprintf("%-26s", h.Desc) + m.st.Muted.Render(a.Group)
|
||||
opts = append(opts, option{label: label, tip: a.Tip, run: func(m *Model) tea.Cmd {
|
||||
send := func() tea.Msg { return msg }
|
||||
if typing && m.search != nil && !m.search.typing {
|
||||
// Typing starts now; its command only blinks the cursor.
|
||||
return tea.Batch(m.setTyping(true), send)
|
||||
}
|
||||
return send
|
||||
}})
|
||||
}
|
||||
}
|
||||
title := map[string]string{"": paneNames[m.focus] + " pane", keys.GoTo: "Go to", keys.Menu: "This menu"}[ctx]
|
||||
section(title, local)
|
||||
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
|
||||
opts = append(opts, option{label: "Custom commands", header: true})
|
||||
for _, c := range cmds {
|
||||
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom")
|
||||
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
|
||||
}
|
||||
}
|
||||
switch ctx {
|
||||
case "":
|
||||
section("Global", rest)
|
||||
case keys.GoTo:
|
||||
// Only what works here: Go to's keys, browsing and typing.
|
||||
var typing []keys.Action
|
||||
for _, a := range actions {
|
||||
if a.Context == keys.Popup || a.Context == keys.Search {
|
||||
typing = append(typing, a)
|
||||
}
|
||||
}
|
||||
section("While typing, after "+keys.Name(m.keys.GoFilter), typing)
|
||||
}
|
||||
cmd := m.showFilterMenu("Keybindings", opts)
|
||||
m.prompt.footer = footer(move(m.keys.PopupUp, m.keys.PopupDown), hint(m.keys.PopupSelect, "run"), hint(m.keys.PopupClose, "close"))
|
||||
if ctx != "" {
|
||||
m.prompt.onClose = func(m *Model) { m.search, m.prompt = search, prompt }
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// versionLabel is the version as shown: "v0.1.0", or the build for
|
||||
// development builds ("dev-c1a4f90").
|
||||
func (m Model) versionLabel() string {
|
||||
if m.version == "" || strings.HasPrefix(m.version, "dev") || strings.HasPrefix(m.version, "v") {
|
||||
return m.version
|
||||
}
|
||||
return "v" + m.version
|
||||
}
|
||||
|
||||
// helpBar draws bindings as the help bar. When they don't fit, entries
|
||||
// before the last keep (like "? help • q quit") give way, marked "…",
|
||||
// so those always show.
|
||||
func (m Model) helpBar(bindings []key.Binding, keep int) string {
|
||||
var bs []key.Binding
|
||||
for _, b := range bindings {
|
||||
if b.Enabled() {
|
||||
bs = append(bs, b)
|
||||
}
|
||||
}
|
||||
h := m.help
|
||||
h.SetWidth(0) // measure untruncated
|
||||
limit := max(0, m.w-1)
|
||||
if v := m.versionLabel(); v != "" {
|
||||
limit -= ansi.StringWidth(v) + 2 // a gap, the version, the margin
|
||||
}
|
||||
if full := h.ShortHelpView(bs); ansi.StringWidth(full) <= limit {
|
||||
return full
|
||||
}
|
||||
keep = min(keep, len(bs))
|
||||
head, tail := bs[:len(bs)-keep], bs[len(bs)-keep:]
|
||||
sep := h.Styles.ShortSeparator.Render(h.ShortSeparator)
|
||||
more := h.Styles.Ellipsis.Render("…")
|
||||
for n := len(head); n >= 0; n-- {
|
||||
line := more + sep + h.ShortHelpView(tail)
|
||||
if n > 0 {
|
||||
line = h.ShortHelpView(head[:n]) + sep + line
|
||||
}
|
||||
if ansi.StringWidth(line) <= limit || n == 0 {
|
||||
return ansi.Truncate(line, limit, "…")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// popupKeys are the open popup's keys for the help bar: Go to's for its
|
||||
// mode and tab, a menu's, or a text field's.
|
||||
func (m Model) popupKeys() []key.Binding {
|
||||
km := m.keys
|
||||
describe := func(b key.Binding, desc string) key.Binding {
|
||||
b.SetHelp(b.Help().Key, desc)
|
||||
return b
|
||||
}
|
||||
if v := m.search; v != nil {
|
||||
inSearch := v.tab == tabSearch
|
||||
if v.typing {
|
||||
b := []key.Binding{km.PopupDown, km.PopupUp}
|
||||
if inSearch {
|
||||
b = append(b, km.SearchSources, km.SearchResults, km.SearchLoad, km.SearchLoadAll)
|
||||
}
|
||||
return append(b, km.PopupStar, km.PopupSelect, describe(km.PopupClose, "stop typing"))
|
||||
}
|
||||
b := []key.Binding{km.GoDown, km.GoUp, km.GoPrevTab, km.GoNextTab}
|
||||
if inSearch {
|
||||
b = append(b, km.GoSources, km.GoResults, km.GoLoad, km.GoLoadAll)
|
||||
}
|
||||
return append(b, km.GoFilter, km.GoStar, km.GoSelect, km.GoClose, km.Help)
|
||||
}
|
||||
switch p := m.prompt; {
|
||||
case p.input != nil:
|
||||
return []key.Binding{describe(km.PopupSelect, "confirm"), describe(km.PopupClose, "cancel")}
|
||||
case p.filter != nil:
|
||||
return []key.Binding{km.PopupDown, km.PopupUp, km.PopupSelect, km.PopupClose}
|
||||
}
|
||||
return []key.Binding{km.MenuDown, km.MenuUp, km.MenuSelect, km.MenuClose, km.Help}
|
||||
}
|
||||
|
||||
// A keyHint is a key and what it does, for footers and hints, read from
|
||||
// the bindings so they show what's bound.
|
||||
type keyHint struct{ key, desc string }
|
||||
|
||||
func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), desc} }
|
||||
|
||||
// move is "↑/↓ move" for an up and a down binding.
|
||||
func move(up, down key.Binding) keyHint {
|
||||
u, d := keys.Name(up), keys.Name(down)
|
||||
if u == "" || d == "" {
|
||||
return keyHint{u + d, "move"}
|
||||
}
|
||||
return keyHint{u + "/" + d, "move"}
|
||||
}
|
||||
|
||||
// withKey fills a hint's format with the binding's key ("· %s loads
|
||||
// everything"), or is empty when the action is unbound.
|
||||
func withKey(b key.Binding, format string) string {
|
||||
if k := keys.Name(b); k != "" {
|
||||
return fmt.Sprintf(format, k)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// joinKeys is "ctrl+h/ctrl+l" for two bindings, or whichever is bound.
|
||||
func joinKeys(a, b key.Binding) string {
|
||||
x, y := keys.Name(a), keys.Name(b)
|
||||
if x != "" && y != "" {
|
||||
return x + "/" + y
|
||||
}
|
||||
return x + y
|
||||
}
|
||||
|
||||
// footer joins hints as "↑/↓ move · enter go", leaving out unbound keys.
|
||||
func footer(hints ...keyHint) string {
|
||||
var parts []string
|
||||
for _, h := range hints {
|
||||
if h.key != "" {
|
||||
parts = append(parts, h.key+" "+h.desc)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// keyPress builds the key press a binding's key name stands for, so the ?
|
||||
// menu can run it.
|
||||
func keyPress(name string) tea.KeyPressMsg {
|
||||
var mod tea.KeyMod
|
||||
for {
|
||||
switch {
|
||||
case strings.HasPrefix(name, "ctrl+") && len(name) > 5:
|
||||
mod |= tea.ModCtrl
|
||||
name = name[5:]
|
||||
continue
|
||||
case strings.HasPrefix(name, "shift+") && len(name) > 6:
|
||||
mod |= tea.ModShift
|
||||
name = name[6:]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
named := map[string]rune{
|
||||
"space": tea.KeySpace, "enter": tea.KeyEnter, "tab": tea.KeyTab, "esc": tea.KeyEscape,
|
||||
"up": tea.KeyUp, "down": tea.KeyDown, "left": tea.KeyLeft, "right": tea.KeyRight,
|
||||
"home": tea.KeyHome, "end": tea.KeyEnd, "backspace": tea.KeyBackspace,
|
||||
}
|
||||
if code, ok := named[name]; ok {
|
||||
k := tea.KeyPressMsg{Code: code, Mod: mod}
|
||||
if code == tea.KeySpace && mod == 0 {
|
||||
k.Text = " "
|
||||
}
|
||||
return k
|
||||
}
|
||||
r := []rune(name)[0]
|
||||
if mod != 0 {
|
||||
return tea.KeyPressMsg{Code: r, Mod: mod}
|
||||
}
|
||||
return tea.KeyPressMsg{Code: r, Text: name}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// The Favourites and Recent scopes of the Go to popup list what's
|
||||
// remembered in the store, so they work before anything has loaded.
|
||||
|
||||
// storeEntries lists starred items (sources first, then vaults, then
|
||||
// secrets) and recently used ones (newest first), as Go to entries.
|
||||
// Entries read as paths: " platform-prod / kv-app-prod / api-key · dev",
|
||||
// the account last, and only when there are several.
|
||||
func (m *Model) storeEntries() (favs, recent []option) {
|
||||
type entry struct {
|
||||
depth int // 0 source, 1 vault, 2 secret: sources list first
|
||||
sort string
|
||||
opt option
|
||||
}
|
||||
nodes := m.store.Nodes()
|
||||
keys := slices.Sorted(maps.Keys(nodes))
|
||||
|
||||
// entries lists jumps to the item through each of accounts; nil
|
||||
// means through whichever accounts list a source, else the account
|
||||
// that last read the item.
|
||||
entries := func(prefix, key string, n store.Node, accounts []string) []entry {
|
||||
r, err := ref.Parse(key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []entry
|
||||
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
|
||||
account := ""
|
||||
if p, ok := m.byID[acct]; ok {
|
||||
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
|
||||
}
|
||||
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
||||
out = append(out, entry{depth, sort, option{
|
||||
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
|
||||
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
|
||||
run: func(m *Model) tea.Cmd {
|
||||
m.used(key)
|
||||
return run(m)
|
||||
}}})
|
||||
}
|
||||
if r.IsSource() {
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != r.Kind || (accounts != nil && !slices.Contains(accounts, p.ID())) {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
if !sourceMatches(r, src) {
|
||||
continue
|
||||
}
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
add([]string{src.Name}, p.ID(), 0, func(m *Model) tea.Cmd {
|
||||
m.nav = &nav{sourceRow: row, sourceOnly: true}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
if accounts == nil {
|
||||
accounts = []string{n.Account}
|
||||
}
|
||||
depth := 1
|
||||
if !r.IsContainer() {
|
||||
depth = 2
|
||||
}
|
||||
path := slices.Clone(n.Path)
|
||||
if len(path) == 0 && !r.IsContainer() {
|
||||
path = []string{r.ContainerName()} // no context recorded
|
||||
}
|
||||
for _, acct := range accounts {
|
||||
via := n
|
||||
via.Account = acct
|
||||
add(append(slices.Clone(path), cmp.Or(n.Name, r.Name, r.ContainerName())), acct, depth,
|
||||
func(m *Model) tea.Cmd { return m.jumpTo(r, via) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var starred []entry
|
||||
for _, k := range keys {
|
||||
if n := nodes[k]; len(n.StarredBy) > 0 {
|
||||
starred = append(starred, entries("fav|", k, n, n.StarredBy)...)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(starred, func(a, b entry) int {
|
||||
return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort))
|
||||
})
|
||||
for _, e := range starred {
|
||||
favs = append(favs, e.opt)
|
||||
}
|
||||
|
||||
if m.history {
|
||||
var used []string
|
||||
for _, k := range keys {
|
||||
if !nodes[k].LastUsed.IsZero() {
|
||||
used = append(used, k)
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(used, func(a, b string) int { return nodes[b].LastUsed.Compare(nodes[a].LastUsed) })
|
||||
for _, k := range used[:min(len(used), recentMax)] {
|
||||
for _, e := range entries("recent|", k, nodes[k], nil) {
|
||||
recent = append(recent, e.opt)
|
||||
}
|
||||
}
|
||||
}
|
||||
return favs, recent
|
||||
}
|
||||
|
||||
// recentMax is how many recently used items Recent lists.
|
||||
const recentMax = 20
|
||||
|
||||
// pathLabel draws a jump list entry: the provider's icon and the path in
|
||||
// its colour, then the account.
|
||||
func (m Model) pathLabel(kind provider.Kind, path []string, account string) string {
|
||||
style := m.st.Kind(kind)
|
||||
parts := make([]string, len(path))
|
||||
for i, p := range path {
|
||||
parts[i] = style.Render(p)
|
||||
}
|
||||
label := style.Render(m.icons.Prefix(kind)) + strings.Join(parts, m.st.Muted.Render(" / "))
|
||||
if account != "" {
|
||||
label += m.st.Muted.Render(" · " + account)
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// sourceMatches reports whether src is the subscription or workspace r
|
||||
// links to.
|
||||
func sourceMatches(r ref.Ref, src provider.Source) bool {
|
||||
if r.Kind != provider.KindAzure { // by host: workspaces, repos
|
||||
return strings.EqualFold(strings.TrimSuffix(src.Detail, "/"), r.Host)
|
||||
}
|
||||
return src.ID == r.Subscription
|
||||
}
|
||||
|
||||
// accountOf is the account part of a source's provider label ("dev" in
|
||||
// "Azure · dev"), or "" when there's a single account.
|
||||
func accountOf(src provider.Source) string {
|
||||
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
// jumpTo goes to a remembered vault or secret. With the account that last
|
||||
// read it signed in, it goes straight there; otherwise it uses the
|
||||
// accounts that list its subscription, asking if there are several, or
|
||||
// finds it like a link. Reaching it updates the remembered account.
|
||||
func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
|
||||
if n.Source == "" && !r.IsContainer() {
|
||||
// A secret without context: its vault's record may have it.
|
||||
parent := r
|
||||
parent.Type, parent.Name, parent.Version = "", "", ""
|
||||
if p := m.store.Node(parent.String()); p.Source != "" {
|
||||
n.Source, n.Account = p.Source, cmp.Or(n.Account, p.Account)
|
||||
}
|
||||
}
|
||||
if _, ok := m.byID[n.Account]; ok && n.Source != "" {
|
||||
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
|
||||
return nil
|
||||
}
|
||||
var choices []choice
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
if src.ID == n.Source && n.Source != "" {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
}
|
||||
}
|
||||
switch len(choices) {
|
||||
case 0:
|
||||
return m.openRef(r, actGo)
|
||||
case 1:
|
||||
m.startNav(r, actGo, choices[0].sourceRow)
|
||||
default:
|
||||
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// Layout: the left column stacks Sources, Vaults and Secrets; the detail
|
||||
// pane fills the rest. The user can move the divider between them, change
|
||||
// the left panes' heights, and zoom the focused pane.
|
||||
|
||||
type zoom int
|
||||
|
||||
const (
|
||||
zoomNormal zoom = iota
|
||||
zoomHalf // the focused pane gets most of the screen
|
||||
zoomFull // only the focused pane
|
||||
zoomCount
|
||||
)
|
||||
|
||||
const (
|
||||
// Divider between the left column and the detail pane.
|
||||
splitStep = 4
|
||||
minLeftW = 20
|
||||
minDetailW = 30
|
||||
defaultMinW = 28
|
||||
defaultMaxW = 48
|
||||
|
||||
// Heights of the left panes.
|
||||
heightStep = 2
|
||||
minPaneH = 4 // border, title and two rows
|
||||
tinyPaneH = 3 // a squeezed pane in half zoom: border and one row
|
||||
)
|
||||
|
||||
func (m Model) defaultLeftWidth() int { return max(defaultMinW, min(defaultMaxW, m.w/3)) }
|
||||
|
||||
// leftWidth is the default width moved by the user's divider offset,
|
||||
// kept within bounds that leave both sides usable.
|
||||
func (m Model) leftWidth() int {
|
||||
return max(minLeftW, min(m.w-minDetailW, m.defaultLeftWidth()+m.split))
|
||||
}
|
||||
|
||||
// moveSplit moves the divider by delta columns. The offset is stored as
|
||||
// what actually applied, so after hitting a limit the other direction
|
||||
// responds immediately.
|
||||
func (m *Model) moveSplit(delta int) {
|
||||
m.split += delta
|
||||
m.split = m.leftWidth() - m.defaultLeftWidth()
|
||||
}
|
||||
|
||||
func defaultHeights(avail int) [3]int {
|
||||
src, cont := max(minPaneH, avail/4), max(minPaneH, avail/3)
|
||||
return [3]int{src, cont, avail - src - cont}
|
||||
}
|
||||
|
||||
// leftHeights applies the user's height changes to the default split.
|
||||
func (m Model) leftHeights(avail int) [3]int {
|
||||
h := defaultHeights(avail)
|
||||
for i := range h {
|
||||
h[i] += m.heights[i]
|
||||
}
|
||||
return fitHeights(h, avail, minPaneH)
|
||||
}
|
||||
|
||||
// fitHeights makes the heights sum to avail with none below lo, taking
|
||||
// from or giving to the tallest panes.
|
||||
func fitHeights(h [3]int, avail, lo int) [3]int {
|
||||
for i := range h {
|
||||
h[i] = max(lo, h[i])
|
||||
}
|
||||
for sum(h) > avail {
|
||||
i := tallest(h)
|
||||
if h[i] <= lo {
|
||||
break // the terminal is too short; the layout check catches it
|
||||
}
|
||||
h[i]--
|
||||
}
|
||||
for sum(h) < avail {
|
||||
h[tallest(h)]++
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func sum(h [3]int) int { return h[0] + h[1] + h[2] }
|
||||
|
||||
func tallest(h [3]int) int {
|
||||
i := 0
|
||||
for j := range h {
|
||||
if h[j] > h[i] {
|
||||
i = j
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// resizeFocused makes the focused left pane taller (delta > 0) or shorter,
|
||||
// trading rows with the pane below it, or above it for the bottom pane.
|
||||
// It reports false if the detail pane is focused.
|
||||
func (m *Model) resizeFocused(delta int) bool {
|
||||
if m.focus > paneSecrets {
|
||||
return false
|
||||
}
|
||||
avail := m.leftAvail()
|
||||
h := m.leftHeights(avail)
|
||||
i := int(m.focus)
|
||||
j := i + 1
|
||||
if i == int(paneSecrets) {
|
||||
j = i - 1
|
||||
}
|
||||
delta = max(delta, minPaneH-h[i]) // don't shrink below the minimum
|
||||
delta = min(delta, h[j]-minPaneH) // or squeeze the neighbour below it
|
||||
h[i] += delta
|
||||
h[j] -= delta
|
||||
base := defaultHeights(avail)
|
||||
for k := range h {
|
||||
m.heights[k] = h[k] - base[k]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// saveLayout remembers the divider and heights for the next run.
|
||||
func (m *Model) saveLayout() {
|
||||
if err := m.store.SetLayout(store.Layout{Split: m.split, Heights: m.heights}); err != nil {
|
||||
m.flash = flash{text: "saving layout: " + err.Error(), isErr: true}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) leftAvail() int { return m.h - lipgloss.Height(m.footerView()) }
|
||||
|
||||
func (m *Model) cycleZoom(delta int) {
|
||||
m.zoom = (m.zoom + zoom(delta) + zoomCount) % zoomCount
|
||||
}
|
||||
|
||||
func (m *Model) layout() {
|
||||
if m.w < minWidth || m.h < minHeight {
|
||||
return
|
||||
}
|
||||
m.help.SetWidth(max(0, m.w-1)) // one column in, like messages
|
||||
avail := m.leftAvail()
|
||||
leftW, h := m.leftWidth(), m.leftHeights(avail)
|
||||
|
||||
switch m.zoom {
|
||||
case zoomHalf:
|
||||
if m.focus == paneDetail {
|
||||
leftW = minLeftW
|
||||
} else {
|
||||
leftW = max(m.leftWidth(), m.w/2)
|
||||
h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH}
|
||||
h[m.focus] = avail
|
||||
h = fitHeights(h, avail, tinyPaneH)
|
||||
}
|
||||
case zoomFull:
|
||||
// Only the focused pane is drawn; it gets the whole screen.
|
||||
switch m.focus {
|
||||
case paneDetail:
|
||||
leftW = 0
|
||||
default:
|
||||
leftW = m.w
|
||||
h = [3]int{}
|
||||
h[m.focus] = avail
|
||||
}
|
||||
}
|
||||
|
||||
m.sources.SetSize(leftW, h[0])
|
||||
m.containers.SetSize(leftW, h[1])
|
||||
m.secrets.SetSize(leftW, h[2])
|
||||
m.detail.SetSize(m.w-leftW, avail)
|
||||
}
|
||||
|
||||
// active reports whether p is drawn focused: it has focus and the terminal
|
||||
// does too. Like lazygit, nothing is highlighted while you're in another
|
||||
// window, so the focus is easy to spot when you come back.
|
||||
func (m Model) active(p pane) bool { return m.focus == p && !m.termBlurred }
|
||||
|
||||
func (m Model) panesView() string {
|
||||
spin := m.spin.View()
|
||||
views := [paneCount]func() string{
|
||||
func() string { return m.sources.View(m.st, m.active(paneSources), spin) },
|
||||
func() string { return m.containers.View(m.st, m.active(paneContainers), spin) },
|
||||
func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) },
|
||||
func() string { return m.detail.View(m.st, m.active(paneDetail), spin) },
|
||||
}
|
||||
if m.zoom == zoomFull {
|
||||
return views[m.focus]()
|
||||
}
|
||||
left := lipgloss.JoinVertical(lipgloss.Left, views[0](), views[1](), views[2]())
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, views[3]())
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// paneHeights reads the left panes' heights back from the screen: each
|
||||
// starts with a "╭─ [n]" line.
|
||||
func (d *driver) paneHeights() [3]int {
|
||||
lines := strings.Split(d.screen(), "\n")
|
||||
var starts []int
|
||||
for i, l := range lines {
|
||||
for n := 1; n <= 3; n++ {
|
||||
if strings.HasPrefix(l, "╭─ ["+string(rune('0'+n))+"]") {
|
||||
starts = append(starts, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(starts) != 3 {
|
||||
d.t.Fatalf("found %d left panes:\n%s", len(starts), d.screen())
|
||||
}
|
||||
bottom := len(lines) - 1 - strings.Count(d.m.footerView(), "\n") // first footer line
|
||||
return [3]int{starts[1] - starts[0], starts[2] - starts[1], bottom - starts[2]}
|
||||
}
|
||||
|
||||
func TestPaneHeights(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t) // 30 rows, one footer line: 29 for panes
|
||||
start := d.paneHeights()
|
||||
if sum(start) != 29 {
|
||||
t.Fatalf("heights %v don't fill the column", start)
|
||||
}
|
||||
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModCtrl}) // Sources taller, from Vaults
|
||||
got := d.paneHeights()
|
||||
if got[0] != start[0]+heightStep || got[1] != start[1]-heightStep || got[2] != start[2] {
|
||||
t.Errorf("ctrl+↓ on Sources: %v → %v", start, got)
|
||||
}
|
||||
|
||||
d.keys("3", "}") // Secrets is the bottom pane: it takes from Vaults above
|
||||
got2 := d.paneHeights()
|
||||
if got2[2] != got[2]+heightStep || got2[1] != got[1]-heightStep {
|
||||
t.Errorf("} on Secrets: %v → %v", got, got2)
|
||||
}
|
||||
|
||||
for range 20 {
|
||||
d.keys("{") // shrink Secrets as far as it goes
|
||||
}
|
||||
if h := d.paneHeights(); h[2] != minPaneH || sum(h) != 29 {
|
||||
t.Errorf("minimum height: %v", h)
|
||||
}
|
||||
|
||||
d.keys("4", "}")
|
||||
if !strings.Contains(d.screen(), "focus a left pane") {
|
||||
t.Error("no hint when resizing from the detail pane")
|
||||
}
|
||||
|
||||
d.keys("=")
|
||||
if h := d.paneHeights(); h != start {
|
||||
t.Errorf("= didn't reset heights: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoom(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "+") // half: Secrets gets most of the column and half the width
|
||||
h := d.paneHeights()
|
||||
if h[0] != tinyPaneH || h[1] != tinyPaneH || h[2] != 29-2*tinyPaneH {
|
||||
t.Errorf("half zoom heights = %v", h)
|
||||
}
|
||||
if got := d.leftEdge(); got != 50 {
|
||||
t.Errorf("half zoom width = %d, want half of 100", got)
|
||||
}
|
||||
|
||||
d.keys("+") // full: only Secrets
|
||||
screen := d.screen()
|
||||
if !strings.Contains(screen, "[3] Secrets") || strings.Contains(screen, "[1] Sources") || strings.Contains(screen, "Secret ─") {
|
||||
t.Errorf("full zoom should show only the Secrets pane:\n%s", screen)
|
||||
}
|
||||
if w := len([]rune(strings.Split(screen, "\n")[0])); w != 100 {
|
||||
t.Errorf("full zoom width = %d", w)
|
||||
}
|
||||
|
||||
d.keys("4") // the zoom follows focus
|
||||
if !strings.Contains(d.screen(), "╭─ Secret ") || strings.Contains(d.screen(), "[3] Secrets") {
|
||||
t.Errorf("full zoom on the detail pane:\n%s", d.screen())
|
||||
}
|
||||
|
||||
d.keys("+") // wraps to normal
|
||||
if d.m.zoom != zoomNormal {
|
||||
t.Errorf("zoom = %d after three +", d.m.zoom)
|
||||
}
|
||||
d.keys("_")
|
||||
if d.m.zoom != zoomFull {
|
||||
t.Errorf("_ from normal should go to full, got %d", d.m.zoom)
|
||||
}
|
||||
d.keys("=")
|
||||
if d.m.zoom != zoomNormal {
|
||||
t.Error("= didn't reset zoom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutIsSaved(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
load := func() *store.Store {
|
||||
s, err := store.Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
d := newDriverOpts(t, Options{Store: load()})
|
||||
d.keys(">", ">", "}")
|
||||
edge, heights := d.leftEdge(), d.paneHeights()
|
||||
|
||||
d2 := newDriverOpts(t, Options{Store: load()})
|
||||
if d2.leftEdge() != edge || d2.paneHeights() != heights {
|
||||
t.Errorf("restored %d %v, want %d %v", d2.leftEdge(), d2.paneHeights(), edge, heights)
|
||||
}
|
||||
d2.keys("=")
|
||||
if l := load().Layout(); l.Split != 0 || l.Heights != [3]int{} {
|
||||
t.Errorf("reset not saved: %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextHelpBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share…") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space reveal") {
|
||||
t.Errorf("Sources help bar: %q", bar)
|
||||
}
|
||||
d.keys("3")
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share…") {
|
||||
t.Errorf("Secrets help bar: %q", bar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeybindingsMenu(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "?")
|
||||
if d.m.prompt == nil || d.m.prompt.title != "Keybindings" {
|
||||
t.Fatalf("? didn't open the menu:\n%s", d.screen())
|
||||
}
|
||||
// Sections: the focused pane's keys, then global ones; the cursor
|
||||
// starts on the first key, and a counter shows where it is.
|
||||
p := d.m.prompt
|
||||
if !p.options[0].header || p.options[0].label != "Secrets pane" {
|
||||
t.Errorf("first section: %+v", p.options[0])
|
||||
}
|
||||
if sel := ansi.Strip(p.options[p.cursor].label); !strings.HasPrefix(sel, "space") {
|
||||
t.Errorf("cursor starts on %q", sel)
|
||||
}
|
||||
screen := d.screen()
|
||||
if !strings.Contains(screen, "Global") || !strings.Contains(screen, "1 of ") {
|
||||
t.Errorf("no Global section or counter:\n%s", screen)
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp}) // can't go up onto the header
|
||||
if p.options[p.cursor].header {
|
||||
t.Error("cursor landed on a header")
|
||||
}
|
||||
if !strings.Contains(d.screen(), "Show or hide the selected value") {
|
||||
t.Errorf("no tip for the selected row:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// The filter matches descriptions and groups too; matches stay under
|
||||
// their section headers, and empty sections drop out.
|
||||
for _, r := range "copy" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
var headers []string
|
||||
for _, o := range d.m.prompt.options {
|
||||
if o.header {
|
||||
headers = append(headers, o.label)
|
||||
}
|
||||
}
|
||||
if strings.Join(headers, ",") != "Secrets pane,Global" {
|
||||
t.Errorf("sections while filtering: %v", headers)
|
||||
}
|
||||
if d.m.prompt.options[d.m.prompt.cursor].header {
|
||||
t.Error("cursor on a header after filtering")
|
||||
}
|
||||
var keys []string
|
||||
for _, o := range d.m.prompt.options {
|
||||
keys = append(keys, strings.Fields(ansi.Strip(o.label))[0])
|
||||
}
|
||||
for _, want := range []string{"y", "Y", "s", "ctrl+y"} {
|
||||
if !slices.Contains(keys, want) {
|
||||
t.Errorf("filter copy: %v lacks %s", keys, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter runs the selected binding.
|
||||
d.keys("esc", "?")
|
||||
for _, r := range "space" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
if sel := ansi.Strip(d.m.prompt.options[d.m.prompt.cursor].label); !strings.HasPrefix(sel, "space") {
|
||||
t.Fatalf("filter space: selected %q", sel)
|
||||
}
|
||||
d.keys("enter")
|
||||
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
|
||||
t.Errorf("running reveal from the menu didn't reveal:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPress(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, want := range map[string]string{
|
||||
"space": "space", "ctrl+y": "ctrl+y", "shift+tab": "shift+tab", "S": "S", "`": "`", "ctrl+left": "ctrl+left",
|
||||
} {
|
||||
if got := keyPress(name).String(); got != want {
|
||||
t.Errorf("keyPress(%q).String() = %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lastLine(s string) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
return lines[len(lines)-1]
|
||||
}
|
||||
|
||||
func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
green := "\x1b[32m╭" // a focused border; "current" versions are green too
|
||||
if !strings.Contains(d.m.View().Content, green) {
|
||||
t.Fatal("focused pane not highlighted")
|
||||
}
|
||||
d.send(tea.BlurMsg{})
|
||||
if strings.Contains(d.m.View().Content, green) {
|
||||
t.Error("still highlighted while the terminal is blurred")
|
||||
}
|
||||
d.send(tea.FocusMsg{})
|
||||
if !strings.Contains(d.m.View().Content, green) {
|
||||
t.Error("highlight not back after focus")
|
||||
}
|
||||
|
||||
// Popups too.
|
||||
d.keys("?")
|
||||
d.send(tea.BlurMsg{})
|
||||
if strings.Contains(d.m.modalView(), "\x1b[32m") || strings.Contains(d.m.modalView(), "\x1b[7m") {
|
||||
t.Error("popup still highlighted while the terminal is blurred")
|
||||
}
|
||||
d.send(tea.FocusMsg{})
|
||||
if !strings.Contains(d.m.modalView(), "\x1b[32m╭") {
|
||||
t.Error("popup highlight not back after focus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionBottomRight(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{Version: "0.1.0"})
|
||||
bar := lastLine(d.screen())
|
||||
if !strings.HasSuffix(bar, "v0.1.0 ") || ansi.StringWidth(bar) != 100 {
|
||||
t.Errorf("help line %q (%d wide)", bar, ansi.StringWidth(bar))
|
||||
}
|
||||
if !strings.Contains(bar, "? help • q quit") {
|
||||
t.Errorf("help squeezed out by the version: %q", bar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// Opening references: a reference (lazysecrets:// link, Key Vault URL, …)
|
||||
// is first resolved to a source, then the panes are stepped through to it
|
||||
// as each list loads. Copy and reveal actions ask before reading the value.
|
||||
|
||||
// linksRowID is the Sources row listing vaults opened from links that no
|
||||
// account could locate (e.g. no read access to the vault resource).
|
||||
const linksRowID = "links"
|
||||
|
||||
type navStage int
|
||||
|
||||
const (
|
||||
navSource navStage = iota
|
||||
navContainer
|
||||
navObject
|
||||
navVersion
|
||||
navAction
|
||||
)
|
||||
|
||||
// nav is an open in progress.
|
||||
type nav struct {
|
||||
ref ref.Ref
|
||||
act linkAction
|
||||
// sourceOnly stops at the source (jumping to a starred subscription).
|
||||
sourceOnly bool
|
||||
|
||||
sourceRow string // Sources row to select
|
||||
stage navStage
|
||||
}
|
||||
|
||||
type resolvedMsg struct {
|
||||
ref ref.Ref
|
||||
act linkAction
|
||||
sourceRow string
|
||||
others []string // other accounts' rows that also found the vault
|
||||
choices []choice // several candidates: ask
|
||||
linked *provider.Container
|
||||
err error
|
||||
}
|
||||
|
||||
type choice struct {
|
||||
label string
|
||||
sourceRow string
|
||||
}
|
||||
|
||||
// linkAction is what to do with an object once it's opened. Links don't
|
||||
// carry it: whoever opens one chooses.
|
||||
type linkAction int
|
||||
|
||||
const (
|
||||
actGo linkAction = iota // just go to it
|
||||
actCopy // and copy its value
|
||||
actShow // and show its value
|
||||
)
|
||||
|
||||
// offerRef asks what to do with a reference: for an object, go to it, or
|
||||
// go to it and copy or show its value; for a subscription, vault or scope,
|
||||
// just open it.
|
||||
// With ask false, vault and scope links open straight away.
|
||||
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
|
||||
if r.IsContainer() || r.IsSource() {
|
||||
if !ask {
|
||||
return m.openRef(r, actGo)
|
||||
}
|
||||
m.showMenu(title, nil, []option{
|
||||
{key: "o", label: "Open it", run: func(m *Model) tea.Cmd { return m.openRef(r, actGo) }},
|
||||
})
|
||||
m.prompt.about = &r
|
||||
return nil
|
||||
}
|
||||
do := func(a linkAction) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd { return m.openRef(r, a) }
|
||||
}
|
||||
opts := []option{
|
||||
{key: "o", label: "Go to it", run: do(actGo)},
|
||||
{key: "c", label: "Go to it and copy its value", run: do(actCopy)},
|
||||
{key: "r", label: "Go to it and show its value", run: do(actShow)},
|
||||
}
|
||||
if r.Kind == provider.KindAzure || r.Host != "" { // {{secrets/s/k}} names no workspace
|
||||
opts = append(opts, option{key: "y", label: "Copy its value, staying here", run: func(m *Model) tea.Cmd { return m.copyFromRef(r) }})
|
||||
}
|
||||
m.showMenu(title, nil, opts)
|
||||
m.prompt.about = &r
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFromRef copies the value a link points at without going there. The
|
||||
// link says enough to read it: the vault and name (Azure, tried with each
|
||||
// account, the one that last read it first) or the workspace, scope and
|
||||
// key (Databricks).
|
||||
func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
|
||||
type try struct {
|
||||
p provider.Provider
|
||||
o provider.Object
|
||||
}
|
||||
var tries []try
|
||||
key := r
|
||||
key.Version = ""
|
||||
node := m.store.Node(key.String())
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
|
||||
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: node.Source}
|
||||
if len(node.Path) > 0 {
|
||||
src.Name = node.Path[0]
|
||||
}
|
||||
if r.Kind != provider.KindAzure { // by host: Databricks workspaces, repos
|
||||
o.Type = provider.TypeSecret
|
||||
src.ID = ""
|
||||
for _, s := range m.sourceResults[p.ID()].sources {
|
||||
if sourceMatches(r, s) {
|
||||
src = s
|
||||
}
|
||||
}
|
||||
if src.ID == "" {
|
||||
continue
|
||||
}
|
||||
o.Container = provider.Container{Source: src, Name: r.Scope}
|
||||
} else {
|
||||
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
|
||||
}
|
||||
t := try{p, o}
|
||||
if p.ID() == node.Account {
|
||||
tries = append([]try{t}, tries...)
|
||||
} else {
|
||||
tries = append(tries, t)
|
||||
}
|
||||
}
|
||||
if len(tries) == 0 {
|
||||
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
|
||||
return nil
|
||||
}
|
||||
ctx, req := m.begin(slotCopy)
|
||||
m.flash = flash{text: "copying " + r.Name + "…"}
|
||||
return func() tea.Msg {
|
||||
var errs []error
|
||||
for _, t := range tries {
|
||||
v, err := t.p.Value(ctx, t.o)
|
||||
if err == nil {
|
||||
msg := copyMsg{req: req, name: r.Name, value: v}
|
||||
if t.o.Container.Source.ID != "" {
|
||||
msg.obj = t.o // where it is is known: count the use
|
||||
}
|
||||
return msg
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return copyMsg{req: req, name: r.Name, err: err}
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
|
||||
}
|
||||
if len(errs) == 1 {
|
||||
return copyMsg{req: req, name: r.Name, err: errors.Unwrap(errs[0])} // one account: no need to name it
|
||||
}
|
||||
return copyMsg{req: req, name: r.Name, err: errors.Join(errs...)}
|
||||
}
|
||||
}
|
||||
|
||||
// openRef starts opening a reference, doing act once there.
|
||||
func (m *Model) openRef(r ref.Ref, act linkAction) tea.Cmd {
|
||||
m.nav = nil
|
||||
if r.IsSource() {
|
||||
m.openSource(r)
|
||||
return nil
|
||||
}
|
||||
m.flash = flash{text: "opening " + describeRef(r) + "…"}
|
||||
cands := m.providers
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultResolveTimeout)
|
||||
defer cancel()
|
||||
msg := resolve(ctx, cands, r)
|
||||
msg.act = act
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
const defaultResolveTimeout = 30 * time.Second
|
||||
|
||||
// openSource goes to a subscription or workspace, asking which account
|
||||
// when several list it. Sources are loaded already, so it needs no lookup.
|
||||
func (m *Model) openSource(r ref.Ref) {
|
||||
var choices []choice
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
if sourceMatches(r, src) {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
}
|
||||
}
|
||||
switch len(choices) {
|
||||
case 0:
|
||||
m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true}
|
||||
case 1:
|
||||
m.nav = &nav{ref: r, sourceRow: choices[0].sourceRow, sourceOnly: true}
|
||||
default:
|
||||
var opts []option
|
||||
for i, c := range choices {
|
||||
row := c.sourceRow
|
||||
opts = append(opts, option{key: fmt.Sprint(i + 1), label: c.label, run: func(m *Model) tea.Cmd {
|
||||
m.nav = &nav{ref: r, sourceRow: row, sourceOnly: true}
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
m.showMenu("Several accounts can see "+describeRef(r), nil, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// refLabel draws what a link points at like the favourites list: the
|
||||
// provider's icon and the path in its colour, " platform-prod /
|
||||
// kv-app-prod / api-key", with the type and version after it. The
|
||||
// subscription or workspace isn't in the link: it comes from what's
|
||||
// remembered about the item, or from lists already loaded.
|
||||
func (m Model) refLabel(r ref.Ref) string {
|
||||
var path []string
|
||||
if src := m.refSource(r); src != "" {
|
||||
path = append(path, src)
|
||||
}
|
||||
switch {
|
||||
case r.IsSource():
|
||||
if len(path) == 0 {
|
||||
path = append(path, cmp.Or(r.Subscription, r.Host))
|
||||
}
|
||||
case r.IsContainer():
|
||||
path = append(path, r.ContainerName())
|
||||
default:
|
||||
path = append(path, r.ContainerName(), r.Name)
|
||||
}
|
||||
label := m.pathLabel(r.Kind, path, "")
|
||||
var extra []string
|
||||
if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret {
|
||||
if r.IsContainer() {
|
||||
extra = append(extra, t+" tab")
|
||||
} else {
|
||||
extra = append(extra, strings.TrimSuffix(t, "s")) // "key", "certificate"
|
||||
}
|
||||
}
|
||||
if r.Version != "" {
|
||||
extra = append(extra, "version "+r.Version)
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
label += m.st.Muted.Render(" · " + strings.Join(extra, " · "))
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// refSource names the subscription or workspace r is in, if known.
|
||||
func (m Model) refSource(r ref.Ref) string {
|
||||
key := r
|
||||
key.Version = ""
|
||||
if n := m.store.Node(key.String()); len(n.Path) > 0 {
|
||||
return n.Path[0]
|
||||
}
|
||||
for _, res := range m.sourceResults {
|
||||
for _, src := range res.sources {
|
||||
if (r.Kind != provider.KindAzure || r.IsSource()) && src.Kind == r.Kind && sourceMatches(r, src) {
|
||||
return src.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.Kind == provider.KindAzure && !r.IsSource() {
|
||||
for _, e := range m.cache.containers {
|
||||
for _, c := range e.value {
|
||||
if matchesContainer(c, r) {
|
||||
return c.Source.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func describeRef(r ref.Ref) string {
|
||||
if r.IsSource() {
|
||||
switch r.Kind {
|
||||
case provider.KindDatabricks:
|
||||
return "workspace " + r.Host
|
||||
case provider.KindRepo:
|
||||
return "repo " + r.Host
|
||||
}
|
||||
return "subscription " + r.Subscription
|
||||
}
|
||||
if r.IsContainer() {
|
||||
return r.ContainerName()
|
||||
}
|
||||
return r.Name + " in " + r.ContainerName()
|
||||
}
|
||||
|
||||
// resolve finds the source holding the reference's container.
|
||||
func resolve(ctx context.Context, providers []provider.Provider, r ref.Ref) resolvedMsg {
|
||||
msg := resolvedMsg{ref: r}
|
||||
var errs []error
|
||||
switch {
|
||||
case r.Kind == provider.KindDatabricks && r.Host == "":
|
||||
// {{secrets/scope/key}}: search every workspace for the scope.
|
||||
for _, p := range providers {
|
||||
if p.Kind() != provider.KindDatabricks {
|
||||
continue
|
||||
}
|
||||
srcs, err := p.Sources(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
for _, src := range srcs {
|
||||
cs, err := p.Containers(ctx, src)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", src.Name, err))
|
||||
continue
|
||||
}
|
||||
for _, c := range cs {
|
||||
if c.Name == r.Scope {
|
||||
msg.choices = append(msg.choices, choice{label: src.Name, sourceRow: sourceRowKey(p.ID(), src.ID)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch len(msg.choices) {
|
||||
case 0:
|
||||
msg.err = errors.Join(append([]error{fmt.Errorf("scope %q isn't in any workspace", r.Scope)}, errs...)...)
|
||||
case 1:
|
||||
msg.sourceRow, msg.choices = msg.choices[0].sourceRow, nil
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
for _, p := range providers {
|
||||
loc, ok := p.(provider.Locator)
|
||||
if !ok || p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
id, found, err := loc.Locate(ctx, r.Host)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", p.Label(), err))
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
// Several accounts may see it, with different access: the
|
||||
// user picks.
|
||||
row := sourceRowKey(p.ID(), id)
|
||||
if msg.sourceRow == "" {
|
||||
msg.sourceRow = row
|
||||
} else {
|
||||
msg.others = append(msg.others, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
if msg.sourceRow != "" {
|
||||
return msg
|
||||
}
|
||||
if r.Kind == provider.KindAzure {
|
||||
// No account can find the vault resource, but one may still read
|
||||
// it: open it under "Opened links", where each account is tried.
|
||||
msg.linked = &provider.Container{
|
||||
Source: provider.Source{Kind: provider.KindAzure, Name: r.Host},
|
||||
ID: r.VaultURL(), Name: r.ContainerName(), Detail: r.VaultURL(),
|
||||
Types: []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate},
|
||||
}
|
||||
return msg
|
||||
}
|
||||
missing := fmt.Errorf("no Databricks profile for workspace %s", r.Host)
|
||||
if r.Kind == provider.KindRepo {
|
||||
missing = fmt.Errorf("repo %s isn't here: start lazysecrets in it, or list it under providers.repos.paths", r.Host)
|
||||
}
|
||||
msg.err = errors.Join(append([]error{missing}, errs...)...)
|
||||
return msg
|
||||
}
|
||||
|
||||
func (m *Model) handleResolved(msg resolvedMsg) {
|
||||
switch {
|
||||
case msg.err != nil:
|
||||
m.flash = flash{text: "open: " + msg.err.Error(), isErr: true}
|
||||
case len(msg.choices) > 1:
|
||||
for i := range msg.choices {
|
||||
msg.choices[i].label = "Open in " + msg.choices[i].label
|
||||
}
|
||||
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices)
|
||||
case len(msg.others) > 0:
|
||||
var choices []choice
|
||||
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices)
|
||||
case msg.linked != nil:
|
||||
m.addLinked(*msg.linked)
|
||||
m.startNav(msg.ref, msg.act, linksRowID)
|
||||
default:
|
||||
m.startNav(msg.ref, msg.act, msg.sourceRow)
|
||||
}
|
||||
}
|
||||
|
||||
// chooseSource asks where to open r.
|
||||
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice) {
|
||||
var opts []option
|
||||
for i, c := range choices {
|
||||
key := ""
|
||||
if i < 9 {
|
||||
key = fmt.Sprint(i + 1)
|
||||
}
|
||||
row := c.sourceRow
|
||||
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
|
||||
m.startNav(r, act, row)
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
m.showMenu(title, nil, opts)
|
||||
}
|
||||
|
||||
// sourceLabel names a Sources row as "Azure · admin / app-dev".
|
||||
func (m Model) sourceLabel(row string) string {
|
||||
providerID, sourceID := splitSourceRow(row)
|
||||
label := providerID
|
||||
if p, ok := m.byID[providerID]; ok {
|
||||
label = p.Label()
|
||||
}
|
||||
for _, s := range m.sourceResults[providerID].sources {
|
||||
if s.ID == sourceID {
|
||||
return label + " / " + s.Name
|
||||
}
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func (m *Model) startNav(r ref.Ref, act linkAction, sourceRow string) {
|
||||
m.nav = &nav{ref: r, act: act, sourceRow: sourceRow}
|
||||
m.versionsOf = nil
|
||||
m.flash = flash{}
|
||||
}
|
||||
|
||||
// addLinked lists a vault under "Opened links" for this session.
|
||||
func (m *Model) addLinked(c provider.Container) {
|
||||
for _, l := range m.linked {
|
||||
if l.ID == c.ID {
|
||||
return
|
||||
}
|
||||
}
|
||||
m.linked = append(m.linked, c)
|
||||
m.rebuildSources()
|
||||
}
|
||||
|
||||
func (m *Model) navFail(format string, a ...any) {
|
||||
m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true}
|
||||
m.nav = nil
|
||||
}
|
||||
|
||||
// advanceNav moves an open along as far as the loaded lists allow. It
|
||||
// reports whether it got further, so the caller can sync and try again.
|
||||
func (m *Model) advanceNav() bool {
|
||||
n := m.nav
|
||||
if n == nil {
|
||||
return false
|
||||
}
|
||||
r := n.ref
|
||||
switch n.stage {
|
||||
case navSource:
|
||||
if !m.sources.Select(n.sourceRow) {
|
||||
group, _ := splitSourceRow(n.sourceRow)
|
||||
if m.collapsed[group] {
|
||||
m.collapsed[group] = false
|
||||
m.rebuildSources()
|
||||
return true
|
||||
}
|
||||
if m.sourcesPending > 0 {
|
||||
return false // still loading
|
||||
}
|
||||
m.navFail("%s isn't in the Sources list (hidden in the config?)", describeRef(r))
|
||||
return false
|
||||
}
|
||||
m.sourcesTouched = true
|
||||
m.focus = paneContainers
|
||||
if n.sourceOnly {
|
||||
m.nav = nil
|
||||
return true
|
||||
}
|
||||
n.stage = navContainer
|
||||
return true
|
||||
|
||||
case navContainer:
|
||||
if m.selSource != n.sourceRow || m.containers.Loading {
|
||||
return false
|
||||
}
|
||||
if !m.containers.SelectWhere(func(c provider.Container) bool { return matchesContainer(c, r) }) {
|
||||
m.navFail("%s not found", r.ContainerName())
|
||||
return false
|
||||
}
|
||||
if r.Type != "" {
|
||||
m.wantType = r.Type
|
||||
}
|
||||
if !r.IsContainer() {
|
||||
m.focus = paneSecrets
|
||||
}
|
||||
n.stage = navObject
|
||||
return true
|
||||
|
||||
case navObject:
|
||||
c, _ := m.containers.Selected()
|
||||
if (r.Type != "" && m.objType != r.Type) || m.secrets.Loading ||
|
||||
!strings.HasPrefix(m.selContainer, containerKey(c)+"|") {
|
||||
return false
|
||||
}
|
||||
if m.secrets.Err != nil {
|
||||
m.nav = nil // the pane shows the error
|
||||
return false
|
||||
}
|
||||
// Reached it, and its account can read it: remember that.
|
||||
m.refreshContext(storeKey(c), c.Name, []string{c.Source.Name}, c.Source)
|
||||
if r.IsContainer() {
|
||||
m.nav = nil
|
||||
return true
|
||||
}
|
||||
if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Name == r.Name }) {
|
||||
m.navFail("%s not found in %s", r.Name, r.ContainerName())
|
||||
return false
|
||||
}
|
||||
if o, ok := m.secrets.Selected(); ok {
|
||||
m.refreshContext(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source)
|
||||
}
|
||||
if r.Version != "" {
|
||||
o, _ := m.secrets.Selected()
|
||||
m.versionsOf = &o
|
||||
n.stage = navVersion
|
||||
return true
|
||||
}
|
||||
n.stage = navAction
|
||||
return true
|
||||
|
||||
case navVersion:
|
||||
if m.versionsOf == nil || m.secrets.Loading || m.secrets.Err != nil {
|
||||
if m.secrets.Err != nil {
|
||||
m.nav = nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Version == r.Version }) {
|
||||
m.flash = flash{text: "version " + r.Version + " not found; showing all versions", isErr: true}
|
||||
}
|
||||
n.stage = navAction
|
||||
return true
|
||||
|
||||
case navAction:
|
||||
// The user chose this when opening the link; no need to ask again.
|
||||
m.nav = nil
|
||||
switch n.act {
|
||||
case actCopy:
|
||||
m.pendingCmd = m.copyValue()
|
||||
case actShow:
|
||||
if m.detail.State() != panes.ValueShown {
|
||||
m.pendingCmd = m.toggleReveal()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesContainer compares by vault URL (Azure), or scope or file name.
|
||||
func matchesContainer(c provider.Container, r ref.Ref) bool {
|
||||
if r.Kind != provider.KindAzure {
|
||||
return c.Name == r.Scope
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSuffix(c.Detail, "/")+"/", r.VaultURL())
|
||||
}
|
||||
|
||||
// share copies a link to the focused item. With menu, it offers the
|
||||
// other things worth sharing about it.
|
||||
func (m *Model) share(menu bool) tea.Cmd {
|
||||
switch m.focus {
|
||||
case paneSources:
|
||||
s, ok := m.selectedSource()
|
||||
if !ok {
|
||||
m.flash = warning("select a subscription, workspace, vault or secret to share")
|
||||
return nil
|
||||
}
|
||||
if !menu {
|
||||
return m.shareSource(s, "o")
|
||||
}
|
||||
share := func(k string) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd { return m.shareSource(s, k) }
|
||||
}
|
||||
opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
|
||||
if s.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
|
||||
}
|
||||
m.showMenu("Share "+s.Name, nil, opts)
|
||||
case paneContainers:
|
||||
c, ok := m.containers.Selected()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !menu {
|
||||
return m.shareContainer(c, "o")
|
||||
}
|
||||
share := func(k string) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
|
||||
}
|
||||
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}}
|
||||
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
|
||||
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
|
||||
}
|
||||
if c.Source.Kind == provider.KindAzure {
|
||||
opts = append(opts, option{key: "u", label: "Vault URL", run: share("u")})
|
||||
}
|
||||
if c.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
|
||||
}
|
||||
m.showMenu("Share "+c.Name, nil, opts)
|
||||
default:
|
||||
o, ok := m.detail.Secret()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !menu {
|
||||
return m.shareObject(o, "o")
|
||||
}
|
||||
share := func(k string) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
|
||||
}
|
||||
opts := []option{
|
||||
option{key: "o", label: "Lazysecrets link", run: share("o")},
|
||||
}
|
||||
if o.Container.Source.Kind == provider.KindAzure {
|
||||
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
|
||||
}
|
||||
if o.Reference != "" {
|
||||
opts = append(opts, option{key: "y", label: "Config reference", run: share("y")})
|
||||
}
|
||||
if o.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
|
||||
}
|
||||
m.showMenu("Share "+o.Name, nil, opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// webLabel names the web page o (open in browser) opens.
|
||||
func webLabel(kind provider.Kind) string {
|
||||
switch kind {
|
||||
case provider.KindDatabricks:
|
||||
return "Web page (Databricks workspace)"
|
||||
case provider.KindRepo:
|
||||
return "Web page (repository)"
|
||||
}
|
||||
return "Web page (Azure portal)"
|
||||
}
|
||||
|
||||
// shareSource copies what the share menu's key k stands for, for a
|
||||
// subscription or workspace.
|
||||
func (m *Model) shareSource(s provider.Source, k string) tea.Cmd {
|
||||
m.usedSource(s)
|
||||
if k == "b" {
|
||||
if s.URL == "" {
|
||||
return nil
|
||||
}
|
||||
return m.copyText(s.Name+" web page", s.URL, false)
|
||||
}
|
||||
r, err := ref.ForSource(s)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
return m.copyText("link to "+s.Name, r.String(), false)
|
||||
}
|
||||
|
||||
// shareContainer copies what the share menu's key k stands for, for a
|
||||
// vault or scope.
|
||||
func (m *Model) shareContainer(c provider.Container, k string) tea.Cmd {
|
||||
m.usedContainer(c)
|
||||
tab := provider.TypeSecret
|
||||
switch k {
|
||||
case "b":
|
||||
if c.URL == "" {
|
||||
return nil
|
||||
}
|
||||
return m.copyText(c.Name+" web page", c.URL, false)
|
||||
case "t":
|
||||
tab = m.objType
|
||||
case "o", "u":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
r, err := ref.ForContainer(c, tab)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
if k == "u" {
|
||||
if u := r.IdentifierURL(); u != "" {
|
||||
return m.copyText(c.Name+" URL", u, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return m.copyText("link to "+c.Name, r.String(), false)
|
||||
}
|
||||
|
||||
// shareObject copies what the share menu's key k stands for.
|
||||
func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
|
||||
m.usedObject(o)
|
||||
if k == "b" {
|
||||
if o.URL == "" {
|
||||
return nil
|
||||
}
|
||||
return m.copyText(o.Name+" web page", o.URL, false)
|
||||
}
|
||||
if k == "y" {
|
||||
if o.Reference == "" {
|
||||
return nil
|
||||
}
|
||||
return m.copyText(o.Name+" reference", o.Reference, false)
|
||||
}
|
||||
version := ""
|
||||
if m.versionsOf != nil {
|
||||
version = o.Version // shared from the versions view: that version
|
||||
}
|
||||
r, err := ref.ForObject(o, version)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
switch k {
|
||||
case "u":
|
||||
if u := r.IdentifierURL(); u != "" {
|
||||
return m.copyText(o.Name+" URL", u, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return m.copyText("link to "+o.Name, r.String(), false)
|
||||
}
|
||||
|
||||
func (m *Model) startOpenPrompt() tea.Cmd {
|
||||
in := textinput.New()
|
||||
in.Prompt = "open: "
|
||||
in.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
|
||||
in.SetWidth(max(20, min(m.w-12, 64)))
|
||||
m.prompt = &modal{
|
||||
title: "Open a link", input: &in,
|
||||
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
|
||||
submit: func(m *Model, text string) tea.Cmd { return m.openText(text) },
|
||||
}
|
||||
// Pre-fill from the clipboard, read now: it may hold any reference,
|
||||
// including ones not offered in the footer (our own, dismissed ones).
|
||||
clip := m.clip
|
||||
readClip := func() tea.Msg {
|
||||
text, err := clip.Read()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if _, err := ref.Parse(text); err != nil || strings.ContainsAny(text, "\n") {
|
||||
return nil // not a reference: don't keep it
|
||||
}
|
||||
return openPromptFillMsg(text)
|
||||
}
|
||||
return tea.Batch(m.prompt.input.Focus(), readClip)
|
||||
}
|
||||
|
||||
type openPromptFillMsg string
|
||||
|
||||
// fillOpenPrompt puts a reference from the clipboard in the open prompt,
|
||||
// unless the user has started typing.
|
||||
func (m *Model) fillOpenPrompt(text string) {
|
||||
if p := m.prompt; p != nil && p.input != nil && p.input.Value() == "" {
|
||||
p.input.SetValue(text)
|
||||
p.input.CursorEnd()
|
||||
}
|
||||
}
|
||||
|
||||
// openText parses a pasted or typed reference and asks what to do with it.
|
||||
func (m *Model) openText(s string) tea.Cmd {
|
||||
r, err := ref.Parse(s)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
return m.offerRef("Open link", r, false)
|
||||
}
|
||||
|
||||
// handlePaste opens a pasted reference, unless a text input has focus.
|
||||
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
|
||||
if p := m.prompt; p != nil && p.input != nil {
|
||||
in, cmd := p.input.Update(msg)
|
||||
p.input = &in
|
||||
return cmd
|
||||
}
|
||||
if l := m.focusedFilter(); l != nil && l.Filtering() {
|
||||
return l.UpdateFilter(msg)
|
||||
}
|
||||
if m.prompt != nil {
|
||||
return nil
|
||||
}
|
||||
return m.openText(msg.Content)
|
||||
}
|
||||
|
||||
type clipboardTextMsg string
|
||||
|
||||
// clipCheckEvery throttles the clipboard check on key presses, which
|
||||
// covers terminals that don't report focus.
|
||||
const clipCheckEvery = 3 * time.Second
|
||||
|
||||
// maybeCheckClipboard checks the clipboard if it hasn't been for a while.
|
||||
func (m *Model) maybeCheckClipboard() tea.Cmd {
|
||||
if now := m.now(); now.Sub(m.clipChecked) >= clipCheckEvery {
|
||||
m.clipChecked = now
|
||||
return m.checkClipboard()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkClipboard reads the clipboard in the background to look for a
|
||||
// lazysecrets:// link. Only such links are kept; anything else (it may well
|
||||
// be a password) is dropped as soon as it has been looked at.
|
||||
func (m Model) checkClipboard() tea.Cmd {
|
||||
clip := m.clip
|
||||
return func() tea.Msg {
|
||||
text, err := clip.Read()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if !isLink(text) {
|
||||
return clipboardTextMsg("")
|
||||
}
|
||||
return clipboardTextMsg(text)
|
||||
}
|
||||
}
|
||||
|
||||
// isLink reports whether s looks like a lazysecrets:// link, valid or not.
|
||||
func isLink(s string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(s), ref.Scheme+"://")
|
||||
}
|
||||
|
||||
// offerClipboardLink pops up a link found on the clipboard, once: whatever
|
||||
// the user picks, that link isn't offered again. It isn't offered while
|
||||
// another popup is open, or if it points at what's selected already (e.g.
|
||||
// just shared with s). A lazysecrets:// link that doesn't parse is
|
||||
// reported, so a typo doesn't fail silently.
|
||||
func (m *Model) offerClipboardLink(text string) {
|
||||
if text == "" || text == m.dismissedLink || m.prompt != nil || m.search != nil {
|
||||
return
|
||||
}
|
||||
r, err := ref.Parse(text)
|
||||
if err != nil {
|
||||
m.dismissedLink = text
|
||||
m.showMenu("Link on clipboard can't be read", []string{m.st.Error.Render(err.Error())}, nil)
|
||||
return
|
||||
}
|
||||
if m.isSelected(r) {
|
||||
return
|
||||
}
|
||||
m.dismissedLink = text
|
||||
m.offerRef("Link on clipboard", r, true)
|
||||
}
|
||||
|
||||
// isSelected reports whether r points at the selected container, or the
|
||||
// selected object in it.
|
||||
func (m Model) isSelected(r ref.Ref) bool {
|
||||
c, ok := m.containers.Selected()
|
||||
if !ok || c.Source.Kind != r.Kind || !matchesContainer(c, r) {
|
||||
return false
|
||||
}
|
||||
if r.IsContainer() {
|
||||
return true
|
||||
}
|
||||
o, ok := m.detail.Secret()
|
||||
return ok && o.Name == r.Name && (r.Type == "" || o.Type == r.Type) &&
|
||||
(r.Version == "" || o.Version == r.Version)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
func (d *driver) note(text string) {
|
||||
d.t.Helper()
|
||||
d.keys("n")
|
||||
if d.m.prompt == nil || !strings.HasPrefix(d.m.prompt.title, "Note on ") {
|
||||
d.t.Fatalf("n didn't open the note popup:\n%s", d.screen())
|
||||
}
|
||||
for range 40 { // clear what's there
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyBackspace})
|
||||
}
|
||||
for _, r := range text {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.keys("enter")
|
||||
}
|
||||
|
||||
func TestNotesOnRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
mem, _ := store.Load(path)
|
||||
d := newDriverOpts(t, Options{Store: mem})
|
||||
|
||||
d.keys("3", "j")
|
||||
d.note("stripe live")
|
||||
d.keys("2")
|
||||
d.note("app secrets")
|
||||
d.keys("1")
|
||||
d.note("main prod")
|
||||
|
||||
screen := d.screen()
|
||||
for _, want := range []string{"stripe live", "app secrets", "main prod", "note stripe live"} {
|
||||
if !strings.Contains(screen, want) {
|
||||
t.Errorf("missing %q:\n%s", want, screen)
|
||||
}
|
||||
}
|
||||
|
||||
// The filter matches notes.
|
||||
d.keys("3", "/")
|
||||
d.typeText("stripe")
|
||||
if _, _, s := d.selected(); s != "api-key" || d.m.secrets.Len() != 1 {
|
||||
t.Errorf("filter by note: %q (%d)", s, d.m.secrets.Len())
|
||||
}
|
||||
d.keys("esc")
|
||||
|
||||
// Saved, and an empty note removes it.
|
||||
again, _ := store.Load(path)
|
||||
notes := 0
|
||||
for _, n := range again.Nodes() {
|
||||
if n.Note != "" {
|
||||
notes++
|
||||
}
|
||||
}
|
||||
if notes != 3 {
|
||||
t.Errorf("saved notes = %v", again.Nodes())
|
||||
}
|
||||
d.keys("3")
|
||||
d.note("")
|
||||
if strings.Contains(d.screen(), "stripe live") {
|
||||
t.Error("empty note didn't remove it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNoteGivesWay(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := styles.Default()
|
||||
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 ||
|
||||
!strings.HasSuffix(stripANSI(got), "prod SQL admin") {
|
||||
t.Errorf("fits: %q", stripANSI(got))
|
||||
}
|
||||
if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
|
||||
!strings.HasSuffix(got, "…") {
|
||||
t.Errorf("tight: %q", got)
|
||||
}
|
||||
if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
|
||||
t.Errorf("no room: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func stripANSI(s string) string { return ansi.Strip(s) }
|
||||
@@ -0,0 +1,240 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
const sopsFile = `db:
|
||||
password: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
|
||||
sops:
|
||||
age:
|
||||
- recipient: age1xxxx
|
||||
lastmodified: "2026-10-09T10:05:29Z"
|
||||
mac: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
|
||||
`
|
||||
|
||||
// gitRepoWith makes a git repository holding a SOPS file, with a remote.
|
||||
func gitRepoWith(t *testing.T, remote string) string {
|
||||
t.Helper()
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
os.MkdirAll(filepath.Join(root, "secrets"), 0o755)
|
||||
os.WriteFile(filepath.Join(root, "secrets", "app.yaml"), []byte(sopsFile), 0o644)
|
||||
for _, args := range [][]string{{"init", "-q"}, {"add", "."}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"},
|
||||
{"remote", "add", "origin", remote}} {
|
||||
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestCurrentRepoFirst(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
here := gitRepoWith(t, "https://git.example.com/team/here.git")
|
||||
other := gitRepoWith(t, "https://git.example.com/team/aaa-other.git")
|
||||
mem, _ := store.Load("")
|
||||
mem.Update("lazysecrets://repo/git.example.com%2Fteam%2Faaa-other", func(n *store.Node) {
|
||||
n.Source, n.StarredBy = other, []string{"Repos"}
|
||||
})
|
||||
d := newDriverOpts(t, Options{Store: mem}, repo.New(repo.Settings{Dir: here, Starred: []string{other}}))
|
||||
// Grouped: the repo it started in on a row of its own on top,
|
||||
// selected, then the other repos under Repos, without it.
|
||||
var rows []string
|
||||
for _, r := range d.m.sources.Items() {
|
||||
switch r.kind {
|
||||
case rowGroup:
|
||||
rows = append(rows, "group "+r.label)
|
||||
case rowSource:
|
||||
rows = append(rows, r.source.ID)
|
||||
}
|
||||
}
|
||||
if want := []string{here, "group Repos", other}; !slices.Equal(rows, want) {
|
||||
t.Errorf("rows %v, want %v", rows, want)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "Repo · "+filepath.Base(here)) {
|
||||
t.Errorf("no Repo · name row:\n%s", d.screen())
|
||||
}
|
||||
if src, ok := d.m.selectedSource(); !ok || src.ID != here {
|
||||
t.Errorf("selected %v, want the repo it started in", src.ID)
|
||||
}
|
||||
|
||||
// Flat: it sorts first, labelled.
|
||||
d.keys("`")
|
||||
if !strings.Contains(d.screen(), filepath.Base(here)+" · here") {
|
||||
t.Errorf("no here label in the flat list:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoSource(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
os.MkdirAll(filepath.Join(root, "secrets"), 0o755)
|
||||
os.WriteFile(filepath.Join(root, "secrets", "app.yaml"), []byte(sopsFile), 0o644)
|
||||
for _, args := range [][]string{{"init", "-q"}, {"add", "."}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"},
|
||||
{"remote", "add", "origin", "https://git.example.com/team/infra.git"}} {
|
||||
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
d := newDriverOpts(t, Options{}, []provider.Provider{repo.New(repo.Settings{Dir: root})}...)
|
||||
scr := d.screen()
|
||||
for _, want := range []string{"Repos", filepath.Base(root), "[2] Files", "secrets/app.yaml sops · age", "db.password"} {
|
||||
if !strings.Contains(scr, want) {
|
||||
t.Fatalf("missing %q:\n%s", want, scr)
|
||||
}
|
||||
}
|
||||
d.keys("3", "f")
|
||||
key := "lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password"
|
||||
if !d.m.store.Node(key).StarredFor("Repos") {
|
||||
t.Errorf("star not stored under the repo link; nodes: %v", d.m.store.Nodes())
|
||||
}
|
||||
d.keys("s")
|
||||
if got := d.clip().content; got != key {
|
||||
t.Errorf("shared link %q", got)
|
||||
}
|
||||
|
||||
// Opening the link finds the repo by its remote and goes there.
|
||||
d.keys("1")
|
||||
r, _ := ref.Parse(key)
|
||||
d.send(OpenLink(r))
|
||||
d.keys("o")
|
||||
if src, c, s := d.selected(); src != filepath.Base(root) || c != "secrets/app.yaml" || s != "db.password" {
|
||||
t.Errorf("opened %s/%s/%s\n%s", src, c, s, d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReposLoadThemselves(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
root := gitRepoWith(t, "https://git.example.com/team/x.git")
|
||||
// Started elsewhere, so the repo isn't the selected source.
|
||||
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Dir: t.TempDir(), Paths: []string{root}}))
|
||||
d.openSearch()
|
||||
d.searchAll()
|
||||
d.typeText("db.password")
|
||||
if !strings.Contains(d.screen(), "secrets/app.yaml / db.password") {
|
||||
t.Errorf("repo keys not searchable without loading them:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupsStartCollapsed(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
here := gitRepoWith(t, "https://git.example.com/team/here.git")
|
||||
d := newDriverOpts(t, Options{CollapseGroups: true},
|
||||
fake.Azure(0), fake.Databricks(0), repo.New(repo.Settings{Dir: here}))
|
||||
var rows []string
|
||||
for _, r := range d.m.sources.Items() {
|
||||
switch r.kind {
|
||||
case rowGroup:
|
||||
rows = append(rows, "group "+r.label)
|
||||
case rowSource:
|
||||
rows = append(rows, r.source.Name)
|
||||
}
|
||||
}
|
||||
// Folded groups, and the repo it started in on top, selected, so its
|
||||
// files show.
|
||||
if want := []string{filepath.Base(here), "group Azure", "group Databricks"}; !slices.Equal(rows, want) {
|
||||
t.Errorf("rows %v, want %v", rows, want)
|
||||
}
|
||||
if src, ok := d.m.selectedSource(); !ok || src.ID != here {
|
||||
t.Errorf("selected %v, want the repo it started in", src.ID)
|
||||
}
|
||||
d.keys("j", "enter") // unfold Azure
|
||||
if d.sourceCount() < 2 {
|
||||
t.Errorf("enter on a folded group didn't unfold it:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentRepoBeforeTheRest(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
here := gitRepoWith(t, "https://git.example.com/team/here.git")
|
||||
p := repo.New(repo.Settings{Dir: here})
|
||||
d := newDriverOpts(t, Options{}, fake.Azure(0), p)
|
||||
// Sources asked again, nothing answered yet but the started-in repo
|
||||
// on its own (provider.Current): it shows on top already.
|
||||
d.m.loadSources()
|
||||
src, ok := p.Current(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("no current repo")
|
||||
}
|
||||
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
||||
d.send(currentMsg{gen: d.m.sourcesGen, source: src})
|
||||
if !strings.Contains(d.screen(), "Repo · "+filepath.Base(here)) {
|
||||
t.Errorf("the started-in repo waits for the others:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchAllRepos(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
a := gitRepoWith(t, "https://git.example.com/team/alpha.git")
|
||||
b := gitRepoWith(t, "https://git.example.com/team/beta.git")
|
||||
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Paths: []string{a, b}}))
|
||||
d.openSearch()
|
||||
d.send(ctrl('h'))
|
||||
for d.m.search.src > 0 {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||
}
|
||||
for d.m.search.scope().id != kindScope+string(provider.KindRepo) {
|
||||
if d.m.search.src == len(d.m.search.scopes)-1 {
|
||||
t.Fatalf("no All repos scope: %v", d.m.search.scopes)
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
}
|
||||
azure := func() (n int) {
|
||||
for row := range d.m.cache.containers {
|
||||
if p, _ := splitSourceRow(row); p != "Repos" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
azureBefore := azure()
|
||||
d.typeText("password")
|
||||
var got []string
|
||||
for _, o := range d.m.search.shown {
|
||||
if o.kind != provider.KindRepo {
|
||||
t.Errorf("not a repo: %s", ansiStrip(o.label))
|
||||
}
|
||||
got = append(got, ansiStrip(o.label))
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("want db.password in both repos: %v", got)
|
||||
}
|
||||
if azure() != azureBefore || d.m.crawl.running {
|
||||
t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure())
|
||||
}
|
||||
if !strings.Contains(d.screen(), "Results in all repos") {
|
||||
t.Errorf("title:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
// ctrl+s searches everything loaded: subscriptions and workspaces, vaults
|
||||
// and scopes, and the secrets, keys and certificates of every vault opened
|
||||
// this session. Azure has no search across vaults, so ctrl+a in the popup
|
||||
// loads the rest in the background, a few requests at a time; results
|
||||
// appear as lists arrive, and the panes use them too.
|
||||
|
||||
const crawlWorkers = 4
|
||||
|
||||
// crawl loads every vault's secrets for search.
|
||||
type crawl struct {
|
||||
running bool
|
||||
queue []crawlJob
|
||||
active int
|
||||
pending map[string]bool // cache keys of lists queued or loading
|
||||
failed map[string]bool // cache keys of lists that couldn't be read
|
||||
}
|
||||
|
||||
type crawlJob struct {
|
||||
src *provider.Source // list its vaults or scopes
|
||||
container *provider.Container // list its secrets
|
||||
row string // the source's row key (containers cache key)
|
||||
force bool // reload what's loaded already
|
||||
}
|
||||
|
||||
type crawlMsg struct {
|
||||
job crawlJob
|
||||
containers []provider.Container
|
||||
objects []provider.Object
|
||||
err error
|
||||
}
|
||||
|
||||
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), d)
|
||||
}
|
||||
|
||||
// searchEntries lists everything loaded: the sources (with how much of
|
||||
// each is loaded) and the vaults, scopes and secrets in them, each with
|
||||
// its source's row key as group.
|
||||
func (m *Model) searchEntries() (sources, items []option) {
|
||||
type entry struct {
|
||||
depth int
|
||||
sort string
|
||||
opt option
|
||||
}
|
||||
var es []entry
|
||||
// key is the store key, so ctrl+f stars the entry; mark shows how
|
||||
// much is loaded, reload (ctrl+r) loads it again.
|
||||
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) {
|
||||
n := m.store.Node(key)
|
||||
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
||||
es = append(es, entry{depth, sort,
|
||||
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
|
||||
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
|
||||
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
|
||||
}
|
||||
for _, p := range m.providers {
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
|
||||
m.nav = &nav{sourceRow: row, sourceOnly: true}
|
||||
return nil
|
||||
}, func(m *Model) tea.Cmd { return m.loadSource(src) })
|
||||
for _, c := range m.cache.containers[row].value {
|
||||
r, err := ref.ForContainer(c, provider.TypeSecret)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) }
|
||||
add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
|
||||
m.startNav(r, actGo, row)
|
||||
return nil
|
||||
}, reload)
|
||||
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
||||
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
||||
r, err := ref.ForObject(o, "")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
suffix := accountOf(src)
|
||||
if t != provider.TypeSecret {
|
||||
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
|
||||
suffix = strings.Trim(typ+" · "+suffix, " ·")
|
||||
}
|
||||
add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
|
||||
m.startNav(r, actGo, row)
|
||||
return nil
|
||||
}, reload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(es, func(a, b entry) int { return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort)) })
|
||||
for _, e := range es {
|
||||
if e.depth == 0 {
|
||||
sources = append(sources, e.opt)
|
||||
} else {
|
||||
items = append(items, e.opt)
|
||||
}
|
||||
}
|
||||
return sources, items
|
||||
}
|
||||
|
||||
// coverage is how much of everything search can see: sources whose
|
||||
// vaults are listed, vaults whose secrets are loaded, and secrets. Lists
|
||||
// that couldn't be read count as done.
|
||||
type coverage struct {
|
||||
sources, sourcesListed int
|
||||
vaults, vaultsLoaded int
|
||||
secrets, unreadable int
|
||||
}
|
||||
|
||||
func (c coverage) complete() bool {
|
||||
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
|
||||
}
|
||||
|
||||
func (m *Model) searchCoverage() coverage { return m.coverageOf("") }
|
||||
|
||||
// coverageOf is searchCoverage for one kind of provider ("" for all).
|
||||
func (m *Model) coverageOf(kind provider.Kind) coverage {
|
||||
var c coverage
|
||||
for _, p := range m.providers {
|
||||
if kind != "" && p.Kind() != kind {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
c.sources++
|
||||
e, ok := m.cache.containers[row]
|
||||
if ok || m.crawl.failed[row] {
|
||||
c.sourcesListed++
|
||||
}
|
||||
if m.crawl.failed[row] {
|
||||
c.unreadable++
|
||||
}
|
||||
for _, v := range e.value {
|
||||
c.vaults++
|
||||
key := containerKey(v) + "|" + string(provider.TypeSecret)
|
||||
if objs, ok := m.cache.objects[key]; ok {
|
||||
c.vaultsLoaded++
|
||||
c.secrets += len(objs.value)
|
||||
} else if m.crawl.failed[key] {
|
||||
c.vaultsLoaded++
|
||||
c.unreadable++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// searchLines says what the search covers.
|
||||
func (m *Model) searchLines() []string {
|
||||
c := m.searchCoverage()
|
||||
switch {
|
||||
case m.crawl.running:
|
||||
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
|
||||
len(m.crawl.queue)+m.crawl.active))}
|
||||
case c.complete():
|
||||
s := fmt.Sprintf("Searching everything: %s, %s, %s",
|
||||
plural(c.sources, "source"), plural(c.vaults, "vault"), plural(c.secrets, "secret"))
|
||||
if c.unreadable > 0 {
|
||||
s += fmt.Sprintf(" (%s unreadable)", plural(c.unreadable, "list"))
|
||||
}
|
||||
return []string{m.st.Success.Render("✓") + " " + m.st.Notice.Render(s)}
|
||||
}
|
||||
s := fmt.Sprintf("Only what's loaded: %d/%d vaults", c.vaultsLoaded, c.vaults)
|
||||
if c.sourcesListed < c.sources {
|
||||
s += fmt.Sprintf(", %d/%d sources listed", c.sourcesListed, c.sources)
|
||||
}
|
||||
return []string{m.st.Warning.Render("⚠ " + s + "." + withKey(m.loadAllKey(), " %s loads the rest."))}
|
||||
}
|
||||
|
||||
func plural(n int, word string) string {
|
||||
if n == 1 {
|
||||
return "1 " + word
|
||||
}
|
||||
return fmt.Sprintf("%d %ss", n, word)
|
||||
}
|
||||
|
||||
// startCrawl loads every unlisted subscription's vaults and every vault's
|
||||
// secrets not loaded yet.
|
||||
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf("") }
|
||||
|
||||
// startCrawlOf is startCrawl for one kind of provider ("" for all).
|
||||
func (m *Model) startCrawlOf(kind provider.Kind) tea.Cmd {
|
||||
for _, p := range m.providers {
|
||||
if kind != "" && p.Kind() != kind {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
||||
m.queueSource(src, false)
|
||||
}
|
||||
}
|
||||
return m.crawlStart()
|
||||
}
|
||||
|
||||
// loadSource loads (again) a subscription's vaults and their secrets.
|
||||
func (m *Model) loadSource(src provider.Source) tea.Cmd {
|
||||
m.queueSource(src, true)
|
||||
return m.crawlStart()
|
||||
}
|
||||
|
||||
// loadContainer loads (again) a vault's secrets.
|
||||
func (m *Model) loadContainer(c provider.Container) tea.Cmd {
|
||||
m.queueContainer(c, sourceRowKey(c.Source.Provider, c.Source.ID), true)
|
||||
return m.crawlStart()
|
||||
}
|
||||
|
||||
// crawlStart runs what's queued, and shows it in the popup.
|
||||
func (m *Model) crawlStart() tea.Cmd {
|
||||
cmd := m.crawlNext()
|
||||
m.refreshSearch()
|
||||
return cmd
|
||||
}
|
||||
|
||||
// queueSource queues listing src's vaults or, with them listed and not
|
||||
// force, loading their secrets.
|
||||
func (m *Model) queueSource(src provider.Source, force bool) {
|
||||
row := sourceRowKey(src.Provider, src.ID)
|
||||
if e, ok := m.cache.containers[row]; ok && !force {
|
||||
for _, c := range e.value {
|
||||
m.queueContainer(c, row, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
m.enqueue(crawlJob{src: &src, row: row, force: force}, row)
|
||||
}
|
||||
|
||||
// queueContainer queues loading c's secrets, unless loaded and not force.
|
||||
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
if _, ok := m.cache.objects[key]; ok && !force {
|
||||
return
|
||||
}
|
||||
m.enqueue(crawlJob{container: &c, row: row, force: force}, key)
|
||||
}
|
||||
|
||||
func (m *Model) enqueue(job crawlJob, key string) {
|
||||
if m.crawl.pending == nil {
|
||||
m.crawl.pending, m.crawl.failed = map[string]bool{}, map[string]bool{}
|
||||
}
|
||||
if m.crawl.pending[key] {
|
||||
return // queued or loading already
|
||||
}
|
||||
m.crawl.pending[key] = true
|
||||
m.crawl.queue = append(m.crawl.queue, job)
|
||||
}
|
||||
|
||||
// crawlNext starts jobs up to crawlWorkers at a time.
|
||||
func (m *Model) crawlNext() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for m.crawl.active < crawlWorkers && len(m.crawl.queue) > 0 {
|
||||
job := m.crawl.queue[0]
|
||||
m.crawl.queue = m.crawl.queue[1:]
|
||||
m.crawl.active++
|
||||
cmds = append(cmds, m.crawlCmd(job))
|
||||
}
|
||||
m.crawl.running = m.crawl.active > 0 || len(m.crawl.queue) > 0
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
|
||||
timeout := m.timeout
|
||||
if job.src != nil {
|
||||
p := m.providerFor(*job.src)
|
||||
src := *job.src
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := contextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
items, err := p.Containers(ctx, src)
|
||||
for i := range items {
|
||||
items[i].Source = src
|
||||
}
|
||||
return crawlMsg{job: job, containers: items, err: err}
|
||||
}
|
||||
}
|
||||
c := *job.container
|
||||
p := m.providerFor(c.Source)
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := contextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
items, err := p.Objects(ctx, c, provider.TypeSecret)
|
||||
for i := range items {
|
||||
items[i].Container = c
|
||||
}
|
||||
return crawlMsg{job: job, objects: items, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
|
||||
m.crawl.active--
|
||||
key := msg.job.row
|
||||
if msg.job.container != nil {
|
||||
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
|
||||
}
|
||||
delete(m.crawl.pending, key)
|
||||
switch {
|
||||
case msg.err != nil:
|
||||
m.crawl.failed[key] = true
|
||||
case msg.job.src != nil:
|
||||
delete(m.crawl.failed, key)
|
||||
m.cache.containers[key] = entry[[]provider.Container]{msg.containers, m.now()}
|
||||
for _, c := range msg.containers {
|
||||
m.queueContainer(c, key, msg.job.force)
|
||||
}
|
||||
default:
|
||||
delete(m.crawl.failed, key)
|
||||
m.cache.objects[key] = entry[[]provider.Object]{msg.objects, m.now()}
|
||||
}
|
||||
cmd := m.crawlNext()
|
||||
m.refreshSearch()
|
||||
return cmd
|
||||
}
|
||||
|
||||
// sourceMark shows how much of a subscription or workspace is loaded:
|
||||
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
|
||||
func (m *Model) sourceMark(row string) string {
|
||||
if m.crawl.pending[row] {
|
||||
return m.st.Muted.Render("◌")
|
||||
}
|
||||
if m.crawl.failed[row] {
|
||||
return m.st.Error.Render("✗")
|
||||
}
|
||||
e, ok := m.cache.containers[row]
|
||||
if !ok {
|
||||
return m.st.Muted.Render("○")
|
||||
}
|
||||
loaded, loading := 0, false
|
||||
for _, c := range e.value {
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
loading = loading || m.crawl.pending[key]
|
||||
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
||||
loaded++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case loading:
|
||||
return m.st.Muted.Render("◌")
|
||||
case loaded == len(e.value):
|
||||
return m.st.Success.Render("●")
|
||||
case loaded > 0:
|
||||
return m.st.Warning.Render("◐")
|
||||
}
|
||||
return m.st.Muted.Render("○")
|
||||
}
|
||||
|
||||
// vaultMark marks a vault in search only when its secrets can't be read
|
||||
// (✗) or are loading (◌): whether a subscription is loaded says whether
|
||||
// its vaults are searchable.
|
||||
func (m *Model) vaultMark(c provider.Container) string {
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
switch {
|
||||
case m.crawl.pending[key]:
|
||||
return m.st.Muted.Render("◌")
|
||||
case m.crawl.failed[key]:
|
||||
return m.st.Error.Render("✗")
|
||||
}
|
||||
return " "
|
||||
}
|
||||
|
||||
// providerOf is the provider ID in a source row key.
|
||||
func providerOf(row string) string {
|
||||
p, _ := splitSourceRow(row)
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func (d *driver) openSearch() {
|
||||
d.t.Helper()
|
||||
d.send(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl})
|
||||
if d.m.search == nil {
|
||||
d.t.Fatalf("no search popup:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func ctrl(r rune) tea.KeyPressMsg { return tea.KeyPressMsg{Code: r, Mod: tea.ModCtrl} }
|
||||
|
||||
// searchAll picks All on the left and goes back to the results.
|
||||
func (d *driver) searchAll() {
|
||||
d.send(ctrl('h'))
|
||||
for d.m.search.src > 0 {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||
}
|
||||
for d.m.search.scope().id != allSources {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
}
|
||||
d.send(ctrl('l'))
|
||||
}
|
||||
|
||||
func TestSearchLoaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("j") // platform-dev: away from what's loaded
|
||||
d.openSearch()
|
||||
for _, want := range []string{"Sources", "Results", "All", "platform-dev"} {
|
||||
if !strings.Contains(d.screen(), want) {
|
||||
t.Fatalf("missing %q:\n%s", want, d.screen())
|
||||
}
|
||||
}
|
||||
d.typeText("api-key")
|
||||
if !strings.Contains(d.screen(), "no matches here · All searches everything") {
|
||||
t.Errorf("no hint to search everything:\n%s", d.screen())
|
||||
}
|
||||
d.searchAll()
|
||||
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") {
|
||||
t.Fatalf("loaded secret not found:\n%s", d.screen())
|
||||
}
|
||||
d.keys("enter")
|
||||
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
|
||||
t.Errorf("jumped to %s/%s/%s", src, c, s)
|
||||
}
|
||||
if d.m.search != nil {
|
||||
t.Error("popup still open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchLoadsAsYouType(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.openSearch() // on the source the cursor is on: platform-prod
|
||||
// Typing loads the scope: that source, without ctrl+a.
|
||||
d.typeText("smtp")
|
||||
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
|
||||
t.Fatalf("not found in a vault that wasn't loaded:\n%s", d.screen())
|
||||
}
|
||||
if _, ok := d.m.cache.objects["Azure|sub-prod/kv-shared-prod|Secret"]; !ok {
|
||||
t.Error("loaded list not in the panes' cache")
|
||||
}
|
||||
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; ok {
|
||||
t.Error("another source loaded too")
|
||||
}
|
||||
|
||||
// On All: everything.
|
||||
d.keys("tab") // to the scopes
|
||||
for d.m.search.src > 0 {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||
}
|
||||
d.typeText("-")
|
||||
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok {
|
||||
t.Errorf("typing on All didn't load the other sources:\n%s", d.screen())
|
||||
}
|
||||
if d.m.search.input.Value() != "smtp-" {
|
||||
t.Error("query lost while loading")
|
||||
}
|
||||
if !strings.Contains(d.screen(), "unreadable") { // kv-restricted refuses
|
||||
t.Errorf("unreadable vaults not counted:\n%s", d.screen())
|
||||
}
|
||||
// Once: the next key doesn't ask for the list that failed again;
|
||||
// ctrl+a still does, on purpose.
|
||||
d.typeText("p")
|
||||
if d.m.crawl.running {
|
||||
t.Error("typing on reloads what failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSourceNarrowsAndLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.openSearch()
|
||||
d.keys("tab") // to Sources
|
||||
sel := func() string { return d.m.search.scopes[d.m.search.src].id }
|
||||
for d.m.search.src > 0 {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||
}
|
||||
for !strings.HasPrefix(sel(), "platform-dev") {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
if d.m.search.src == len(d.m.search.scopes)-1 {
|
||||
t.Fatal("platform-dev not in sources")
|
||||
}
|
||||
}
|
||||
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "○") {
|
||||
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
|
||||
}
|
||||
if len(d.m.search.shown) != 0 {
|
||||
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
|
||||
}
|
||||
d.send(ctrl('r'))
|
||||
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "●") {
|
||||
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
|
||||
}
|
||||
for _, o := range d.m.search.shown {
|
||||
if !strings.Contains(ansiStrip(o.label), "platform-dev") {
|
||||
t.Errorf("result from elsewhere: %q", ansiStrip(o.label))
|
||||
}
|
||||
}
|
||||
if len(d.m.search.shown) == 0 {
|
||||
t.Error("nothing shown after loading platform-dev")
|
||||
}
|
||||
if _, ok := d.m.cache.objects["Azure|sub-prod/kv-shared-prod|Secret"]; ok {
|
||||
t.Error("loaded another subscription too")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchResultsStayPut(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.openSearch()
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
ids := func() []string {
|
||||
var out []string
|
||||
for _, o := range d.m.search.shown {
|
||||
out = append(out, o.id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
before, at := ids(), d.m.search.shown[d.m.search.res].id
|
||||
d.send(ctrl('a'))
|
||||
after := ids()
|
||||
if len(after) <= len(before) {
|
||||
t.Fatalf("nothing loaded: %d → %d", len(before), len(after))
|
||||
}
|
||||
for i, id := range before {
|
||||
if after[i] != id {
|
||||
t.Errorf("row %d changed from %q to %q", i, id, after[i])
|
||||
}
|
||||
}
|
||||
if got := d.m.search.shown[d.m.search.res].id; got != at {
|
||||
t.Errorf("cursor moved from %q to %q", at, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarFromSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.openSearch()
|
||||
d.typeText("api-key")
|
||||
d.send(ctrl('f'))
|
||||
if !d.m.store.Node(kvKey + "/secrets/api-key").StarredFor("Azure") {
|
||||
t.Errorf("ctrl+f in search didn't star:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func ansiStrip(s string) string { return ansi.Strip(s) }
|
||||
|
||||
func TestSearchStartsWhereYouAre(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("2", "j") // platform-prod / kv-shared-prod
|
||||
d.openSearch()
|
||||
v := d.m.search
|
||||
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-prod") {
|
||||
t.Errorf("source not preselected: %q", got)
|
||||
}
|
||||
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
|
||||
t.Errorf("vault not under the cursor:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// From the Sources pane: just the source.
|
||||
d.keys("esc", "esc", "1", "j") // stop typing, close; platform-dev
|
||||
d.openSearch()
|
||||
v = d.m.search
|
||||
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-dev") {
|
||||
t.Errorf("source not preselected: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.openSearch()
|
||||
d.searchAll()
|
||||
if !strings.Contains(d.screen(), "vaults loaded · ctrl+a loads everything") {
|
||||
t.Errorf("no hint on All:\n%s", d.screen())
|
||||
}
|
||||
d.send(ctrl('a'))
|
||||
if strings.Contains(d.screen(), "ctrl+a loads everything") {
|
||||
t.Errorf("hint after loading everything:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoToTabsAndModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("2", "f", "j", "f") // two favourites
|
||||
d.keys("F")
|
||||
v := d.m.search
|
||||
if v.tab != tabFavs || v.typing {
|
||||
t.Fatalf("F: tab %d, typing %v", v.tab, v.typing)
|
||||
}
|
||||
d.keys("j") // browsing: j moves, doesn't type
|
||||
if v.res != 1 || v.input.Value() != "" {
|
||||
t.Errorf("j: cursor %d, input %q", v.res, v.input.Value())
|
||||
}
|
||||
d.keys("]")
|
||||
if v.tab != tabRecent {
|
||||
t.Errorf("] → tab %d, want Recent", v.tab)
|
||||
}
|
||||
d.keys("]", "]")
|
||||
if v.tab != tabFavs {
|
||||
t.Errorf("] wraps to tab %d, want Favourites", v.tab)
|
||||
}
|
||||
d.keys("[")
|
||||
if v.tab != tabSearch || !strings.Contains(d.screen(), "Scope") {
|
||||
t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen())
|
||||
}
|
||||
|
||||
d.keys("/")
|
||||
d.typeText("shared")
|
||||
if !v.typing || len(v.shown) == 0 {
|
||||
t.Fatalf("typing: %v, %d results", v.typing, len(v.shown))
|
||||
}
|
||||
d.keys("esc") // clears, back to browsing
|
||||
if v.typing || v.input.Value() != "" || d.m.search == nil {
|
||||
t.Errorf("esc while typing: typing %v, input %q, open %v", v.typing, v.input.Value(), d.m.search != nil)
|
||||
}
|
||||
d.keys("esc") // closes
|
||||
if d.m.search != nil {
|
||||
t.Error("esc while browsing didn't close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHighlightsMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Icons: labels start with a multi-byte glyph, where byte offsets and
|
||||
// characters differ.
|
||||
d := newDriverOpts(t, Options{Icons: true})
|
||||
d.openSearch()
|
||||
d.typeText("dbpw")
|
||||
if len(d.m.search.shown) == 0 {
|
||||
t.Fatal("no results")
|
||||
}
|
||||
o := d.m.search.shown[0]
|
||||
plain := []rune(ansiStrip(o.label))
|
||||
var got string
|
||||
for _, h := range o.hits {
|
||||
got += string(plain[h])
|
||||
}
|
||||
if got != "dbpw" {
|
||||
t.Errorf("hits %v spell %q in %q", o.hits, got, string(plain))
|
||||
}
|
||||
with := d.m.searchViewBox()
|
||||
for i := range d.m.search.shown {
|
||||
d.m.search.shown[i].hits = nil
|
||||
}
|
||||
if with == d.m.searchViewBox() {
|
||||
t.Error("matched characters not styled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpBarFollowsPopup(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("F")
|
||||
bar := lastLine(d.screen())
|
||||
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
|
||||
if !strings.Contains(bar, want) {
|
||||
t.Errorf("browsing: help bar %q lacks %q", bar, want)
|
||||
}
|
||||
}
|
||||
d.keys("/")
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
|
||||
t.Errorf("typing: help bar %q", bar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpFromPopups(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("F", "?")
|
||||
if d.m.prompt == nil || d.m.prompt.title != "Keybindings" || d.m.prompt.options[0].label != "Go to" {
|
||||
t.Fatalf("? in Go to:\n%s", d.screen())
|
||||
}
|
||||
if !strings.Contains(d.screen(), "switch list") { // tab: not on the help bar
|
||||
t.Errorf("Go to's keys missing:\n%s", d.screen())
|
||||
}
|
||||
var sections []string
|
||||
for _, o := range d.m.prompt.options {
|
||||
if o.header {
|
||||
sections = append(sections, o.label)
|
||||
}
|
||||
if strings.Contains(ansiStrip(o.label), "copy value") {
|
||||
t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label))
|
||||
}
|
||||
}
|
||||
if strings.Join(sections, ",") != "Go to,While typing, after /" {
|
||||
t.Errorf("sections %v", sections)
|
||||
}
|
||||
d.keys("esc") // back to Go to, where it was
|
||||
if d.m.search == nil || d.m.search.tab != tabFavs || d.m.prompt != nil {
|
||||
t.Errorf("not back in Go to: search %v, prompt %v", d.m.search != nil, d.m.prompt != nil)
|
||||
}
|
||||
|
||||
// Picking a key in the list runs it in Go to.
|
||||
d.keys("?")
|
||||
d.typeText("next tab")
|
||||
d.keys("enter")
|
||||
if d.m.search == nil || d.m.search.tab != tabRecent {
|
||||
t.Errorf("running ] from ? didn't switch Go to's tab")
|
||||
}
|
||||
|
||||
// Menus too.
|
||||
d.keys("esc", "3", "S", "?")
|
||||
if d.m.prompt == nil || d.m.prompt.options[0].label != "This menu" {
|
||||
t.Fatalf("? in a menu:\n%s", d.screen())
|
||||
}
|
||||
for _, o := range d.m.prompt.options {
|
||||
if o.header && o.label != "This menu" {
|
||||
t.Errorf("menu help has section %q", o.label)
|
||||
}
|
||||
}
|
||||
d.keys("esc")
|
||||
if d.m.prompt == nil || !strings.HasPrefix(d.m.prompt.title, "Share") {
|
||||
t.Errorf("not back in the share menu:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpRunsTypingKeysInGoTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("ctrl+s", "esc") // Go to on Search, browsing
|
||||
before := d.m.search.results
|
||||
d.keys("?")
|
||||
d.typeText("ctrl+h") // "sources", a typing key
|
||||
d.keys("enter")
|
||||
if d.m.search == nil || !d.m.search.typing || d.m.search.results || !before {
|
||||
t.Errorf("typing key from ? didn't run: typing %v, results %v", d.m.search != nil && d.m.search.typing, d.m.search.results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoToHelpBarEndsWithHelp(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t) // 100 columns: Go to's keys don't all fit
|
||||
d.keys("ctrl+s", "esc")
|
||||
bar := strings.TrimSpace(lastLine(d.screen()))
|
||||
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
|
||||
t.Errorf("help bar %q", bar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithEmptyResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := &counting{Provider: fake.Azure(0)}
|
||||
d := newDriverOpts(t, Options{}, c)
|
||||
d.openSearch()
|
||||
d.send(ctrl('h')) // pick platform-dev, never opened, in the scopes
|
||||
for d.m.search.src > 0 {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||
}
|
||||
for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ {
|
||||
if i > len(d.m.search.scopes) {
|
||||
var labels []string
|
||||
for _, o := range d.m.search.scopes {
|
||||
labels = append(labels, ansiStrip(o.label))
|
||||
}
|
||||
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
}
|
||||
d.send(ctrl('l'))
|
||||
if len(d.m.search.shown) != 0 || !d.m.search.results {
|
||||
t.Fatalf("want empty results with the cursor there: %d shown", len(d.m.search.shown))
|
||||
}
|
||||
before := c.containers
|
||||
d.send(ctrl('r')) // in the results, nothing selected: the source
|
||||
if c.containers == before {
|
||||
t.Error("ctrl+r with empty results didn't load the source")
|
||||
}
|
||||
if len(d.m.search.shown) == 0 {
|
||||
t.Errorf("nothing shown after loading:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// Go to is one popup for finding a place and going there. Tabs at the
|
||||
// top: Favourites and Recent (from the store, so they work before
|
||||
// anything loads) are one list each; Search has two lists side by side,
|
||||
// the scope on the left (All, or one source with how much of it is
|
||||
// loaded: ○ ◐ ● ◌ ✗) and what's in it on the right.
|
||||
//
|
||||
// Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f
|
||||
// star, / to type) or typing a filter (ctrl+ letters). esc while typing
|
||||
// clears the text and goes back to browsing; esc while browsing closes.
|
||||
// F opens it on Favourites and R on Recent, browsing; search on Search,
|
||||
// typing.
|
||||
//
|
||||
// The results don't move by themselves: as lists load, rows already shown
|
||||
// keep their order and new ones come after them, with the cursor on the
|
||||
// same entry. Starring keeps an entry where it is, its ★ showing the
|
||||
// change. Only typing or picking another tab or scope re-sorts them.
|
||||
|
||||
type searchView struct {
|
||||
input textinput.Model
|
||||
typing bool // the text field has the keyboard
|
||||
tab int // tabFavs, tabRecent or tabSearch
|
||||
results bool // in Search, the results have the cursor (else the scopes)
|
||||
|
||||
scopes []option // All, then the sources (Search)
|
||||
items []option // everything loaded, unfiltered
|
||||
favs []option // starred, as when the popup opened
|
||||
recent []option // recently used, as when the popup opened
|
||||
shown []option // the tab's (or scope's) entries matching the input
|
||||
src int // cursor in scopes
|
||||
res int // cursor in shown
|
||||
|
||||
// loaded are the scopes a search has loaded since the popup opened:
|
||||
// typing loads the scope once, so lists that fail aren't asked for
|
||||
// again on every key.
|
||||
loaded map[string]bool
|
||||
}
|
||||
|
||||
const (
|
||||
tabFavs = iota
|
||||
tabRecent
|
||||
tabSearch
|
||||
tabCount
|
||||
)
|
||||
|
||||
var tabNames = [tabCount]string{"Favourites", "Recent", "Search"}
|
||||
|
||||
const (
|
||||
scopeFavs = "#favs"
|
||||
scopeRecent = "#recent"
|
||||
allSources = "#all"
|
||||
kindScope = "#kind:" // + a provider kind: all of Azure, Databricks or repos
|
||||
)
|
||||
|
||||
const goToTitle = "Go to"
|
||||
|
||||
// showSearch opens Go to on Search, typing, in the source you're in
|
||||
// (else All), with the selected vault under the cursor.
|
||||
func (m *Model) showSearch() tea.Cmd {
|
||||
cmd := m.openGoTo(tabSearch, true)
|
||||
m.preselect()
|
||||
return cmd
|
||||
}
|
||||
|
||||
// showFavourites opens Go to on Favourites; showRecent on Recent. Both
|
||||
// start browsing: they're short lists.
|
||||
func (m *Model) showFavourites() tea.Cmd { return m.openGoTo(tabFavs, false) }
|
||||
func (m *Model) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) }
|
||||
|
||||
func (m *Model) openGoTo(tab int, typing bool) tea.Cmd {
|
||||
in := textinput.New()
|
||||
in.Prompt = "/"
|
||||
in.SetWidth(searchWidth(m.w) - 6) // as wide as the popup
|
||||
v := &searchView{input: in, tab: tab, results: true}
|
||||
v.favs, v.recent = m.storeEntries()
|
||||
m.search = v
|
||||
m.fillSearch(false)
|
||||
m.flash = flash{}
|
||||
return m.setTyping(typing)
|
||||
}
|
||||
|
||||
// setTyping gives the keyboard to the text field, or takes it back.
|
||||
func (m *Model) setTyping(on bool) tea.Cmd {
|
||||
v := m.search
|
||||
v.typing = on
|
||||
if on {
|
||||
v.input.Prompt, v.input.Placeholder = "/", "type to filter"
|
||||
return v.input.Focus()
|
||||
}
|
||||
v.input.Blur()
|
||||
v.input.Prompt = "" // the placeholder names the key
|
||||
if v.input.Value() != "" {
|
||||
v.input.Prompt = "/"
|
||||
}
|
||||
v.input.Placeholder = withKey(m.keys.GoFilter, "%s to filter")
|
||||
return nil
|
||||
}
|
||||
|
||||
// scope is what the results come from: the tab, or in Search the scope
|
||||
// picked on the left.
|
||||
func (v *searchView) scope() option {
|
||||
switch v.tab {
|
||||
case tabFavs:
|
||||
return option{id: scopeFavs}
|
||||
case tabRecent:
|
||||
return option{id: scopeRecent}
|
||||
}
|
||||
return v.scopes[v.src]
|
||||
}
|
||||
|
||||
// onSource reports whether the results come from one source.
|
||||
func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" }
|
||||
|
||||
// preselect starts where the user is: the selected source picked on the
|
||||
// left, and the selected vault or scope under the cursor on the right.
|
||||
func (m *Model) preselect() {
|
||||
v := m.search
|
||||
row := m.sources.SelectedID()
|
||||
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.group != "" && o.group == row })
|
||||
if i < 0 {
|
||||
return
|
||||
}
|
||||
v.src = i
|
||||
m.fillSearch(false)
|
||||
if m.focus == paneSources {
|
||||
return
|
||||
}
|
||||
if c, ok := m.containers.Selected(); ok {
|
||||
key := storeKey(c)
|
||||
if j := slices.IndexFunc(v.shown, func(o option) bool { return o.star == key }); j >= 0 {
|
||||
v.res = j
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fillSearch (re)builds the lists from what's loaded. keep keeps the
|
||||
// order of rows already shown (lists arrived); otherwise the results are
|
||||
// ranked afresh (the query, tab or scope changed).
|
||||
func (m *Model) fillSearch(keep bool) {
|
||||
v := m.search
|
||||
atSrc, atRes := "", ""
|
||||
if v.src < len(v.scopes) {
|
||||
atSrc = v.scopes[v.src].id
|
||||
}
|
||||
if v.res < len(v.shown) {
|
||||
atRes = v.shown[v.res].id
|
||||
}
|
||||
prev := map[string]int{}
|
||||
for i, o := range v.shown {
|
||||
prev[o.id] = i
|
||||
}
|
||||
|
||||
sources, items := m.searchEntries()
|
||||
all := option{label: "All", id: allSources, mark: m.coverageMark("")}
|
||||
scopes := []option{all}
|
||||
// All of one kind, so a search can stay on repos (local, quick)
|
||||
// without asking Azure for every vault; only with several kinds.
|
||||
var kinds []provider.Kind
|
||||
for _, p := range m.providers {
|
||||
if len(m.sourceResults[p.ID()].sources) > 0 && !slices.Contains(kinds, p.Kind()) {
|
||||
kinds = append(kinds, p.Kind())
|
||||
}
|
||||
}
|
||||
if len(kinds) > 1 {
|
||||
for _, k := range kinds {
|
||||
scopes = append(scopes, option{
|
||||
label: m.st.Kind(k).Render(m.icons.Prefix(k) + kindScopeLabel(k)),
|
||||
id: kindScope + string(k), kind: k, mark: m.coverageMark(k),
|
||||
})
|
||||
}
|
||||
}
|
||||
v.scopes, v.items = append(scopes, sources...), items
|
||||
v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc }))
|
||||
|
||||
v.shown = v.match()
|
||||
if keep {
|
||||
v.shown = stableOrder(v.shown, prev)
|
||||
if i := slices.IndexFunc(v.shown, func(o option) bool { return o.id == atRes }); i >= 0 {
|
||||
v.res = i
|
||||
}
|
||||
}
|
||||
v.res = max(0, min(v.res, len(v.shown)-1))
|
||||
}
|
||||
|
||||
// kindScopeLabel names the scope of all of a kind's sources.
|
||||
func kindScopeLabel(k provider.Kind) string {
|
||||
if k == provider.KindRepo {
|
||||
return "All repos"
|
||||
}
|
||||
return "All " + string(k)
|
||||
}
|
||||
|
||||
// coverageMark is ○ ◐ ● for how much of a kind ("" for all) is loaded.
|
||||
func (m *Model) coverageMark(kind provider.Kind) string {
|
||||
switch c := m.coverageOf(kind); {
|
||||
case c.complete():
|
||||
return m.st.Success.Render("●")
|
||||
case c.vaultsLoaded > 0:
|
||||
return m.st.Warning.Render("◐")
|
||||
}
|
||||
return m.st.Muted.Render("○")
|
||||
}
|
||||
|
||||
// scopeKind is the kind a scope covers all of, if it's one of those.
|
||||
func (v *searchView) scopeKind() (provider.Kind, bool) {
|
||||
k, ok := strings.CutPrefix(v.scope().id, kindScope)
|
||||
if !ok || v.tab != tabSearch {
|
||||
return "", false
|
||||
}
|
||||
return provider.Kind(k), true
|
||||
}
|
||||
|
||||
// match lists the scope's entries matching the input, best first.
|
||||
func (v *searchView) match() []option {
|
||||
var cand []option
|
||||
switch sc := v.scope(); sc.id {
|
||||
case scopeFavs:
|
||||
cand = v.favs
|
||||
case scopeRecent:
|
||||
cand = v.recent
|
||||
default:
|
||||
kind, ofKind := v.scopeKind()
|
||||
for _, o := range v.items {
|
||||
if sc.id == allSources || ofKind && o.kind == kind || sc.group != "" && o.group == sc.group {
|
||||
cand = append(cand, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
q := v.input.Value()
|
||||
if q == "" {
|
||||
return slices.Clone(cand)
|
||||
}
|
||||
items := make([]match.Item, len(cand))
|
||||
for i, o := range cand {
|
||||
label := ansi.Strip(o.label)
|
||||
start, end := nameRange(label)
|
||||
items[i] = match.Item{Text: strings.TrimSpace(label + " " + o.note), NameStart: start, NameEnd: end}
|
||||
}
|
||||
var out []option
|
||||
for _, h := range match.Rank(match.Parse(q), items) {
|
||||
o := cand[h.Index]
|
||||
// Hits in the label are highlighted; ones in the note just count.
|
||||
n := utf8.RuneCountInString(ansi.Strip(o.label))
|
||||
o.hits = nil
|
||||
for _, p := range h.Pos {
|
||||
if p < n {
|
||||
o.hits = append(o.hits, p)
|
||||
}
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nameRange is where the item's own name is in a Go to label, in runes:
|
||||
// the last part of "sub / vault / secret · account", which matches count
|
||||
// most for.
|
||||
func nameRange(label string) (int, int) {
|
||||
end := len(label)
|
||||
if i := strings.LastIndex(label, " · "); i >= 0 {
|
||||
end = i
|
||||
}
|
||||
start := 0 // a source: all of it (an icon doesn't match letters)
|
||||
if i := strings.LastIndex(label[:end], " / "); i >= 0 {
|
||||
start = i + len(" / ")
|
||||
}
|
||||
return utf8.RuneCountInString(label[:start]), utf8.RuneCountInString(label[:end])
|
||||
}
|
||||
|
||||
// refreshSearch updates an open search as lists arrive.
|
||||
func (m *Model) refreshSearch() {
|
||||
if m.search != nil {
|
||||
m.fillSearch(true)
|
||||
}
|
||||
}
|
||||
|
||||
// stableOrder puts entries shown before (prev) in their earlier order,
|
||||
// new ones after them in the order given.
|
||||
func stableOrder(opts []option, prev map[string]int) []option {
|
||||
out := slices.Clone(opts)
|
||||
slices.SortStableFunc(out, func(a, b option) int {
|
||||
ia, okA := prev[a.id]
|
||||
ib, okB := prev[b.id]
|
||||
switch {
|
||||
case okA && okB:
|
||||
return ia - ib
|
||||
case okA:
|
||||
return -1
|
||||
case okB:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
||||
v := m.search
|
||||
km := m.keys
|
||||
// The actions shared by both modes, by their keys in the mode.
|
||||
type modeKeys struct{ down, up, sources, results, switchList, star, load, loadAll, sel key.Binding }
|
||||
k := modeKeys{km.GoDown, km.GoUp, km.GoSources, km.GoResults, km.GoSwitch, km.GoStar, km.GoLoad, km.GoLoadAll, km.GoSelect}
|
||||
if v.typing {
|
||||
k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect}
|
||||
if key.Matches(msg, km.PopupClose) {
|
||||
// Back to browsing, the filter cleared.
|
||||
if v.input.Value() != "" {
|
||||
v.input.SetValue("")
|
||||
v.res = 0
|
||||
m.fillSearch(false)
|
||||
}
|
||||
return m.setTyping(false)
|
||||
}
|
||||
} else {
|
||||
switch {
|
||||
case key.Matches(msg, km.GoClose):
|
||||
m.search = nil
|
||||
return nil
|
||||
case key.Matches(msg, km.GoFilter):
|
||||
return m.setTyping(true)
|
||||
case key.Matches(msg, km.Help):
|
||||
return m.showKeybindingsFor(keys.GoTo)
|
||||
case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab):
|
||||
d := 1
|
||||
if key.Matches(msg, km.GoPrevTab) {
|
||||
d = tabCount - 1
|
||||
}
|
||||
v.tab = (v.tab + d) % tabCount
|
||||
v.res, v.results = 0, true
|
||||
m.fillSearch(false)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
inSearch := v.tab == tabSearch
|
||||
moveSrc := func(d int) {
|
||||
v.src = max(0, min(v.src+d, len(v.scopes)-1))
|
||||
v.res = 0
|
||||
m.fillSearch(false)
|
||||
}
|
||||
switch {
|
||||
case inSearch && key.Matches(msg, k.switchList):
|
||||
v.results = !v.results
|
||||
return nil
|
||||
case inSearch && key.Matches(msg, k.sources):
|
||||
v.results = false
|
||||
return nil
|
||||
case inSearch && key.Matches(msg, k.results):
|
||||
v.results = true
|
||||
return nil
|
||||
case key.Matches(msg, k.up):
|
||||
if v.results {
|
||||
v.res = max(0, v.res-1)
|
||||
} else {
|
||||
moveSrc(-1)
|
||||
}
|
||||
return nil
|
||||
case key.Matches(msg, k.down):
|
||||
if v.results {
|
||||
v.res = max(0, min(v.res+1, len(v.shown)-1))
|
||||
} else {
|
||||
moveSrc(1)
|
||||
}
|
||||
return nil
|
||||
case key.Matches(msg, k.sel):
|
||||
switch {
|
||||
case v.results && v.res < len(v.shown):
|
||||
m.search = nil
|
||||
return v.shown[v.res].run(m)
|
||||
case !v.results && v.onSource():
|
||||
m.search = nil
|
||||
return v.scope().run(m)
|
||||
case !v.results:
|
||||
v.results = true // All: on to the results
|
||||
}
|
||||
return nil
|
||||
case inSearch && key.Matches(msg, k.load):
|
||||
if o, ok := v.selected(); ok && o.reload != nil {
|
||||
return o.reload(m) // the selected source, or a result's vault
|
||||
}
|
||||
// Nothing selected (no results yet, or All): the scope.
|
||||
if v.onSource() {
|
||||
return v.scope().reload(m)
|
||||
}
|
||||
kind, _ := v.scopeKind()
|
||||
return m.startCrawlOf(kind)
|
||||
case inSearch && key.Matches(msg, k.loadAll):
|
||||
kind, _ := v.scopeKind()
|
||||
return m.startCrawlOf(kind)
|
||||
case key.Matches(msg, k.star):
|
||||
if o, ok := v.selected(); ok && o.star != "" {
|
||||
m.toggleStarKey(o.star, o.starAccount)
|
||||
// The entry stays where it is, its ★ showing the change.
|
||||
on := m.store.Node(o.star).StarredFor(o.starAccount)
|
||||
for _, list := range [][]option{v.favs, v.recent} {
|
||||
for i := range list {
|
||||
if list[i].star == o.star && list[i].starAccount == o.starAccount {
|
||||
list[i].starred = on
|
||||
}
|
||||
}
|
||||
}
|
||||
m.fillSearch(true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !v.typing {
|
||||
return nil
|
||||
}
|
||||
before := v.input.Value()
|
||||
in, cmd := v.input.Update(msg)
|
||||
v.input = in
|
||||
if v.input.Value() != before {
|
||||
v.res = 0
|
||||
m.fillSearch(false)
|
||||
return tea.Batch(cmd, m.searchLoads())
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// searchLoads loads what a search in the Search tab covers, like the
|
||||
// load-all key: everything on All, else the source picked. Only what
|
||||
// isn't loaded yet, once a popup; repos load by themselves anyway.
|
||||
func (m *Model) searchLoads() tea.Cmd {
|
||||
v := m.search
|
||||
if v.tab != tabSearch || strings.TrimSpace(v.input.Value()) == "" {
|
||||
return nil
|
||||
}
|
||||
sc := v.scope()
|
||||
if v.loaded[sc.id] {
|
||||
return nil
|
||||
}
|
||||
if v.loaded == nil {
|
||||
v.loaded = map[string]bool{}
|
||||
}
|
||||
v.loaded[sc.id] = true
|
||||
if kind, ok := v.scopeKind(); ok {
|
||||
return m.startCrawlOf(kind)
|
||||
}
|
||||
if !v.onSource() {
|
||||
return m.startCrawl()
|
||||
}
|
||||
providerID, sourceID := splitSourceRow(sc.group)
|
||||
for _, src := range m.sourceResults[providerID].sources {
|
||||
if src.ID == sourceID {
|
||||
src.Provider, src.ProviderLabel = providerID, m.byID[providerID].Label()
|
||||
m.queueSource(src, false)
|
||||
return m.crawlStart()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// selected is the entry under the cursor in the focused list (a scope
|
||||
// that isn't one source is none).
|
||||
func (v *searchView) selected() (option, bool) {
|
||||
if v.results {
|
||||
if v.res < len(v.shown) {
|
||||
return v.shown[v.res], true
|
||||
}
|
||||
return option{}, false
|
||||
}
|
||||
if v.onSource() {
|
||||
return v.scope(), true
|
||||
}
|
||||
return option{}, false
|
||||
}
|
||||
|
||||
// toggleStarKey stars or unstars a store record for an account and
|
||||
// re-sorts the panes.
|
||||
func (m *Model) toggleStarKey(key, account string) {
|
||||
if err := m.store.Update(key, func(n *store.Node) { n.ToggleStar(account) }); err != nil {
|
||||
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
|
||||
return
|
||||
}
|
||||
m.resortStarred()
|
||||
}
|
||||
|
||||
// searchHint tops the results when what they come from isn't all
|
||||
// loaded, saying how to load it.
|
||||
func (m Model) searchHint() string {
|
||||
v := m.search
|
||||
if m.crawl.running {
|
||||
return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active))
|
||||
}
|
||||
if v.tab != tabSearch {
|
||||
return "" // from the store: nothing to load
|
||||
}
|
||||
if kind, ok := v.scopeKind(); ok || v.scope().id == allSources {
|
||||
c := m.coverageOf(kind)
|
||||
if c.complete() {
|
||||
return ""
|
||||
}
|
||||
return m.st.Warning.Render(fmt.Sprintf("⚠ %d/%d vaults loaded", c.vaultsLoaded, c.vaults) + withKey(m.loadAllKey(), " · %s loads everything"))
|
||||
}
|
||||
src := v.scopes[v.src]
|
||||
e, listed := m.cache.containers[src.group]
|
||||
name := ansi.Strip(src.label)
|
||||
if !listed {
|
||||
if m.crawl.failed[src.group] {
|
||||
return ""
|
||||
}
|
||||
return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name))
|
||||
}
|
||||
loaded := 0
|
||||
for _, c := range e.value {
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
||||
loaded++
|
||||
}
|
||||
}
|
||||
if loaded == len(e.value) {
|
||||
return ""
|
||||
}
|
||||
return m.st.Warning.Render(fmt.Sprintf("⚠ %d/%d vaults loaded", loaded, len(e.value)) + withKey(m.loadKey(), " · %s loads "+name))
|
||||
}
|
||||
|
||||
// loadKey and loadAllKey are the keys for loading in the current mode.
|
||||
func (m Model) loadKey() key.Binding {
|
||||
if m.search.typing {
|
||||
return m.keys.SearchLoad
|
||||
}
|
||||
return m.keys.GoLoad
|
||||
}
|
||||
|
||||
func (m Model) loadAllKey() key.Binding {
|
||||
if m.search.typing {
|
||||
return m.keys.SearchLoadAll
|
||||
}
|
||||
return m.keys.GoLoadAll
|
||||
}
|
||||
|
||||
// searchWidth is most of the screen: paths are long.
|
||||
func searchWidth(screen int) int {
|
||||
return max(40, min(screen-4, screen*9/10))
|
||||
}
|
||||
|
||||
// searchViewBox draws the popup.
|
||||
func (m Model) searchViewBox() string {
|
||||
v := m.search
|
||||
focused := !m.termBlurred
|
||||
w := searchWidth(m.w)
|
||||
listH := max(5, min(m.h-10, 14))
|
||||
|
||||
srcW := 10
|
||||
for _, o := range v.scopes {
|
||||
srcW = max(srcW, ansi.StringWidth(ansi.Strip(o.label))+ansi.StringWidth(o.note)+12) // marks, gap, borders
|
||||
}
|
||||
srcW = min(srcW, (w-4)*2/5)
|
||||
resW := w - 4 - srcW
|
||||
if v.tab != tabSearch {
|
||||
resW = w - 4 // one list
|
||||
}
|
||||
|
||||
row := func(o option, width int, cursor, active bool) string {
|
||||
mark := o.mark
|
||||
if mark == "" {
|
||||
mark = " "
|
||||
}
|
||||
star := " "
|
||||
if o.starred {
|
||||
star = m.st.Favourite.Render("★ ")
|
||||
}
|
||||
label := panes.MarkMatches(o.label, o.hits, m.st.Match)
|
||||
text := withNote(m.st, mark+" "+star+label, o.note, width-4) // borders, cursor column
|
||||
if !cursor {
|
||||
return " " + text
|
||||
}
|
||||
sel := m.st.Selected
|
||||
if !active || !focused {
|
||||
sel = m.st.SelectedBlurred
|
||||
}
|
||||
return " " + panes.Highlight("▸"+text, sel, width-3) // inside the borders
|
||||
}
|
||||
window := func(n, cursor, h int) (int, int) {
|
||||
first := max(0, min(cursor-h/2, n-h))
|
||||
return first, min(n, first+h)
|
||||
}
|
||||
inner := listH - 2
|
||||
|
||||
// The picked source is a choice that stays, like a tab: it keeps the
|
||||
// full highlight while the results have the cursor.
|
||||
var left []string
|
||||
first, last := window(len(v.scopes), v.src, inner)
|
||||
for i := first; i < last; i++ {
|
||||
left = append(left, row(v.scopes[i], srcW, i == v.src, true))
|
||||
}
|
||||
var right []string
|
||||
resRows := inner
|
||||
if hint := m.searchHint(); hint != "" {
|
||||
right = append(right, " "+hint)
|
||||
resRows--
|
||||
}
|
||||
first, last = window(len(v.shown), v.res, resRows)
|
||||
for i := first; i < last; i++ {
|
||||
right = append(right, row(v.shown[i], resW, i == v.res, v.results))
|
||||
}
|
||||
if len(v.shown) == 0 {
|
||||
msg := "no matches"
|
||||
switch {
|
||||
case v.input.Value() == "" && v.scope().id == scopeFavs:
|
||||
msg = "nothing starred yet" + withKey(m.keys.Favourite, ": %s in the panes stars the selected item")
|
||||
case v.input.Value() == "" && v.scope().id == scopeRecent:
|
||||
msg = "nothing used yet"
|
||||
case v.input.Value() == "":
|
||||
msg = "nothing loaded here yet"
|
||||
case v.tab == tabSearch && v.scope().id != allSources:
|
||||
msg += " here · All searches everything"
|
||||
case v.tab != tabSearch:
|
||||
msg += withKey(m.keys.GoNextTab, " here · Search (%s) looks in everything loaded")
|
||||
}
|
||||
right = append(right, " "+m.st.Muted.Render(msg))
|
||||
}
|
||||
counter := func(i, n int) string {
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d of %d", i+1, n)
|
||||
}
|
||||
title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) }
|
||||
resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id]
|
||||
if v.onSource() {
|
||||
resultsTitle = "Results in " + ansi.Strip(v.scope().label)
|
||||
}
|
||||
if kind, ok := v.scopeKind(); ok {
|
||||
resultsTitle = "Results in " + string(kind)
|
||||
if kind == provider.KindRepo {
|
||||
resultsTitle = "Results in all repos"
|
||||
}
|
||||
}
|
||||
var lists string
|
||||
if v.tab == tabSearch {
|
||||
lists = lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
panes.BoxCounted(m.st, title("Scope", !v.results), "", counter(v.src, len(v.scopes)), left, srcW, listH, !v.results && focused),
|
||||
panes.BoxCounted(m.st, title(resultsTitle, v.results), "", counter(v.res, len(v.shown)), right, resW, listH, v.results && focused),
|
||||
)
|
||||
} else {
|
||||
lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused)
|
||||
}
|
||||
|
||||
body := []string{" " + v.input.View(), ""}
|
||||
for _, l := range strings.Split(lists, "\n") {
|
||||
body = append(body, " "+l)
|
||||
}
|
||||
body = append(body, "")
|
||||
if v.tab == tabSearch {
|
||||
for _, l := range m.searchLines() {
|
||||
body = append(body, " "+l)
|
||||
}
|
||||
} else {
|
||||
body = append(body, "") // the same height on every tab
|
||||
}
|
||||
km := m.keys
|
||||
var foot string
|
||||
switch {
|
||||
case v.typing:
|
||||
hints := []keyHint{move(km.PopupUp, km.PopupDown)}
|
||||
if v.tab == tabSearch {
|
||||
hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load"))
|
||||
}
|
||||
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, "go"), hint(km.PopupClose, "stop typing"))...)
|
||||
default:
|
||||
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
|
||||
if v.tab == tabSearch {
|
||||
hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all"))
|
||||
}
|
||||
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, "go"), hint(km.GoClose, "close"))...)
|
||||
}
|
||||
// Tabs in the title, the current one underlined, like the panes.
|
||||
ts := panes.TitleStyle(m.st, focused)
|
||||
head := ts.Render(goToTitle + " ")
|
||||
for i, name := range tabNames {
|
||||
if i > 0 {
|
||||
head += ts.Render(" - ")
|
||||
}
|
||||
if i == v.tab {
|
||||
head += ts.Underline(true).Render(name)
|
||||
} else {
|
||||
head += m.st.Muted.Render(name)
|
||||
}
|
||||
}
|
||||
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
╭─ [1] Sources - Favourites ────╮╭─ Secret ────────────────────────────────────────────────────────╮
|
||||
│ ▾ Azure (2) ││ name db-password │
|
||||
│ platform-prod ││ vault kv-app-prod │
|
||||
│ platform-dev ││ source Azure / platform-prod │
|
||||
│ ▾ Databricks (2) ││ version 3f9c2a │
|
||||
│ prod ││ enabled yes │
|
||||
╰────────────────────── 2 of 6 ─╯│ updated 2026-09-01 09:30 │
|
||||
╭─ [2] Vaults ──────────────────╮│ expires 2027-09-01 09:30 │
|
||||
│ kv-app-prod ││ content text/plain │
|
||||
│ kv-shared-prod ││ tags env=prod, owner=data │
|
||||
│ kv-restricted ││ ref @Microsoft.KeyVault(SecretUri=https://kv-app-prod.vau│
|
||||
│ ││ lt.azure.net/secrets/db-password) │
|
||||
│ ││ │
|
||||
│ ││ value •••••••• (space to reveal) │
|
||||
│ ││ │
|
||||
╰────────────────────── 1 of 3 ─╯│ versions 3 · v to browse │
|
||||
╭─ [3] Secrets - Keys - Certs ──╮│ ▸ 3f9c2a 2026-09-01 current │
|
||||
│ db-password ││ 91d3e0 2026-03-01 expired │
|
||||
│ api-key ││ 0a77c4 2025-09-01 disabled │
|
||||
│ legacy-token (disabled) ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰────────────────────── 1 of 3 ─╯╰─────────────────────────────────────────────────────────────────╯
|
||||
enter open • s copy link • S share… • f star • F favourites • n note • … • ? help • q quit
|
||||
@@ -0,0 +1,169 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/browser"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
|
||||
// Updater finds and installs newer releases (internal/update).
|
||||
type Updater struct {
|
||||
Latest func(context.Context) (update.Release, error)
|
||||
Install func(context.Context, update.Release) error
|
||||
}
|
||||
|
||||
// updateEvery is how often lazysecrets looks for a newer release on its
|
||||
// own; within that, the last answer (in the state file) is used.
|
||||
const updateEvery = 24 * time.Hour
|
||||
|
||||
type updateMsg struct {
|
||||
release update.Release
|
||||
err error
|
||||
asked bool // the user asked (U), rather than the daily check
|
||||
}
|
||||
|
||||
type installedMsg struct {
|
||||
release update.Release
|
||||
err error
|
||||
}
|
||||
|
||||
// startUpdateCheck sets up the startup look for a newer release: the
|
||||
// state file's answer when it looked less than a day ago, else a quiet
|
||||
// look at the server (fetchOnStart, for Init).
|
||||
func (m *Model) startUpdateCheck() {
|
||||
if m.updater == nil || !m.updateCheck {
|
||||
return
|
||||
}
|
||||
last := m.store.UpdateCheck()
|
||||
if m.now().Sub(last.At) < updateEvery {
|
||||
m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL})
|
||||
return
|
||||
}
|
||||
m.fetchOnStart = true
|
||||
}
|
||||
|
||||
func (m Model) fetchLatest(asked bool) tea.Cmd {
|
||||
latest := m.updater.Latest
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
r, err := latest(ctx)
|
||||
return updateMsg{release: r, err: err, asked: asked}
|
||||
}
|
||||
}
|
||||
|
||||
// offer remembers r as the update to offer, if it's newer.
|
||||
func (m *Model) offer(r update.Release) {
|
||||
m.newer = nil
|
||||
if update.Newer(m.version, r.Tag) {
|
||||
m.newer = &r
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleUpdate(msg updateMsg) tea.Cmd {
|
||||
if msg.err != nil {
|
||||
if msg.asked {
|
||||
m.flash = flash{text: "couldn't check for a newer lazysecrets: " + msg.err.Error(), isErr: true}
|
||||
}
|
||||
return nil // the daily check stays quiet: offline, or the server's down
|
||||
}
|
||||
m.store.SetUpdateCheck(store.UpdateCheck{
|
||||
At: m.now(), Tag: msg.release.Tag, Notes: msg.release.Body, URL: msg.release.URL,
|
||||
})
|
||||
m.offer(msg.release)
|
||||
if msg.asked {
|
||||
if m.newer == nil {
|
||||
m.flash = done("lazysecrets " + m.versionLabel() + " is the latest (" + msg.release.Tag + ")")
|
||||
return nil
|
||||
}
|
||||
m.showUpdate()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// update is the update key: the newer release, or a look for one.
|
||||
func (m *Model) update() tea.Cmd {
|
||||
switch {
|
||||
case m.updater == nil:
|
||||
m.flash = warning("updates aren't available in this build")
|
||||
return nil
|
||||
case m.newer != nil:
|
||||
m.showUpdate()
|
||||
return nil
|
||||
case !update.Newer(m.version, "v999.0.0"): // dev builds aren't versions
|
||||
m.flash = warning("this is a development build (" + m.versionLabel() + "): update it with go install or install.sh")
|
||||
return nil
|
||||
}
|
||||
m.flash = flash{text: "looking for a newer lazysecrets…"}
|
||||
return m.fetchLatest(true)
|
||||
}
|
||||
|
||||
// showUpdate offers the newer release: its changelog, and updating.
|
||||
func (m *Model) showUpdate() {
|
||||
r := *m.newer
|
||||
lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")}
|
||||
if notes := r.Notes(); notes != "" {
|
||||
lines = append(lines, "")
|
||||
for _, l := range strings.Split(notes, "\n") {
|
||||
if h, ok := strings.CutPrefix(l, "### "); ok {
|
||||
l = m.st.Label.Render(h)
|
||||
}
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.",
|
||||
run: func(m *Model) tea.Cmd { return m.install(r) }}}
|
||||
if r.URL != "" {
|
||||
opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd {
|
||||
switch err := m.openURL(r.URL); {
|
||||
case errors.Is(err, browser.ErrRemote):
|
||||
cmd := m.copyText("link to the release", r.URL, false)
|
||||
m.flash.text += " (can't open a browser over SSH)"
|
||||
return cmd
|
||||
case err != nil:
|
||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
m.showMenu("lazysecrets "+r.Tag, lines, opts)
|
||||
}
|
||||
|
||||
func (m *Model) install(r update.Release) tea.Cmd {
|
||||
m.flash = flash{text: "downloading lazysecrets " + r.Tag + "…"}
|
||||
install := m.updater.Install
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
return installedMsg{release: r, err: install(ctx, r)}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleInstalled(msg installedMsg) {
|
||||
if msg.err != nil {
|
||||
m.flash = flash{text: "update failed: " + msg.err.Error(), isErr: true}
|
||||
return
|
||||
}
|
||||
m.newer = nil
|
||||
m.updated = msg.release.Tag
|
||||
m.flash = done("updated to lazysecrets " + msg.release.Tag + " · restart lazysecrets to use it")
|
||||
}
|
||||
|
||||
// updateLabel is the version's suffix in the footer: a newer release to
|
||||
// update to, or the one installed and waiting for a restart.
|
||||
func (m Model) updateLabel() string {
|
||||
switch {
|
||||
case m.updated != "":
|
||||
return " → " + m.updated + " (restart)"
|
||||
case m.newer != nil:
|
||||
return " ↑ " + m.newer.Tag + withKey(m.keys.Update, " %s")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
|
||||
// fakeUpdater offers latest and records what it installs.
|
||||
type fakeUpdater struct {
|
||||
latest update.Release
|
||||
err error
|
||||
asked int
|
||||
installed string
|
||||
}
|
||||
|
||||
func (f *fakeUpdater) updater() *Updater {
|
||||
return &Updater{
|
||||
Latest: func(context.Context) (update.Release, error) {
|
||||
f.asked++
|
||||
return f.latest, f.err
|
||||
},
|
||||
Install: func(_ context.Context, r update.Release) error {
|
||||
f.installed = r.Tag
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var v020 = update.Release{
|
||||
Tag: "v0.2.0",
|
||||
Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\ncurl …",
|
||||
URL: "https://git.example.com/keb/lazysecrets/releases/tag/v0.2.0",
|
||||
}
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &fakeUpdater{latest: v020}
|
||||
mem, _ := store.Load(filepath.Join(t.TempDir(), "state.json"))
|
||||
d := newDriverOpts(t, Options{Version: "0.1.1", Updater: f.updater(), UpdateCheck: true, Store: mem})
|
||||
if f.asked != 1 {
|
||||
t.Fatalf("asked %d times at startup", f.asked)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "v0.1.1 ↑ v0.2.0 U") {
|
||||
t.Errorf("no update in the footer:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// U: the changelog, without the install notes; u installs.
|
||||
d.keys("U")
|
||||
scr := d.screen()
|
||||
for _, want := range []string{"lazysecrets v0.2.0", "You have v0.1.1.", "Install with a one-liner (#7)", "Update now", "Open the release page"} {
|
||||
if !strings.Contains(scr, want) {
|
||||
t.Errorf("missing %q:\n%s", want, scr)
|
||||
}
|
||||
}
|
||||
if strings.Contains(scr, "curl") {
|
||||
t.Errorf("install notes shown:\n%s", scr)
|
||||
}
|
||||
d.keys("u")
|
||||
if f.installed != "v0.2.0" {
|
||||
t.Fatalf("installed %q", f.installed)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "updated to lazysecrets v0.2.0 · restart lazysecrets to use it") ||
|
||||
!strings.Contains(d.screen(), "→ v0.2.0 (restart)") {
|
||||
t.Errorf("after updating:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// Within a day, the next start uses what it found: no request.
|
||||
f2 := &fakeUpdater{latest: v020}
|
||||
d2 := newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, Store: mem})
|
||||
if f2.asked != 0 || !strings.Contains(d2.screen(), "↑ v0.2.0") {
|
||||
t.Errorf("asked %d times within a day:\n%s", f2.asked, d2.screen())
|
||||
}
|
||||
// A day later it asks again.
|
||||
mem.SetUpdateCheck(store.UpdateCheck{At: time.Now().Add(-25 * time.Hour), Tag: "v0.2.0"})
|
||||
newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, Store: mem})
|
||||
if f2.asked != 1 {
|
||||
t.Errorf("asked %d times a day later", f2.asked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAsked(t *testing.T) {
|
||||
t.Parallel()
|
||||
mem, _ := store.Load(filepath.Join(t.TempDir(), "state.json"))
|
||||
// Not looking on its own: U looks.
|
||||
f := &fakeUpdater{latest: update.Release{Tag: "v0.1.1"}}
|
||||
d := newDriverOpts(t, Options{Version: "0.1.1", Updater: f.updater(), Store: mem})
|
||||
if f.asked != 0 {
|
||||
t.Fatal("looked without updateCheck")
|
||||
}
|
||||
d.keys("U")
|
||||
if f.asked != 1 || !strings.Contains(d.screen(), "lazysecrets v0.1.1 is the latest (v0.1.1)") {
|
||||
t.Errorf("asked %d:\n%s", f.asked, d.screen())
|
||||
}
|
||||
|
||||
// The daily check stays quiet when offline; U says why.
|
||||
f.err = errors.New("no network")
|
||||
d.keys("U")
|
||||
if !strings.Contains(d.screen(), "couldn't check for a newer lazysecrets: no network") {
|
||||
t.Errorf("no error:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// A development build isn't offered releases.
|
||||
dev := newDriverOpts(t, Options{Version: "dev-2ba762c", Updater: (&fakeUpdater{latest: v020}).updater(), UpdateCheck: true, Store: mem})
|
||||
if strings.Contains(dev.screen(), "↑") {
|
||||
t.Errorf("dev build offered an update:\n%s", dev.screen())
|
||||
}
|
||||
dev.keys("U")
|
||||
if !strings.Contains(dev.screen(), "development build") {
|
||||
t.Errorf("U on a dev build:\n%s", dev.screen())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 + " "
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// Package keys defines the keymap: named actions in contexts, their
|
||||
// default keys, and overrides from the config:
|
||||
//
|
||||
// keybinding:
|
||||
// lists:
|
||||
// star: [f]
|
||||
// versions: [] # unbound
|
||||
// filter:
|
||||
// star: [ctrl+f]
|
||||
//
|
||||
// An action set in the config gets exactly the keys given, replacing its
|
||||
// defaults; an empty list unbinds it. Everything that shows a key (the
|
||||
// help bar, ?, footers, hints) reads it from here.
|
||||
package keys
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
)
|
||||
|
||||
// Contexts group the actions by where they apply. The search popup takes
|
||||
// search's actions and popup's.
|
||||
const (
|
||||
Lists = "lists" // the panes, when not typing
|
||||
Filter = "filter" // typing a filter in a pane
|
||||
Menu = "menu" // popups without a text field
|
||||
Popup = "popup" // popups with a text field: favourites, help, search, notes, open link
|
||||
Search = "search" // Go to's own actions while typing
|
||||
GoTo = "goto" // Go to while browsing (no text field active)
|
||||
)
|
||||
|
||||
type Map struct {
|
||||
// lists
|
||||
Up, Down, Top, Bottom key.Binding
|
||||
NextPane, PrevPane key.Binding
|
||||
Pane1, Pane2, Pane3, Pane4 key.Binding
|
||||
NextTab, PrevTab key.Binding
|
||||
Enter, Back, Filter key.Binding
|
||||
Reveal, Copy, CopyRef, Open key.Binding
|
||||
Versions, Favourite, Favourites key.Binding
|
||||
Recent key.Binding
|
||||
Note, Share, ShareMenu, OpenRef key.Binding
|
||||
Search, SourcesView, Commands key.Binding
|
||||
SplitLeft, SplitRight, SplitReset key.Binding
|
||||
Taller, Shorter, ZoomIn, ZoomOut key.Binding
|
||||
Login, Refresh, Help, Quit key.Binding
|
||||
Update key.Binding
|
||||
// filter
|
||||
AcceptFilter, ClearFilter key.Binding
|
||||
FilterUp, FilterDown key.Binding
|
||||
FilterCopy, FilterReveal, FilterStar key.Binding
|
||||
// menu
|
||||
MenuUp, MenuDown, MenuSelect, MenuClose key.Binding
|
||||
// popup
|
||||
PopupUp, PopupDown, PopupSelect, PopupClose, PopupStar key.Binding
|
||||
// search
|
||||
SearchSources, SearchResults, SearchSwitch key.Binding
|
||||
SearchLoad, SearchLoadAll key.Binding
|
||||
// goto
|
||||
GoDown, GoUp, GoPrevTab, GoNextTab key.Binding
|
||||
GoSources, GoResults, GoSwitch key.Binding
|
||||
GoStar, GoLoad, GoLoadAll, GoFilter key.Binding
|
||||
GoSelect, GoClose key.Binding
|
||||
}
|
||||
|
||||
// Action is a binding with its name in the config and what it does.
|
||||
type Action struct {
|
||||
Context, Name string
|
||||
Binding *key.Binding
|
||||
Desc string // short, for the help bar
|
||||
Group string // section in the ? menu
|
||||
Tip string // a sentence or two for the ? menu
|
||||
}
|
||||
|
||||
// Actions lists every action, in the ? menu's order.
|
||||
func (m *Map) Actions() []Action {
|
||||
a := func(ctx, name string, b *key.Binding, desc, group, tip string) Action {
|
||||
return Action{ctx, name, b, desc, group, tip}
|
||||
}
|
||||
return []Action{
|
||||
a(Lists, "up", &m.Up, "up", "move", "Move up in the focused list, or scroll the detail pane."),
|
||||
a(Lists, "down", &m.Down, "down", "move", "Move down in the focused list, or scroll the detail pane."),
|
||||
a(Lists, "top", &m.Top, "top", "move", "Jump to the top of the focused list."),
|
||||
a(Lists, "bottom", &m.Bottom, "bottom", "move", "Jump to the bottom of the focused list."),
|
||||
a(Lists, "nextPane", &m.NextPane, "next pane", "move", "Focus the next pane: Sources, Vaults, Secrets, detail."),
|
||||
a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane."),
|
||||
a(Lists, "pane1", &m.Pane1, "Sources pane", "move", "Focus the Sources pane."),
|
||||
a(Lists, "pane2", &m.Pane2, "Vaults pane", "move", "Focus the Vaults (or Scopes) pane."),
|
||||
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
|
||||
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
|
||||
a(Lists, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
|
||||
a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
|
||||
|
||||
a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."),
|
||||
a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."),
|
||||
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
|
||||
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
|
||||
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
|
||||
a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one, starting in the source you're in. On the left, the scope: Favourites, Recent, All, or one source (○ ◐ ● for how much of it is loaded)."),
|
||||
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
|
||||
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
|
||||
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
|
||||
a(Lists, "note", &m.Note, "note", "lists", "Write a short note on the selected subscription, vault or secret, shown on the right of its row. Filtering matches notes too."),
|
||||
|
||||
a(Lists, "reveal", &m.Reveal, "reveal/hide", "secrets", "Show or hide the selected value. For keys and certificates: the public key or certificate."),
|
||||
a(Lists, "copyValue", &m.Copy, "copy value", "secrets", "Copy the selected value without showing it. It is cleared from the clipboard after a countdown."),
|
||||
a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."),
|
||||
a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."),
|
||||
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
|
||||
|
||||
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
|
||||
a(Lists, "shareMenu", &m.ShareMenu, "share…", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."),
|
||||
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
|
||||
|
||||
a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."),
|
||||
a(Lists, "dividerRight", &m.SplitRight, "divider right", "layout", "Move the divider between the left panes and the detail pane to the right."),
|
||||
a(Lists, "taller", &m.Taller, "taller", "layout", "Make the focused left pane taller."),
|
||||
a(Lists, "shorter", &m.Shorter, "shorter", "layout", "Make the focused left pane shorter."),
|
||||
a(Lists, "zoomIn", &m.ZoomIn, "zoom", "layout", "Zoom the focused pane: normal, half screen, full screen."),
|
||||
a(Lists, "zoomOut", &m.ZoomOut, "unzoom", "layout", "Zoom back out."),
|
||||
a(Lists, "resetLayout", &m.SplitReset, "reset layout", "layout", "Reset the divider, pane heights and zoom."),
|
||||
|
||||
a(Lists, "refresh", &m.Refresh, "refresh", "other", "Reload the focused pane, bypassing the cache."),
|
||||
a(Lists, "login", &m.Login, "sign in", "other", "Sign in again (az login or databricks auth login) when a login has expired."),
|
||||
a(Lists, "update", &m.Update, "update", "other", "Check for a newer lazysecrets: its changelog, and update to it."),
|
||||
a(Lists, "help", &m.Help, "help", "other", "Show this list. Also works in Go to while browsing, and in menus, showing their keys first."),
|
||||
a(Lists, "quit", &m.Quit, "quit", "other", "Quit lazysecrets. ctrl+c always quits too."),
|
||||
|
||||
a(Filter, "down", &m.FilterDown, "down", "while filtering", "Move down while typing a filter."),
|
||||
a(Filter, "up", &m.FilterUp, "up", "while filtering", "Move up while typing a filter."),
|
||||
a(Filter, "copyValue", &m.FilterCopy, "copy value", "while filtering", "Copy the selected value while typing a filter."),
|
||||
a(Filter, "reveal", &m.FilterReveal, "reveal/hide", "while filtering", "Reveal or hide the selected value while typing a filter."),
|
||||
a(Filter, "star", &m.FilterStar, "star", "while filtering", "Star or unstar the selected item while typing a filter."),
|
||||
a(Filter, "accept", &m.AcceptFilter, "apply filter", "while filtering", "Stop typing and keep the filter."),
|
||||
a(Filter, "clear", &m.ClearFilter, "clear filter", "while filtering", "Clear the filter."),
|
||||
|
||||
a(Menu, "down", &m.MenuDown, "down", "menus", "Move down in a menu."),
|
||||
a(Menu, "up", &m.MenuUp, "up", "menus", "Move up in a menu."),
|
||||
a(Menu, "select", &m.MenuSelect, "select", "menus", "Pick the selected entry. Each entry's letter picks it directly."),
|
||||
a(Menu, "close", &m.MenuClose, "dismiss", "menus", "Close the menu."),
|
||||
|
||||
a(Popup, "down", &m.PopupDown, "down", "popups", "Move down in a popup with a text field (favourites, help, search)."),
|
||||
a(Popup, "up", &m.PopupUp, "up", "popups", "Move up in a popup with a text field."),
|
||||
a(Popup, "select", &m.PopupSelect, "go", "popups", "Go to the selected entry, or confirm what you typed."),
|
||||
a(Popup, "close", &m.PopupClose, "close", "popups", "Close the popup."),
|
||||
a(Popup, "star", &m.PopupStar, "star", "popups", "Star or unstar the selected entry in Go to. It stays where it is until Go to opens again."),
|
||||
|
||||
a(Search, "sources", &m.SearchSources, "sources", "search", "Move to the sources list."),
|
||||
a(Search, "results", &m.SearchResults, "results", "search", "Move to the results list."),
|
||||
a(Search, "switchList", &m.SearchSwitch, "switch list", "search", "Switch between the sources and the results."),
|
||||
a(Search, "load", &m.SearchLoad, "load selected", "search", "Load (or reload) the selected source's vaults and secrets, or the selected vault's secrets, in the background."),
|
||||
a(Search, "loadAll", &m.SearchLoadAll, "load all", "search", "Load every source's vaults and secrets in the background, to search them all."),
|
||||
|
||||
a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."),
|
||||
a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."),
|
||||
a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Favourites, Recent, Search."),
|
||||
a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."),
|
||||
a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."),
|
||||
a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."),
|
||||
a(GoTo, "switchList", &m.GoSwitch, "switch list", "go to", "Switch between the sources and the results (Search tab)."),
|
||||
a(GoTo, "star", &m.GoStar, "star", "go to", "Star or unstar the selected entry. It stays where it is until Go to opens again."),
|
||||
a(GoTo, "load", &m.GoLoad, "load selected", "go to", "Load (or reload) the selected source or vault in the background (Search tab)."),
|
||||
a(GoTo, "loadAll", &m.GoLoadAll, "load all", "go to", "Load everything in the background, to search it all (Search tab)."),
|
||||
a(GoTo, "filter", &m.GoFilter, "filter", "go to", "Start typing to filter the list."),
|
||||
a(GoTo, "select", &m.GoSelect, "go", "go to", "Go to the selected entry."),
|
||||
a(GoTo, "close", &m.GoClose, "close", "go to", "Close Go to."),
|
||||
}
|
||||
}
|
||||
|
||||
// defaults follow one rule: a letter in the lists, ctrl+ the same letter
|
||||
// where you type.
|
||||
var defaults = map[string][]string{
|
||||
"lists.up": {"k", "up", "ctrl+k", "ctrl+p"}, "lists.down": {"j", "down", "ctrl+j", "ctrl+n"},
|
||||
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
|
||||
"lists.nextPane": {"tab", "l", "right"}, "lists.prevPane": {"shift+tab", "h", "left"},
|
||||
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"},
|
||||
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
|
||||
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
|
||||
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
|
||||
"lists.commands": {"x"},
|
||||
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
|
||||
"lists.versions": {"v"}, "lists.openInBrowser": {"o"},
|
||||
"lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
|
||||
"lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
|
||||
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
|
||||
"lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="},
|
||||
"lists.refresh": {"r"}, "lists.login": {"L"}, "lists.update": {"U"}, "lists.help": {"?"}, "lists.quit": {"q"},
|
||||
|
||||
"filter.down": {"ctrl+j", "ctrl+n", "down"}, "filter.up": {"ctrl+k", "ctrl+p", "up"},
|
||||
"filter.copyValue": {"ctrl+y"}, "filter.reveal": {"ctrl+r"}, "filter.star": {"ctrl+f"},
|
||||
"filter.accept": {"enter"}, "filter.clear": {"esc"},
|
||||
|
||||
"menu.down": {"j", "down", "ctrl+j", "ctrl+n"}, "menu.up": {"k", "up", "ctrl+k", "ctrl+p"},
|
||||
"menu.select": {"enter"}, "menu.close": {"esc"},
|
||||
|
||||
"popup.down": {"ctrl+j", "ctrl+n", "down"}, "popup.up": {"ctrl+k", "ctrl+p", "up"},
|
||||
"popup.select": {"enter"}, "popup.close": {"esc"}, "popup.star": {"ctrl+f"},
|
||||
|
||||
"search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"},
|
||||
"search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"},
|
||||
|
||||
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
|
||||
"goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"},
|
||||
"goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"},
|
||||
"goto.select": {"enter"}, "goto.close": {"esc", "q"},
|
||||
}
|
||||
|
||||
// Default is the keymap without overrides.
|
||||
func Default() Map {
|
||||
var m Map
|
||||
for _, a := range m.Actions() {
|
||||
*a.Binding = bind(defaults[a.Context+"."+a.Name], a.Desc)
|
||||
}
|
||||
m.Login.SetEnabled(false) // shown and active only once a login expires
|
||||
return m
|
||||
}
|
||||
|
||||
func bind(keys []string, desc string) key.Binding {
|
||||
if len(keys) == 0 {
|
||||
return key.NewBinding(key.WithDisabled())
|
||||
}
|
||||
return key.NewBinding(key.WithKeys(keys...), key.WithHelp(Label(keys), desc))
|
||||
}
|
||||
|
||||
// Label shows keys in help: the first two, arrows as arrows, a shared
|
||||
// ctrl+ written once ("k/↑", "ctrl+j/n").
|
||||
func Label(keys []string) string {
|
||||
pretty := strings.NewReplacer("left", "←", "right", "→", "up", "↑", "down", "↓", "backspace", "⌫")
|
||||
var out []string
|
||||
for i, k := range keys[:min(2, len(keys))] {
|
||||
k = pretty.Replace(k)
|
||||
if rest, ok := strings.CutPrefix(k, "ctrl+"); ok && i > 0 && strings.HasPrefix(out[i-1], "ctrl+") {
|
||||
k = rest
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return strings.Join(out, "/")
|
||||
}
|
||||
|
||||
// Name is the key to mention in a hint ("space to reveal"): the first one
|
||||
// bound, or "" if the action is unbound.
|
||||
func Name(b key.Binding) string {
|
||||
if ks := b.Keys(); len(ks) > 0 {
|
||||
return Label(ks[:1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Apply overrides the defaults from the config: each action given gets
|
||||
// exactly the keys listed; none unbinds it. Unknown contexts or actions,
|
||||
// and a key bound to two actions that apply together, are errors.
|
||||
func (m *Map) Apply(cfg map[string]map[string][]string) error {
|
||||
byName := map[string]Action{}
|
||||
contexts := map[string]bool{}
|
||||
for _, a := range m.Actions() {
|
||||
byName[a.Context+"."+a.Name] = a
|
||||
contexts[a.Context] = true
|
||||
}
|
||||
for _, ctx := range sortedKeys(cfg) {
|
||||
if !contexts[ctx] {
|
||||
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", "))
|
||||
}
|
||||
for _, name := range sortedKeys(cfg[ctx]) {
|
||||
a, ok := byName[ctx+"."+name]
|
||||
if !ok {
|
||||
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
|
||||
}
|
||||
*a.Binding = bind(cfg[ctx][name], a.Desc)
|
||||
}
|
||||
}
|
||||
m.Login.SetEnabled(false)
|
||||
return m.check()
|
||||
}
|
||||
|
||||
func (m *Map) names(ctx string) []string {
|
||||
var out []string
|
||||
for _, a := range m.Actions() {
|
||||
if a.Context == ctx {
|
||||
out = append(out, a.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedKeys[V any](set map[string]V) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// check finds a key bound to two actions that apply together: within a
|
||||
// context, and in the search popup (search and popup).
|
||||
func (m *Map) check() error {
|
||||
for _, group := range [][]string{{Lists}, {Filter}, {Menu}, {Popup}, {Search, Popup}, {GoTo}} {
|
||||
seen := map[string]string{}
|
||||
for _, a := range m.Actions() {
|
||||
if !slices.Contains(group, a.Context) {
|
||||
continue
|
||||
}
|
||||
name := a.Context + "." + a.Name
|
||||
for _, k := range a.Binding.Keys() {
|
||||
if other, ok := seen[k]; ok && other != name {
|
||||
return fmt.Errorf("keybinding: %q is bound to both %s and %s", k, other, name)
|
||||
}
|
||||
seen[k] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Section is a titled group of actions for the ? menu.
|
||||
type Section struct {
|
||||
Title string
|
||||
Entries []Action
|
||||
}
|
||||
|
||||
// Sections groups the actions for the ? menu.
|
||||
func (m *Map) Sections() []Section {
|
||||
var out []Section
|
||||
for _, a := range m.Actions() {
|
||||
if n := len(out); n == 0 || out[n-1].Title != a.Group {
|
||||
out = append(out, Section{Title: a.Group})
|
||||
}
|
||||
out[len(out)-1].Entries = append(out[len(out)-1].Entries, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FilterHelp is shown while typing a filter.
|
||||
type FilterHelp struct{ Map }
|
||||
|
||||
func (f FilterHelp) ShortHelp() []key.Binding {
|
||||
return []key.Binding{f.FilterDown, f.FilterUp, f.FilterCopy, f.FilterReveal, f.FilterStar, f.AcceptFilter, f.ClearFilter}
|
||||
}
|
||||
|
||||
func (f FilterHelp) FullHelp() [][]key.Binding { return [][]key.Binding{f.ShortHelp()} }
|
||||
|
||||
// YAML is the keymap as a config section, every action with its keys and
|
||||
// what it does, to copy into the config and edit.
|
||||
func (m *Map) YAML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("keybinding:\n")
|
||||
ctx := ""
|
||||
for _, a := range m.Actions() {
|
||||
if a.Context != ctx {
|
||||
ctx = a.Context
|
||||
fmt.Fprintf(&b, " %s:\n", ctx)
|
||||
}
|
||||
quoted := make([]string, len(a.Binding.Keys()))
|
||||
for i, k := range a.Binding.Keys() {
|
||||
quoted[i] = fmt.Sprintf("%q", k)
|
||||
}
|
||||
fmt.Fprintf(&b, " %-15s [%s] # %s\n", a.Name+":", strings.Join(quoted, ", "), a.Desc)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// CheckExtra reports a key some other binding in the lists takes, for keys
|
||||
// added outside the keymap (custom commands). name says whose key it is.
|
||||
func (m *Map) CheckExtra(k, name string) error {
|
||||
for _, a := range m.Actions() {
|
||||
if a.Context == Lists && slices.Contains(a.Binding.Keys(), k) {
|
||||
return fmt.Errorf("%s: %q is bound to lists.%s", name, k, a.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user