Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
c00f4b14c4
133 files changed
+26979
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 }}
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Bug report
|
||||
description: Something in lazysecrets doesn't work as it should.
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting! Please leave out secret values, tokens and anything else private: names of vaults and secrets are enough.
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What you did, what you expected, and what happened instead.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: If you can, try `lazysecrets --demo` first.
|
||||
placeholder: |
|
||||
1. lazysecrets --demo
|
||||
2. ctrl+s, type …
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The status pane shows it, e.g. `v0.1.0`.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: source
|
||||
attributes:
|
||||
label: Where
|
||||
multiple: true
|
||||
options:
|
||||
- Azure Key Vault
|
||||
- Databricks
|
||||
- SOPS or .env files
|
||||
- Links
|
||||
- Everywhere / not sure
|
||||
- type: input
|
||||
id: platform
|
||||
attributes:
|
||||
label: OS and terminal
|
||||
placeholder: macOS 26, kitty + tmux
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Docs
|
||||
url: https://git.kebcloud.no/keb/lazysecrets/src/branch/main/docs
|
||||
about: Keys, sources, links, config and more.
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Feature request
|
||||
description: An idea for lazysecrets, or something that could work better.
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
lazysecrets stays focused on finding, reading and sharing secrets, so not every idea will make it in, but they're all read.
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What you're trying to do
|
||||
description: The problem or the situation, more than the solution.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: Your idea
|
||||
description: How you'd like it to work, if you have one in mind.
|
||||
+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-12
|
||||
|
||||
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,125 @@
|
||||
# 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.
|
||||
That's by design and may stay that way.
|
||||
- **Zero setup**: uses the logins you already have (`az`, the Databricks
|
||||
CLI, `sops`, `git`). No config file needed.
|
||||
- **Search everything**: `ctrl+s` searches every source at once, with
|
||||
fzf-style matching.
|
||||
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
|
||||
with their own access, from the terminal or with a click in the browser
|
||||
or chat.
|
||||
- **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 for easy access.
|
||||
- **Expiry warnings**: secrets, keys and certificates that expire soon are
|
||||
marked, listed together in the status pane.
|
||||
- **Your way**: rebind any key, pick a theme, and add custom commands to run
|
||||
on a secret.
|
||||
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
||||
- **Supported platforms**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos.
|
||||
|
||||
> **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
|
||||
|
||||
## Install
|
||||
|
||||
On macOS or Linux (arm64 or x86_64):
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
|
||||
```
|
||||
|
||||
It installs the latest [release](https://git.kebcloud.no/keb/lazysecrets/releases)
|
||||
into `~/.local/bin` after checking it against the release's checksums.
|
||||
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
|
||||
somewhere else.
|
||||
|
||||
|
||||
lazysecrets looks for a newer release once a day: the footer then shows
|
||||
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run
|
||||
`lazysecrets update`).
|
||||
|
||||
Or build it yourself:
|
||||
`go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`.
|
||||
|
||||
## Get started
|
||||
|
||||
```sh
|
||||
az login # login with azure cli
|
||||
lazysecrets # start lazysecrets
|
||||
lazysecrets --demo # test with demo data
|
||||
```
|
||||
|
||||
Pick a source on the left, then a vault, then a secret. With nothing
|
||||
signed in yet, the right pane says how to get started. The keys you'll
|
||||
use most:
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `j`/`k`, `enter`, `esc` | move, drill in, back |
|
||||
| `l` / `h` | the details of what's selected / back |
|
||||
| `/` | filter the list you're in |
|
||||
| `space` | reveal / hide the value |
|
||||
| `y` | copy the value (cleared from the clipboard after 30s) |
|
||||
| `ctrl+s` | Go to: search everything and jump there |
|
||||
| `f` / `F` | star / your favourites |
|
||||
| `s` | copy a link to share |
|
||||
| `?` | every key, searchable |
|
||||
| `q` | quit |
|
||||
|
||||
## Docs
|
||||
|
||||
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
|
||||
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
|
||||
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
|
||||
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting
|
||||
- [Links](docs/links.md): sharing, opening, `lazysecrets get` in scripts,
|
||||
and opening links from the browser and chat
|
||||
- [Custom commands](docs/custom-commands.md): run your own commands on a
|
||||
secret
|
||||
- [Config](docs/config.md): every option, themes, layered configs, the
|
||||
state file
|
||||
- [Development](docs/development.md): building, tests, pull requests and
|
||||
releasing
|
||||
|
||||
## Privacy
|
||||
|
||||
lazysecrets never stores, caches or sends your secret values. It keeps the
|
||||
names and metadata of what you've loaded in a cache file readable only by
|
||||
you, to show it at once next time (`cache: false` turns it off). It talks
|
||||
only to the services you use it with, and collects no usage data. The one
|
||||
call of its own is a daily check for a new release on this server, which
|
||||
`updateCheck: false` turns off.
|
||||
|
||||
## Repositories
|
||||
|
||||
The main repo of this codebase is at [git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets).
|
||||
The [GitHub mirror](https://github.com/karleinarb/lazysecrets) is for discoverability, issue-tracking and releases.
|
||||
It also makes it possible to give a ⭐ if you like lazysecrets!
|
||||
|
||||
## Contributing
|
||||
|
||||
I don't take pull requests as I don't have time to review and test them.
|
||||
But bug reports and ideas are very welcome!
|
||||
Open an [issue on GitHub](https://github.com/karleinarb/lazysecrets/issues) and I'll have a look.
|
||||
|
||||
## AI disclosure
|
||||
|
||||
Built with help from an agentic coding assistant. Every change is requested, reviewed and tested by me before it's merged.
|
||||
|
||||
## License
|
||||
|
||||
Copyright © 2026 Karl-Einar Bergstrøm.
|
||||
|
||||
lazysecrets is free software: you can redistribute it and/or modify it
|
||||
under the terms of the GNU General Public License as published by the Free
|
||||
Software Foundation, either version 3 of the License, or (at your option)
|
||||
any later version. See [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package lazysecrets holds what the module's root has for the app: the
|
||||
// changelog, embedded so the status pane can show it offline.
|
||||
package lazysecrets
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// Changelog is CHANGELOG.md as of the build: every release up to it.
|
||||
//
|
||||
//go:embed CHANGELOG.md
|
||||
var Changelog string
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
# git-cliff (https://git-cliff.org) builds CHANGELOG.md and the release
|
||||
# notes from the commits on main. Pull requests are squash-merged, so each
|
||||
# one is a single commit whose message is the PR title: a conventional one
|
||||
# (feat: …, fix: …, perf: …) puts it in the changelog, anything else
|
||||
# (chore, ci, docs, refactor, test, …) leaves it out.
|
||||
#
|
||||
# git cliff --unreleased # what the next release would say
|
||||
|
||||
[changelog]
|
||||
header = """
|
||||
# Changelog
|
||||
|
||||
Every release of lazysecrets, newest first.\n
|
||||
"""
|
||||
body = """
|
||||
{% if version -%}
|
||||
## {{ version }} – {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else -%}
|
||||
## Unreleased
|
||||
{% endif -%}
|
||||
{% if commits | length == 0 %}
|
||||
Hello world 👋
|
||||
{% endif -%}
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.breaking %}**Breaking:** {% endif %}{{ commit.message | split(pat="\n") | first | upper_first | trim }} \
|
||||
([{{ commit.id | truncate(length=7, end="") }}](https://git.kebcloud.no/keb/lazysecrets/commit/{{ commit.id }}))\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
footer = "<!-- generated by git-cliff (cliff.toml) -->"
|
||||
trim = true
|
||||
# Runs on each part (header, every release) separately.
|
||||
postprocessors = [
|
||||
{ pattern = "\n{3,}", replace = "\n\n" },
|
||||
# Squash commits end in the PR's number: link it.
|
||||
{ pattern = "\\(#([0-9]+)\\)", replace = "([#${1}](https://git.kebcloud.no/keb/lazysecrets/pulls/${1}))" },
|
||||
]
|
||||
|
||||
[git]
|
||||
conventional_commits = true
|
||||
filter_unconventional = false
|
||||
split_commits = false
|
||||
protect_breaking_commits = true
|
||||
tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+$"
|
||||
sort_commits = "oldest"
|
||||
commit_preprocessors = [
|
||||
# A merge commit (not a squash) says "Merge pull request 'feat: …' (#4)
|
||||
# from branch into main": keep the title and number.
|
||||
{ pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" },
|
||||
# #11 was squash-merged with #10 (the status pane) in it: name both.
|
||||
{ pattern = "^feat: link each changelog entry to its PR and commit \\(#11\\)", replace = "feat: a status pane (0) with version, updates, accounts, files and links; changelog entries link to their PR and commit (#11)" },
|
||||
# Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see
|
||||
# pr-title.yml); all become "feat: x".
|
||||
{ pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " },
|
||||
{ pattern = "^(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?):(\\S)", replace = "${1}${2}${3}: ${4}" },
|
||||
]
|
||||
commit_parsers = [
|
||||
{ body = "\\[skip changelog\\]", skip = true },
|
||||
|
||||
{ message = "^[a-z]+(\\(.*\\))?!:", group = "<!-- 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,407 @@
|
||||
// Command lazysecrets is a terminal UI for browsing secrets in Azure Key
|
||||
// Vault and Databricks secret scopes.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/instance"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/azure"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/databricks"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/app"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// looksLikeRef tells a reference given without "open" from a mistyped
|
||||
// subcommand, so the latter still gets the usage text.
|
||||
func looksLikeRef(s string) bool {
|
||||
return strings.Contains(s, "://") || strings.HasPrefix(s, "@Microsoft.KeyVault(") || strings.HasPrefix(s, "{{secrets/")
|
||||
}
|
||||
|
||||
// starredRepos are the repos the user starred something in: the repo, a
|
||||
// file or a key. Their records keep the repo's folder.
|
||||
func starredRepos(st *store.Store) []string {
|
||||
var out []string
|
||||
for key, n := range st.Nodes() {
|
||||
r, err := ref.Parse(key)
|
||||
if err == nil && r.Kind == provider.KindRepo && len(n.StarredBy) > 0 && n.Source != "" && !slices.Contains(out, n.Source) {
|
||||
out = append(out, n.Source)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// checkCommandKeys reports a custom command key that a built-in action or
|
||||
// another command in an overlapping context takes.
|
||||
func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
|
||||
for i, c := range cmds {
|
||||
if c.Key == "" {
|
||||
continue
|
||||
}
|
||||
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command))
|
||||
if err := km.CheckExtra(c.Key, name); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range cmds[:i] {
|
||||
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
|
||||
if d.Key == c.Key && overlap {
|
||||
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmpOr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
|
||||
// config directory.
|
||||
func defaultConfigPath() string {
|
||||
if env := os.Getenv("LAZYSECRETS_CONFIG_FILE"); env != "" {
|
||||
return env
|
||||
}
|
||||
dir, err := config.Dir()
|
||||
if err != nil {
|
||||
return "config.yaml"
|
||||
}
|
||||
return filepath.Join(dir, "config.yaml")
|
||||
}
|
||||
|
||||
func run() error {
|
||||
configPath := flag.String("config", defaultConfigPath(),
|
||||
"config file; several, comma-separated, are layered in order (also LAZYSECRETS_CONFIG_FILE)")
|
||||
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||
dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)")
|
||||
demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends")
|
||||
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request")
|
||||
icons := flag.Bool("icons", true,
|
||||
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0")
|
||||
clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
|
||||
lazysecrets [flags] browse secrets
|
||||
lazysecrets [flags] [open] <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:alice@example.com/demo", "Azure · alice"),
|
||||
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"),
|
||||
fake.Databricks(600 * time.Millisecond),
|
||||
fake.Repos(),
|
||||
}
|
||||
} else {
|
||||
pc := cfg.Providers
|
||||
// One provider per az account. Created even when disabled: AKV-
|
||||
// backed Databricks scopes fall back to reading the vault through
|
||||
// the default account.
|
||||
accounts, err := azure.NewAccounts(azure.Settings{
|
||||
HideSubscriptions: pc.Azure.HideSubscriptions,
|
||||
Vaults: pc.Azure.Vaults,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("azure: %w", err)
|
||||
}
|
||||
if config.On(pc.Azure.Enabled) {
|
||||
for _, a := range accounts {
|
||||
providers = append(providers, a)
|
||||
}
|
||||
}
|
||||
if config.On(pc.Databricks.Enabled) {
|
||||
dbx, err := databricks.New(accounts[0], pc.Databricks.HideProfiles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("databricks: %w", err)
|
||||
}
|
||||
providers = append(providers, dbx)
|
||||
}
|
||||
if config.On(pc.Repos.Enabled) {
|
||||
start := *dir
|
||||
if start == "" {
|
||||
start, _ = os.Getwd()
|
||||
}
|
||||
roots := []string{"~/git"}
|
||||
if pc.Repos.Roots != nil {
|
||||
roots = *pc.Repos.Roots
|
||||
}
|
||||
repos := repo.New(repo.Settings{
|
||||
Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st),
|
||||
Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
|
||||
})
|
||||
// The repo it started in (shown on top as Repo) and the
|
||||
// others, checked for secret files in the background like
|
||||
// the other sources; repos without any aren't listed.
|
||||
providers = append(providers, repos)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return fmt.Errorf("all providers are disabled in %s", *configPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Libraries (notably the Databricks SDK) log token refreshes through
|
||||
// the standard logger, which would draw over the TUI.
|
||||
log.SetOutput(io.Discard)
|
||||
|
||||
if getRef != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
return get(ctx, providers, st, *getRef)
|
||||
}
|
||||
|
||||
// Links opened elsewhere (lazysecrets launch) can come here.
|
||||
var p *tea.Program
|
||||
var srv *instance.Server
|
||||
if !*demo {
|
||||
srv, err = instance.Listen(instance.Dir(stateDir), func(link, front string) error {
|
||||
r, err := ref.Parse(link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Accept at once: launch waits for the answer, and opens a
|
||||
// window anyway if it's slow. Coming to the front can be
|
||||
// (activating the terminal app).
|
||||
go func() {
|
||||
p.Send(app.OpenLink(r))
|
||||
instance.Raise(front)
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
defer srv.Close()
|
||||
} // else links open in new windows: not worth failing for
|
||||
}
|
||||
var setup *app.HandlerSetup
|
||||
if !*demo {
|
||||
setup = handlerSetup(cfg, *configPath)
|
||||
}
|
||||
onActive := func() {}
|
||||
if srv != nil {
|
||||
onActive = srv.Touch
|
||||
}
|
||||
|
||||
// What was loaded last time, and where it was left in this folder.
|
||||
var cacheFile, placeDir string
|
||||
if !*demo {
|
||||
if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) {
|
||||
cacheFile = filepath.Join(d, "cache.json")
|
||||
}
|
||||
placeDir = *dir
|
||||
if placeDir == "" {
|
||||
placeDir, _ = os.Getwd()
|
||||
}
|
||||
if abs, err := filepath.Abs(placeDir); err == nil {
|
||||
placeDir = abs
|
||||
}
|
||||
}
|
||||
|
||||
m := app.New(providers, app.Options{
|
||||
Timeout: *timeout,
|
||||
Icons: *icons,
|
||||
ClipboardClear: *clipClear,
|
||||
FlatSources: cfg.SourcesView == "flat",
|
||||
CollapseGroups: config.On(cfg.CollapseGroups),
|
||||
LoadDelay: loadDelay,
|
||||
NoHistory: !config.On(cfg.History),
|
||||
OnActive: onActive,
|
||||
RevealTimeout: revealTimeout,
|
||||
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
||||
HideOnBlur: config.On(cfg.HideOnBlur),
|
||||
HandlerSetup: setup,
|
||||
Updater: updater(),
|
||||
UpdateCheck: config.On(cfg.UpdateCheck),
|
||||
Keys: &keymap,
|
||||
Commands: cfg.CustomCommands,
|
||||
Version: versionString(),
|
||||
Store: st,
|
||||
CacheFile: cacheFile,
|
||||
ConfigFiles: configFiles(*configPath),
|
||||
StatePath: statePath,
|
||||
Changelog: lazysecrets.Changelog,
|
||||
Dir: placeDir,
|
||||
Open: open,
|
||||
Styles: &theme,
|
||||
DefaultFg: defaultFg,
|
||||
})
|
||||
p = tea.NewProgram(m)
|
||||
final, err := p.Run()
|
||||
if fm, ok := final.(app.Model); ok {
|
||||
_ = fm.Persist() // a cache it couldn't save is no reason to fail
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// configFiles are the config files given, for the status pane: those
|
||||
// that exist, or the first if none does (where to create one).
|
||||
func configFiles(list string) []string {
|
||||
var all, found []string
|
||||
for _, f := range strings.Split(list, ",") {
|
||||
if f = strings.TrimSpace(f); f == "" {
|
||||
continue
|
||||
}
|
||||
all = append(all, f)
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
found = append(found, f)
|
||||
}
|
||||
}
|
||||
if len(found) == 0 && len(all) > 0 {
|
||||
return all[:1]
|
||||
}
|
||||
return found
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
@@ -0,0 +1,78 @@
|
||||
# Azure Key Vault
|
||||
|
||||
lazysecrets reads Azure with your az CLI login: run `az login` once, and
|
||||
it reuses it.
|
||||
|
||||
## Accounts
|
||||
|
||||
Every account signed in to az gets its own group in the Sources pane
|
||||
(`az login` again with another account to add one), and each account's
|
||||
vaults are read with that account. A subscription several accounts can see
|
||||
is listed under each, since their permissions can differ.
|
||||
|
||||
A service principal is used instead when `AZURE_CLIENT_ID`,
|
||||
`AZURE_TENANT_ID` and a secret or certificate are set in the environment.
|
||||
|
||||
On an account's group row, the fourth pane shows who it signs in as: name,
|
||||
user, tenant, object ID, whether the session used MFA, directory roles
|
||||
(built-in ones by name) and its subscriptions, read from az's account list
|
||||
and the token lazysecrets already has: no requests of their own. `y` on
|
||||
the row copies the object ID, the one access policies and role
|
||||
assignments ask for; `l` goes into the pane, where `j`/`k` pick any
|
||||
field, `/` filters them (`/tenant`) and `y` copies the selected one.
|
||||
|
||||
If an account's az login has expired, the status line says so once, and the
|
||||
Details pane on that account's rows says it until you sign in: press `L`
|
||||
there to run `az login` for it without leaving lazysecrets, and everything
|
||||
reloads.
|
||||
|
||||
## Permissions
|
||||
|
||||
Vaults are discovered with Resource Graph, so you need read access to the
|
||||
vault resources (e.g. *Reader*). Key Vault data-plane access is separate
|
||||
(Azure RBAC or access policies). With RBAC, typically:
|
||||
|
||||
| To | Role |
|
||||
|---|---|
|
||||
| list secrets, keys and certificates | *Key Vault Reader* |
|
||||
| read secret values | *Key Vault Secrets User* |
|
||||
| read certificate details | *Key Vault Certificate User* |
|
||||
|
||||
A missing permission is shown in the pane, shortened to what was denied
|
||||
(the action, or the access policy permission), with what to check: a
|
||||
role or group to activate in PIM, access from the vault's owner, or, when
|
||||
its firewall refused, the network you're on.
|
||||
|
||||
## Vaults you can't discover
|
||||
|
||||
A vault you can read but not see through Resource Graph can be pinned in
|
||||
the [config](config.md); it's listed under "Pinned vaults" and read with
|
||||
az's default account:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
azure:
|
||||
vaults:
|
||||
- https://kv-foo.vault.azure.net/
|
||||
hideSubscriptions: [platform-mgmt] # by name or ID
|
||||
```
|
||||
|
||||
## What's shown
|
||||
|
||||
Secrets, keys and certificates, each on their own tab of the Secrets pane
|
||||
(`[`/`]`). `space` reveals a secret's value, or the public material of a
|
||||
key (PEM public key) or certificate; private key material is never
|
||||
fetched. `v` lists an item's versions. Items expiring within
|
||||
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
|
||||
|
||||
The status pane (`0`) lists them across every vault loaded, opened or
|
||||
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
|
||||
on one goes to it. That makes no requests of its own: expiry dates come
|
||||
with the lists. It says how many vaults it covers; `ctrl+a` in search
|
||||
loads the rest.
|
||||
|
||||
On a vault, the right pane shows its settings: resource group, location,
|
||||
SKU, whether access is by Azure roles or access policies, soft delete and
|
||||
purge protection, which networks can reach it, private endpoints and tags.
|
||||
They come with the list of vaults, so they cost no extra requests and are
|
||||
cached with it.
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
# 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)
|
||||
cache: true # keep what was loaded (never values) for the next start
|
||||
providers:
|
||||
azure: # applies to every az account
|
||||
enabled: true # default
|
||||
hideSubscriptions: [platform-mgmt] # by name or ID
|
||||
vaults: # listed under "Pinned vaults", for vaults
|
||||
- https://kv-foo.vault.azure.net/ # you can read but not discover
|
||||
databricks:
|
||||
enabled: true
|
||||
hideProfiles: [test]
|
||||
repos:
|
||||
enabled: true
|
||||
roots: [~/git]
|
||||
paths: [~/work/infra]
|
||||
include: ["secrets/**"]
|
||||
exclude: ["**/testdata/**"]
|
||||
```
|
||||
|
||||
More, each on its own page: [repos](repos.md) (`providers.repos`), the
|
||||
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
|
||||
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
|
||||
[custom commands](custom-commands.md) (`customCommands`).
|
||||
|
||||
## Clipboard
|
||||
|
||||
The clipboard is the system clipboard locally and OSC52 over SSH (your
|
||||
terminal must allow OSC52 writes; in tmux, `set -g set-clipboard on`).
|
||||
|
||||
## Theme
|
||||
|
||||
Colours follow your terminal's 16-colour palette by default. A `theme:`
|
||||
section overrides roles with hex colours, palette numbers (0-255) or names,
|
||||
plus attributes (`bold`, `italic`, `underline`, `reverse`, `faint`). Role
|
||||
names follow lazygit's `gui.theme` where they mean the same thing:
|
||||
|
||||
```yaml
|
||||
theme:
|
||||
activeBorderColor: ["#ff966c", bold] # focused pane
|
||||
inactiveBorderColor: ["#589ed7"]
|
||||
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
|
||||
inactiveViewSelectedLineBgColor: ["#2f334d"] # in the other panes (default: palette 8, not bold)
|
||||
defaultFgColor: ["#c8d3f5"]
|
||||
optionsTextColor: ["#82aaff"] # keys and labels
|
||||
searchMatchColor: ["#ffc777", bold]
|
||||
errorColor: ["#c53b53"] # ✗ failures
|
||||
noticeColor: ["#c3e88d"]
|
||||
successColor: ["#c3e88d", bold] # the ✓ before confirmations
|
||||
warningColor: ["#ffc777"] # ⚠ what can't be done here
|
||||
mutedColor: ["#636da6"]
|
||||
favouriteColor: ["#ffc777"]
|
||||
valueColor: ["#ffc777"]
|
||||
azureColor: ["#82aaff"]
|
||||
databricksColor: ["#ff757f"]
|
||||
repoColor: ["#c099ff"]
|
||||
```
|
||||
|
||||
Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render
|
||||
as boxes, turn them off with `icons: false`.
|
||||
|
||||
## Layering
|
||||
|
||||
Several config files can be layered, later ones winning (theme roles and
|
||||
keybindings merge one by one), with a comma-separated `--config` or
|
||||
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`. An editor
|
||||
plugin can so add a generated theme file to your own config, the way
|
||||
snacks.nvim themes lazygit from Neovim's highlight groups:
|
||||
|
||||
```sh
|
||||
LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets
|
||||
```
|
||||
|
||||
## State
|
||||
|
||||
Stars, notes, usage and the layout are kept in
|
||||
`~/.local/state/lazysecrets/state.json` (`$XDG_STATE_HOME`), readable only
|
||||
by you: one record per source, vault or secret, keyed by its
|
||||
`lazysecrets://` link, never with values. A star belongs to the account you
|
||||
starred the item under (a vault two of your accounts can see can be starred
|
||||
for one); a note describes the item, so it shows under every account. It's
|
||||
JSON so an editor plugin can read it with a built-in parser; several
|
||||
lazysecrets windows can save to it without losing each other's changes.
|
||||
|
||||
Revealing, copying, opening in the browser, sharing and jumping to an item
|
||||
count as using it: its record keeps when it was last used and how often,
|
||||
for Recent (`R`). Records kept only for usage are dropped after 90 days
|
||||
unused, and beyond the 100 most recent. `history: false` stops counting.
|
||||
|
||||
It also keeps where you left lazysecrets in each folder (the selected
|
||||
source, vault or secret), and goes back there the next time it starts in
|
||||
that folder, unless it was started on a link.
|
||||
|
||||
## Cache
|
||||
|
||||
What lazysecrets has loaded is kept in
|
||||
`~/.cache/lazysecrets/cache.json` (`$XDG_CACHE_HOME`), readable only by
|
||||
you: the Sources lists, vaults and scopes, and secrets' names and metadata
|
||||
(tags, dates, references), never values. On the next start they show at
|
||||
once, and anything older than two minutes reloads in the background when
|
||||
you go to it, or when Go to searches it. While it does, the pane's
|
||||
bottom border says how old what it shows is (`cached 3h ago`). Revealing
|
||||
and copying always fetch the value live.
|
||||
|
||||
Lists not loaded for 30 days are dropped, and so is everything from an
|
||||
account you're no longer signed in to. Repos aren't cached: their files
|
||||
are read from disk anyway. Deleting the file is always safe, and
|
||||
`cache: false` stops using it.
|
||||
|
||||
## Updates
|
||||
|
||||
Once a day (`updateCheck`), in the background, lazysecrets asks 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,26 @@
|
||||
# Databricks
|
||||
|
||||
Workspaces come from the profiles in `~/.databrickscfg` (or
|
||||
`$DATABRICKS_CONFIG_FILE`); account-console profiles are skipped. Each
|
||||
workspace is a source, its secret scopes are the vaults.
|
||||
|
||||
Credentials are resolved the same way as the Databricks CLI, so
|
||||
`auth_type = azure-cli` uses your az login and OAuth profiles use
|
||||
`databricks auth login`. When a login expires, `L` runs the right one for
|
||||
that profile.
|
||||
|
||||
Secrets in Azure Key Vault-backed scopes are read through Databricks; if
|
||||
Databricks refuses, lazysecrets reads the backing vault directly with your
|
||||
az login (which then needs the [Key Vault roles](azure.md#permissions)).
|
||||
|
||||
When Databricks refuses, the pane says what to check: access to the
|
||||
workspace and READ on the scope, or, when the workspace's IP access list
|
||||
refused, the network you're on.
|
||||
|
||||
Hide profiles you don't want in the [config](config.md):
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
databricks:
|
||||
hideProfiles: [test]
|
||||
```
|
||||
@@ -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.
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
# Keys
|
||||
|
||||
`?` lists every key with what it does, the focused pane's first; type to
|
||||
filter, `enter` runs one. The help bar at the bottom shows the most useful
|
||||
keys for where you are. Every key can be [rebound](#rebinding-keys).
|
||||
|
||||
The rule: a letter in the lists, ctrl+ that letter where you type a filter
|
||||
(`f` stars, `ctrl+f` stars while filtering).
|
||||
|
||||
## Moving around
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `j`/`k`, `↑`/`↓` | move |
|
||||
| `g`/`G` | top / bottom |
|
||||
| `tab`, `shift+tab`/`h` | next / previous pane |
|
||||
| `l` | the details of what's selected, on the right (`h` goes back) |
|
||||
| `1`–`4` | jump to pane |
|
||||
| `enter` / `esc`, `⌫` | drill in / back (also clears a filter or leaves the versions view) |
|
||||
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
|
||||
| `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars |
|
||||
| `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it |
|
||||
| `r` | refresh the focused pane |
|
||||
| `q`, `ctrl+c` | quit |
|
||||
|
||||
## Searching
|
||||
|
||||
`/` in a pane, Go to, `?` and other popups with a text field all search
|
||||
the same way, with [fzf](https://github.com/junegunn/fzf)'s scoring (the
|
||||
same as snacks.nvim's picker): letters at the start of a word or in one
|
||||
run count far more than letters scattered through a name, matches in an
|
||||
item's own name more than in its path or note, and equally good matches
|
||||
put the shorter name first.
|
||||
|
||||
| Query | Finds |
|
||||
|---|---|
|
||||
| `dbpw` | fuzzy: `db-password` |
|
||||
| `pass prod` | both terms, in any order |
|
||||
| `smtp \| key` | either |
|
||||
| `'prod` | exactly `prod`, not fuzzy |
|
||||
| `^kv-` / `-dev$` | names starting / ending with it |
|
||||
| `!test` | without `test` |
|
||||
|
||||
Lowercase ignores case; typing an uppercase letter makes that term
|
||||
match case exactly.
|
||||
|
||||
## Secrets
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `space` | reveal / hide the selected value (public material for keys and certificates) |
|
||||
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field |
|
||||
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
||||
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
|
||||
| `o` | open the selected item in the browser (over SSH: copy the link) |
|
||||
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
|
||||
| `s` / `S` | copy a [link](links.md) to the selected item / share menu |
|
||||
| `ctrl+o` | open a link or reference (or just paste one) |
|
||||
| `x` | [custom commands](custom-commands.md) |
|
||||
| `L` | sign in again (shown when a login has expired) |
|
||||
| `U` | a newer lazysecrets: its changelog, and update in place (restart to use it); or look for one |
|
||||
|
||||
A revealed value is shown as it reads best: JSON indented, and PEM
|
||||
certificates as their subject, issuer, dates, names and thumbprint, with
|
||||
any other PEM block (a private key) named but not shown. `y` still copies
|
||||
it exactly as stored.
|
||||
|
||||
A revealed value hides again after `revealTimeout` (60s) and when the
|
||||
terminal loses focus (`hideOnBlur`); see [config](config.md).
|
||||
|
||||
## Stars and notes
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list |
|
||||
| `n` | write a short note on it, shown dimmed on its row and in the detail pane |
|
||||
|
||||
A star belongs to the account you starred the item under; a note shows
|
||||
under every account. Both are kept in the [state file](config.md#state).
|
||||
|
||||
## Go to
|
||||
|
||||
`ctrl+s` opens Go to on its Search tab, `F` on Favourites (everything you
|
||||
starred) and `R` on Recent (what you used last). `enter` goes to the
|
||||
selected item.
|
||||
|
||||
Search has the scope on the left and what's in it on the right. The
|
||||
scopes are All, then grouped like the Sources pane: each account (or
|
||||
Databricks, or Repos) with its sources under it, with `○ ◐ ●` for how
|
||||
much of each is loaded. Repos searches every repo without asking Azure
|
||||
or Databricks for anything. Go to opens on All; `ctrl+s` again narrows
|
||||
it to the source or group selected in the Sources pane, and once more
|
||||
goes back to All. Typing a search loads what it covers: everything on
|
||||
All, else the account or source picked; results appear as lists
|
||||
arrive. That's once each time Go to opens, and only what isn't loaded
|
||||
yet; `ctrl+a` loads again, lists that failed included.
|
||||
|
||||
Like a pane, Go to is either browsing or typing a filter:
|
||||
|
||||
| Browsing | Typing | Action |
|
||||
|---|---|---|
|
||||
| `j`/`k` | `ctrl+j`/`ctrl+k` | move |
|
||||
| `g`/`G` | | top / bottom |
|
||||
| `ctrl+s` | `ctrl+s` | only the source or group you're in / All |
|
||||
| `h`/`l`, `tab` | `ctrl+h`/`ctrl+l`, `tab` | scope list / results |
|
||||
| `[` / `]` | | previous / next tab |
|
||||
| `f` | `ctrl+f` | star |
|
||||
| `r` | `ctrl+r` | load the selected item, or the scope when nothing is selected |
|
||||
| `a` | `ctrl+a` | load everything in the scope (again) |
|
||||
| `/` | `esc` | start typing / clear it and go back to browsing |
|
||||
| `esc`, `q` | | close |
|
||||
|
||||
Favourites and Recent come from the state file, so they work before
|
||||
anything loads.
|
||||
|
||||
## Layout
|
||||
|
||||
The pane on the right describes what's selected in the pane you're in: the
|
||||
account or subscription in Sources, the vault in Vaults, the secret in
|
||||
Secrets. Going into it (`l`, or `4`) keeps it on that; there `j`/`k`,
|
||||
`/` and `y` work on its rows, and `h` goes back. A value you reveal stays in view whichever pane you're in, until
|
||||
it's hidden.
|
||||
|
||||
On top, the status pane (`0`) shows the version, a newer release and how
|
||||
your accounts are doing. Focused, the right pane lists more under the
|
||||
logo (when there's room): the version and updates (`U`), each account
|
||||
(`L` on one that needs it signs in), what's expiring (`enter` goes to
|
||||
it), this session's errors, newest first (what a pane showed, or the
|
||||
footer flashed before you moved on; not kept after quitting), the config,
|
||||
state and cache files (`e` opens one in your editor), and links to the
|
||||
docs, source, issues, releases and changelog (`o` opens one; on the
|
||||
version or update, its release), then the changelog of every release up
|
||||
to yours, built in so it needs no network: `/` filters it (`/fixed`,
|
||||
`/search`). `y` copies any row.
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `ctrl+←`/`ctrl+→` or `<`/`>` | move the divider between the left panes and the detail pane |
|
||||
| `ctrl+↓`/`ctrl+↑` or `}`/`{` | make the focused left pane taller / shorter |
|
||||
| `+` / `_` | zoom the focused pane: normal → half → full screen, and back |
|
||||
| `=` | reset the layout (divider, heights, zoom) |
|
||||
|
||||
The divider and pane heights are remembered (zoom isn't). macOS uses
|
||||
ctrl+arrows for Mission Control and switching desktops by default, hence
|
||||
the `<` `>` `{` `}` alternatives.
|
||||
|
||||
## Rebinding keys
|
||||
|
||||
Every action can be rebound in the [config](config.md), per context:
|
||||
|
||||
```yaml
|
||||
keybinding:
|
||||
lists: # the panes, when not typing
|
||||
star: [x] # replaces f
|
||||
versions: [] # unbound
|
||||
filter: # typing a filter in a pane
|
||||
star: [ctrl+x]
|
||||
popup: # popups with a text field: help, notes, open link, Go to while typing
|
||||
down: [ctrl+j, down]
|
||||
menu: # popups without a text field
|
||||
search: # Go to's own actions while typing (on top of popup)
|
||||
goto: # Go to while browsing
|
||||
```
|
||||
|
||||
An action you set gets exactly the keys you list, replacing its defaults;
|
||||
`[]` unbinds it. Actions you don't set keep their defaults, also across
|
||||
layered config files. `lazysecrets keybindings` prints every action with
|
||||
its keys (your config applied), ready to copy and edit. A key bound to two
|
||||
actions that apply together is an error at startup, naming both. The help
|
||||
bar, `?` and every hint show the keys you bound. Fixed: the letters inside
|
||||
menus (they're part of each entry), and `ctrl+c`, which always quits.
|
||||
|
||||
Without the kitty keyboard protocol (or tmux `extended-keys`), some
|
||||
combinations never reach a terminal app, such as `ctrl+shift+x`, `ctrl+1`
|
||||
or `ctrl+enter`.
|
||||
+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.
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# SOPS and .env files in git repos
|
||||
|
||||
lazysecrets shows git repositories under **Repos**: their
|
||||
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
|
||||
`.env`, INI) and plain env files as vaults, and the keys in them
|
||||
(`db.password`, `list[0]`) as secrets.
|
||||
|
||||
## Which repos
|
||||
|
||||
**Repos** lists every repo with secret files under the `roots` (`~/git`
|
||||
by default; one or two levels down, `repo` or `org/repo`), any listed in
|
||||
`paths`, and any you starred something in (the repo, a file or a key),
|
||||
wherever it is. They're checked in the background.
|
||||
|
||||
Started inside a repo, that repo gets a row of its own at the top,
|
||||
`Repo · <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.
|
||||
|
||||
On a file, the right pane shows how it's encrypted and the keys it's
|
||||
encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP
|
||||
fingerprints), when it was last encrypted, and what git knows of it:
|
||||
tracked, ignored or neither, and its last commit. A plain `.env` file that
|
||||
git tracks, or that isn't ignored, is marked in red: its values are in the
|
||||
repo's history, or easily could be.
|
||||
|
||||
## Opening files
|
||||
|
||||
`e` opens the selected file, or on a secret, its file at that key's line:
|
||||
|
||||
- **SOPS files** open with `sops edit`, which decrypts them into your
|
||||
editor (`$SOPS_EDITOR` or `$EDITOR`) and encrypts them again when you
|
||||
save. lazysecrets comes back after, with the file's keys reloaded.
|
||||
- **Other files**, when lazysecrets runs inside nvim (its plugin, or
|
||||
`:terminal`): lazysecrets quits and the file opens in that nvim, in the
|
||||
window you came from.
|
||||
- Otherwise in `$VISUAL` or `$EDITOR` in the terminal, coming back after;
|
||||
without either, in the system's default app for the file.
|
||||
|
||||
Editors that take a line (vim, nvim, nano, emacs, micro, helix, VS Code,
|
||||
Zed, Sublime and others) start on the key. With `sops edit` that works for
|
||||
editors taking `+N` (vim, nvim, nano, emacs, micro and others), and needs
|
||||
sops 3.8 or later.
|
||||
|
||||
## Links
|
||||
|
||||
Links name the repo by its git remote, so they open in a colleague's clone
|
||||
wherever it is:
|
||||
|
||||
```
|
||||
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
||||
```
|
||||
|
||||
See [links](links.md).
|
||||
@@ -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,233 @@
|
||||
// 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"`
|
||||
// Cache keeps what was loaded (never values) between runs, so lists
|
||||
// show at once and refresh in the background; defaults to true.
|
||||
Cache *bool `yaml:"cache"`
|
||||
// CollapseGroups starts the Sources pane with its groups folded;
|
||||
// defaults to true.
|
||||
CollapseGroups *bool `yaml:"collapseGroups"`
|
||||
Providers Providers `yaml:"providers"`
|
||||
// LinkHandler sets how lazysecrets:// links open; see linkhandler.
|
||||
LinkHandler LinkHandler `yaml:"linkHandler"`
|
||||
// CustomCommands run shell commands on the selected item; see
|
||||
// package commands.
|
||||
CustomCommands []commands.Command `yaml:"customCommands"`
|
||||
// Keybinding sets keys per context and action, replacing the
|
||||
// defaults of each action given; see keys.Map.Apply.
|
||||
Keybinding map[string]map[string][]string `yaml:"keybinding"`
|
||||
// Theme maps roles to colours, like lazygit's gui.theme; see
|
||||
// styles.Roles.
|
||||
Theme map[string][]string `yaml:"theme"`
|
||||
}
|
||||
|
||||
type LinkHandler struct {
|
||||
// Terminal to open links in, overriding the one handler install
|
||||
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
|
||||
// gnome-terminal, konsole or foot.
|
||||
Terminal string `yaml:"terminal"`
|
||||
// Command opens any other terminal: its arguments, with {cmd} where
|
||||
// the lazysecrets command goes, e.g. [foot, "{cmd}"].
|
||||
Command []string `yaml:"command"`
|
||||
// Reuse hands links to a running lazysecrets, the one last used that
|
||||
// can be brought into view, before opening a new window. Defaults to
|
||||
// true.
|
||||
Reuse *bool `yaml:"reuse"`
|
||||
// Prompt offers to set up the link handler at startup when links
|
||||
// don't open lazysecrets. Defaults to true.
|
||||
Prompt *bool `yaml:"prompt"`
|
||||
}
|
||||
|
||||
type Providers struct {
|
||||
Azure Azure `yaml:"azure"`
|
||||
Databricks Databricks `yaml:"databricks"`
|
||||
Repos Repos `yaml:"repos"`
|
||||
}
|
||||
|
||||
// Repos are git repositories' secret files (SOPS, .env). Inside a repo,
|
||||
// that repo is the only one shown; elsewhere, the ones in Paths and under
|
||||
// Roots.
|
||||
type Repos struct {
|
||||
// Enabled defaults to true.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
Paths []string `yaml:"paths"`
|
||||
// Roots are folders of repos. Defaults to ~/git; an empty list
|
||||
// turns it off.
|
||||
Roots *[]string `yaml:"roots"`
|
||||
// Include and Exclude are globs relative to a repo's root (** for any
|
||||
// depth), for repos without their own .lazysecrets.yaml. Without them,
|
||||
// a repo's .sops.yaml creation_rules say where to look.
|
||||
Include []string `yaml:"include"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
}
|
||||
|
||||
type Azure struct {
|
||||
// Enabled defaults to true.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
// HideSubscriptions lists subscriptions to leave out, by name or ID.
|
||||
HideSubscriptions []string `yaml:"hideSubscriptions"`
|
||||
// Vaults are shown under "Pinned vaults", for vaults that can't be
|
||||
// discovered (e.g. data-plane access only).
|
||||
Vaults []string `yaml:"vaults"`
|
||||
}
|
||||
|
||||
type Databricks struct {
|
||||
// Enabled defaults to true.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
HideProfiles []string `yaml:"hideProfiles"`
|
||||
}
|
||||
|
||||
// Dir is $XDG_CONFIG_HOME/lazysecrets, defaulting to ~/.config/lazysecrets
|
||||
// on every platform, like most terminal tools.
|
||||
func Dir() (string, error) {
|
||||
return xdgDir("XDG_CONFIG_HOME", ".config")
|
||||
}
|
||||
|
||||
// CacheDir is $XDG_CACHE_HOME/lazysecrets, defaulting to
|
||||
// ~/.cache/lazysecrets. It holds what was loaded, to show it at once.
|
||||
func CacheDir() (string, error) {
|
||||
return xdgDir("XDG_CACHE_HOME", ".cache")
|
||||
}
|
||||
|
||||
// StateDir is $XDG_STATE_HOME/lazysecrets, defaulting to
|
||||
// ~/.local/state/lazysecrets. It holds data the app writes, like favourites.
|
||||
func StateDir() (string, error) {
|
||||
return xdgDir("XDG_STATE_HOME", filepath.Join(".local", "state"))
|
||||
}
|
||||
|
||||
func xdgDir(env, fallback string) (string, error) {
|
||||
base := os.Getenv(env)
|
||||
if base == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base = filepath.Join(home, fallback)
|
||||
}
|
||||
return filepath.Join(base, "lazysecrets"), nil
|
||||
}
|
||||
|
||||
// Load reads path. A missing file is an empty config. Unknown keys are an
|
||||
// error, so typos don't silently do nothing.
|
||||
func Load(path string) (Config, error) {
|
||||
return LoadAll([]string{path})
|
||||
}
|
||||
|
||||
// LoadAll reads several config files in order, each on top of the ones
|
||||
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the
|
||||
// settings it sets, and theme roles merge one by one. That lets an editor
|
||||
// plugin add a generated theme file to the user's own config. Missing
|
||||
// files are skipped.
|
||||
func LoadAll(paths []string) (Config, error) {
|
||||
var c Config
|
||||
keybinding := map[string]map[string][]string{}
|
||||
for _, path := range paths {
|
||||
// Keybindings layer per action: a later file setting one action
|
||||
// in a context keeps the others set before it.
|
||||
c.Keybinding = nil
|
||||
if err := decodeInto(&c, path); err != nil {
|
||||
return c, err
|
||||
}
|
||||
for ctx, actions := range c.Keybinding {
|
||||
if keybinding[ctx] == nil {
|
||||
keybinding[ctx] = map[string][]string{}
|
||||
}
|
||||
for name, keys := range actions {
|
||||
keybinding[ctx][name] = keys
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Keybinding = keybinding
|
||||
return c, c.validate(strings.Join(paths, ", "))
|
||||
}
|
||||
|
||||
func decodeInto(c *Config, path string) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dec := yaml.NewDecoder(bytes.NewReader(b))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(c); err != nil && !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) validate(path string) error {
|
||||
if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" {
|
||||
return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v)
|
||||
}
|
||||
for _, cmd := range c.CustomCommands {
|
||||
if err := cmd.Validate(); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
}
|
||||
if d := c.ExpiryWarningDays; d != nil && *d < 0 {
|
||||
return fmt.Errorf("%s: expiryWarningDays: %d is negative", path, *d)
|
||||
}
|
||||
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
|
||||
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
|
||||
}
|
||||
for i, 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,157 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// accountDetails describes an az account from az's account list and the
|
||||
// claims of the latest token lazysecrets got for it: nothing is fetched
|
||||
// for it.
|
||||
type accountDetails struct {
|
||||
account
|
||||
claims func() map[string]any
|
||||
}
|
||||
|
||||
var _ provider.Accounts = (*Provider)(nil)
|
||||
|
||||
// Account describes the az account, once a token has been fetched for it
|
||||
// (the user's name and object ID come from the token).
|
||||
func (p *Provider) Account() (provider.Account, bool) {
|
||||
if p.account == nil {
|
||||
return provider.Account{}, false
|
||||
}
|
||||
a := p.account
|
||||
claims := a.claims()
|
||||
str := func(k string) string { s, _ := claims[k].(string); return s }
|
||||
|
||||
var out provider.Account
|
||||
add := func(label, value string) {
|
||||
if value != "" {
|
||||
out.Fields = append(out.Fields, provider.Attr{Label: label, Value: value})
|
||||
}
|
||||
}
|
||||
add("name", str("name"))
|
||||
user := a.user
|
||||
if a.userType == "servicePrincipal" {
|
||||
user += " (service principal)"
|
||||
}
|
||||
add("user", user)
|
||||
add("tenant", a.tenantName)
|
||||
add("tenant ID", a.tenant)
|
||||
if oid := str("oid"); oid != "" {
|
||||
add("object ID", oid)
|
||||
out.Copy, out.CopyLabel = oid, "object ID"
|
||||
}
|
||||
if claims != nil {
|
||||
add("sign-in", signInMethods(claims["amr"]))
|
||||
add("roles", directoryRoles(claims["wids"]))
|
||||
}
|
||||
subs := fmt.Sprint(a.subscriptions)
|
||||
if a.defaultSub != "" {
|
||||
subs += " · default " + a.defaultSub
|
||||
}
|
||||
add("subs", subs)
|
||||
if a.cloud != "" && a.cloud != "AzureCloud" {
|
||||
add("cloud", a.cloud)
|
||||
}
|
||||
return out, len(out.Fields) > 0
|
||||
}
|
||||
|
||||
// signInMethods says how the session was signed in to, from the amr
|
||||
// claim: with MFA or not, and by what.
|
||||
func signInMethods(amr any) string {
|
||||
list, _ := amr.([]any)
|
||||
var methods []string
|
||||
mfa := false
|
||||
for _, m := range list {
|
||||
s, _ := m.(string)
|
||||
switch s {
|
||||
case "mfa", "ngcmfa":
|
||||
mfa = true
|
||||
case "pwd":
|
||||
methods = append(methods, "password")
|
||||
case "rsa":
|
||||
methods = append(methods, "Windows Hello or certificate")
|
||||
case "fido":
|
||||
methods = append(methods, "security key")
|
||||
case "wia":
|
||||
methods = append(methods, "Windows sign-in")
|
||||
case "otp", "sms":
|
||||
methods = append(methods, "one-time code")
|
||||
}
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return ""
|
||||
}
|
||||
how := strings.Join(methods, ", ")
|
||||
switch {
|
||||
case mfa && how != "":
|
||||
return "MFA · " + how
|
||||
case mfa:
|
||||
return "MFA"
|
||||
case how != "":
|
||||
return "no MFA · " + how
|
||||
}
|
||||
return "no MFA"
|
||||
}
|
||||
|
||||
// builtinRoles names Entra ID's built-in directory roles by template ID,
|
||||
// the IDs a token's wids claim lists.
|
||||
var builtinRoles = map[string]string{
|
||||
"62e90394-69f5-4237-9190-012177145e10": "Global Administrator",
|
||||
"f2ef992c-3afb-46b9-b7cf-a126ee74c451": "Global Reader",
|
||||
"e8611ab8-c189-46e8-94e1-60213ab1f814": "Privileged Role Administrator",
|
||||
"7be44c8a-adaf-4e2a-84d6-ab2649e08a13": "Privileged Authentication Administrator",
|
||||
"194ae4cb-b126-40b2-bd5b-6091b380977d": "Security Administrator",
|
||||
"5d6b6bb7-de71-4623-b4af-96380a352509": "Security Reader",
|
||||
"5f2222b1-57c3-48ba-8ad5-d4759f1fde6f": "Security Operator",
|
||||
"9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3": "Application Administrator",
|
||||
"158c047a-c907-4556-b7ef-446551a6b5f7": "Cloud Application Administrator",
|
||||
"cf1c38e5-3621-4004-a7cb-879624dced7c": "Application Developer",
|
||||
"fe930be7-5e62-47db-91af-98c3a49a38b1": "User Administrator",
|
||||
"fdd7a751-b60b-444a-984c-02652fe8fa1c": "Groups Administrator",
|
||||
"729827e3-9c14-49f7-bb1b-9608f156bbb8": "Helpdesk Administrator",
|
||||
"966707d0-3269-4727-9be2-8c3a10f19b9d": "Password Administrator",
|
||||
"c4e39bd9-1100-46d3-8c65-fb160da0071f": "Authentication Administrator",
|
||||
"0526716b-113d-4c15-b2c8-68e3c22b9f80": "Authentication Policy Administrator",
|
||||
"8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2": "Hybrid Identity Administrator",
|
||||
"b0f54661-2d74-4c50-afa3-1ec803f12efe": "Billing Administrator",
|
||||
"4d6ac14f-3453-41d0-bef9-a3e0c569773a": "License Administrator",
|
||||
"17315797-102d-40b4-93e0-432062caca18": "Compliance Administrator",
|
||||
"29232cdf-9323-42fd-ade2-1d097af3e4de": "Exchange Administrator",
|
||||
"f28a1f50-f6e7-4571-818b-6a12f2af6b6c": "SharePoint Administrator",
|
||||
"69091246-20e8-4a56-aa4d-066075b2a7a8": "Teams Administrator",
|
||||
"3a2c62db-5318-420d-8d74-23affee5d9d5": "Intune Administrator",
|
||||
"11648597-926c-4cf3-9c36-bcebb0ba8dcc": "Power Platform Administrator",
|
||||
"e3973bdf-4987-49ae-837a-ba8e231c7286": "Azure DevOps Administrator",
|
||||
"9360feb5-f418-4baa-8175-e2a00bac4301": "Directory Writers",
|
||||
"88d8e3e3-8f55-4a1e-953a-9b9898b8876b": "Directory Readers",
|
||||
"95e79109-95c0-4d8e-aee3-d01accf2d47b": "Guest Inviter",
|
||||
}
|
||||
|
||||
// memberRole is in every member's token: not worth listing.
|
||||
const memberRole = "b79fbf4d-3ef9-4689-8143-76b194e85509"
|
||||
|
||||
// directoryRoles names the roles in a wids claim: built-in ones by name,
|
||||
// others by their ID's first part.
|
||||
func directoryRoles(wids any) string {
|
||||
list, _ := wids.([]any)
|
||||
var names []string
|
||||
for _, w := range list {
|
||||
id, _ := w.(string)
|
||||
switch name, ok := builtinRoles[strings.ToLower(id)]; {
|
||||
case id == "" || id == memberRole:
|
||||
case ok:
|
||||
names = append(names, name)
|
||||
default:
|
||||
short, _, _ := strings.Cut(id, "-")
|
||||
names = append(names, "role "+short)
|
||||
}
|
||||
}
|
||||
slices.Sort(names)
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
func TestAccount(t *testing.T) {
|
||||
claims := map[string]any{
|
||||
"name": "Alice Example", "oid": "11111111-2222-3333-4444-555555555555",
|
||||
"amr": []any{"pwd", "mfa"},
|
||||
"wids": []any{"62e90394-69f5-4237-9190-012177145e10", memberRole, "aaaaaaaa-0000-0000-0000-000000000000"},
|
||||
}
|
||||
p := &Provider{account: &accountDetails{
|
||||
account: account{user: "alice@contoso.com", tenant: "t1", tenantName: "Contoso", subscriptions: 3, defaultSub: "platform"},
|
||||
claims: func() map[string]any { return claims },
|
||||
}}
|
||||
a, ok := p.Account()
|
||||
if !ok {
|
||||
t.Fatal("no account")
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, f := range a.Fields {
|
||||
got[f.Label] = f.Value
|
||||
}
|
||||
want := map[string]string{
|
||||
"name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso", "tenant ID": "t1",
|
||||
"object ID": "11111111-2222-3333-4444-555555555555", "sign-in": "MFA · password",
|
||||
"roles": "Global Administrator, role aaaaaaaa", "subs": "3 · default platform",
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("%s = %q, want %q", k, got[k], v)
|
||||
}
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("fields %v", got)
|
||||
}
|
||||
if a.Copy != claims["oid"] || a.CopyLabel != "object ID" {
|
||||
t.Errorf("copy %q %q", a.Copy, a.CopyLabel)
|
||||
}
|
||||
|
||||
// Before any token: what az's list says.
|
||||
claims = nil
|
||||
a, _ = p.Account()
|
||||
if len(a.Fields) != 4 || a.Copy != "" {
|
||||
t.Errorf("without a token: %+v", a)
|
||||
}
|
||||
if _, ok := (&Provider{}).Account(); ok {
|
||||
t.Error("a service principal from AZURE_* has no account details")
|
||||
}
|
||||
var _ provider.Accounts = p
|
||||
}
|
||||
|
||||
func TestSignInMethods(t *testing.T) {
|
||||
for want, amr := range map[string][]any{
|
||||
"MFA · password": {"pwd", "mfa"},
|
||||
"MFA · Windows Hello or certificate": {"rsa", "ngcmfa"},
|
||||
"no MFA · password": {"pwd"},
|
||||
"": nil,
|
||||
} {
|
||||
if got := signInMethods(amr); got != want {
|
||||
t.Errorf("%v: %q, want %q", amr, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// account is one az CLI identity in one tenant.
|
||||
type account struct {
|
||||
user, tenant string
|
||||
userType string // "user" or "servicePrincipal"
|
||||
tenantName string
|
||||
cloud string // az's environment: AzureCloud, AzureChinaCloud, …
|
||||
defaultSub string // az's default subscription's name, if this account's
|
||||
anchor string // a subscription az maps to this account, to pin it
|
||||
anchorEnabled bool
|
||||
isDefault bool
|
||||
subscriptions int // how many subscriptions az maps to it
|
||||
}
|
||||
|
||||
// azProfilePath is az's account list. It holds no tokens.
|
||||
func azProfilePath() (string, error) {
|
||||
dir := os.Getenv("AZURE_CONFIG_DIR")
|
||||
if dir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir = filepath.Join(home, ".azure")
|
||||
}
|
||||
return filepath.Join(dir, "azureProfile.json"), nil
|
||||
}
|
||||
|
||||
// readAccounts lists the accounts signed in to az, default first.
|
||||
func readAccounts(path string) ([]account, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf")) // az writes a BOM
|
||||
var profile struct {
|
||||
Subscriptions []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
TenantID string `json:"tenantId"`
|
||||
TenantName string `json:"tenantDisplayName"`
|
||||
Cloud string `json:"environmentName"`
|
||||
User struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
} `json:"user"`
|
||||
} `json:"subscriptions"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &profile); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
|
||||
var out []account
|
||||
for _, s := range profile.Subscriptions {
|
||||
if s.User.Name == "" || s.TenantID == "" {
|
||||
continue
|
||||
}
|
||||
i := slices.IndexFunc(out, func(a account) bool { return a.user == s.User.Name && a.tenant == s.TenantID })
|
||||
if i < 0 {
|
||||
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName, userType: s.User.Type, cloud: s.Cloud})
|
||||
i = len(out) - 1
|
||||
}
|
||||
a := &out[i]
|
||||
a.subscriptions++
|
||||
// Anchor on az's default subscription if it is this account's,
|
||||
// otherwise on an enabled one.
|
||||
enabled := s.State == "Enabled"
|
||||
switch {
|
||||
case s.IsDefault:
|
||||
a.anchor, a.anchorEnabled, a.isDefault, a.defaultSub = s.ID, enabled, true, s.Name
|
||||
case a.isDefault:
|
||||
case a.anchor == "" || enabled && !a.anchorEnabled:
|
||||
a.anchor, a.anchorEnabled = s.ID, enabled
|
||||
}
|
||||
}
|
||||
// az leaves the tenant's name out of some entries: take it from
|
||||
// another in the same tenant.
|
||||
for i := range out {
|
||||
for _, b := range out {
|
||||
if out[i].tenantName == "" && b.tenant == out[i].tenant {
|
||||
out[i].tenantName = b.tenantName
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(out, func(a, b account) int {
|
||||
if a.isDefault != b.isDefault {
|
||||
if a.isDefault {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return cmp.Compare(a.user, b.user)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// accountLabels names accounts as briefly as stays unambiguous: the user
|
||||
// name before the @, the full user if another user shares that, plus the
|
||||
// tenant if one user is signed in to several.
|
||||
func accountLabels(accounts []account) []string {
|
||||
short := func(u string) string { s, _, _ := strings.Cut(u, "@"); return s }
|
||||
labels := make([]string, len(accounts))
|
||||
for i, a := range accounts {
|
||||
l, tenants := short(a.user), 0
|
||||
for _, b := range accounts {
|
||||
if b.user != a.user && short(b.user) == short(a.user) {
|
||||
l = a.user
|
||||
}
|
||||
if b.user == a.user {
|
||||
tenants++
|
||||
}
|
||||
}
|
||||
if tenants > 1 {
|
||||
l += " · " + cmp.Or(a.tenantName, a.tenant)
|
||||
}
|
||||
labels[i] = l
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// NewAccounts returns one provider per account signed in to az, the default
|
||||
// first, or a single provider if AZURE_* configures a service principal or
|
||||
// az has no accounts yet. Pinned vaults go to the first.
|
||||
func NewAccounts(s Settings) ([]*Provider, error) {
|
||||
if os.Getenv("AZURE_CLIENT_ID") != "" && os.Getenv("AZURE_TENANT_ID") != "" {
|
||||
cred, err := azidentity.NewEnvironmentCredential(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := newWithOptions(authCredential{cred: cred}, options{Settings: s})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*Provider{p}, nil
|
||||
}
|
||||
|
||||
path, err := azProfilePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accounts, err := readAccounts(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
// Not signed in yet: the first call fails with an AuthError and
|
||||
// the UI offers az login.
|
||||
cred := authCredential{cred: newCLICredential("", "", nil), login: []string{"az", "login"}}
|
||||
p, err := newWithOptions(cred, options{Settings: s})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*Provider{p}, nil
|
||||
}
|
||||
|
||||
labels := accountLabels(accounts)
|
||||
var out []*Provider
|
||||
for i, a := range accounts {
|
||||
settings := s
|
||||
if i > 0 {
|
||||
settings.Vaults = nil
|
||||
}
|
||||
label := ""
|
||||
if len(accounts) > 1 {
|
||||
label = string(provider.KindAzure) + " · " + labels[i]
|
||||
}
|
||||
cred := authCredential{
|
||||
cred: newCLICredential(a.user, a.tenant, func() ([]account, error) { return readAccounts(path) }),
|
||||
login: []string{"az", "login", "--tenant", a.tenant},
|
||||
account: label, // so "sign-in required" says which account
|
||||
}
|
||||
p, err := newWithOptions(cred, options{Settings: settings})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.account = &accountDetails{account: a, claims: cred.cred.(*cliCredential).claims}
|
||||
if label != "" {
|
||||
p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant
|
||||
p.label = label
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
)
|
||||
|
||||
const profileJSON = "\xef\xbb\xbf" + `{"installationId": "x", "subscriptions": [
|
||||
{"id": "s-admin-1", "name": "platform", "state": "Enabled", "isDefault": false, "tenantId": "t1", "tenantDisplayName": "Contoso",
|
||||
"user": {"name": "admin.alice@contoso.onmicrosoft.com", "type": "user"}},
|
||||
{"id": "s-admin-2", "name": "old", "state": "Disabled", "isDefault": false, "tenantId": "t1",
|
||||
"user": {"name": "admin.alice@contoso.onmicrosoft.com", "type": "user"}},
|
||||
{"id": "s-alice-1", "name": "app-dev", "state": "Enabled", "isDefault": false, "tenantId": "t1",
|
||||
"user": {"name": "alice@contoso.com", "type": "user"}},
|
||||
{"id": "s-alice-2", "name": "app-prod", "state": "Enabled", "isDefault": true, "tenantId": "t1",
|
||||
"user": {"name": "alice@contoso.com", "type": "user"}}
|
||||
]}`
|
||||
|
||||
func TestReadAccounts(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "azureProfile.json")
|
||||
if err := os.WriteFile(path, []byte(profileJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readAccounts(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("accounts = %+v", got)
|
||||
}
|
||||
// Default account first, anchored on the default subscription.
|
||||
if got[0].user != "alice@contoso.com" || got[0].anchor != "s-alice-2" || !got[0].isDefault {
|
||||
t.Errorf("default account = %+v", got[0])
|
||||
}
|
||||
// Anchored on an enabled subscription, not the disabled one.
|
||||
if got[1].user != "admin.alice@contoso.onmicrosoft.com" || got[1].anchor != "s-admin-1" || got[1].subscriptions != 2 {
|
||||
t.Errorf("second account = %+v", got[1])
|
||||
}
|
||||
|
||||
if accts, err := readAccounts(filepath.Join(t.TempDir(), "missing.json")); err != nil || accts != nil {
|
||||
t.Errorf("missing profile: %v, %v", accts, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountLabels(t *testing.T) {
|
||||
got := accountLabels([]account{
|
||||
{user: "alice@contoso.com", tenant: "t1"},
|
||||
{user: "admin@a.example", tenant: "t1"},
|
||||
{user: "admin@b.example", tenant: "t1"},
|
||||
{user: "guest@x.example", tenant: "t1", tenantName: "Home"},
|
||||
{user: "guest@x.example", tenant: "t2", tenantName: "Partner"},
|
||||
})
|
||||
want := []string{"alice", "admin@a.example", "admin@b.example", "guest · Home", "guest · Partner"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("labels = %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLICredentialPinsAccount(t *testing.T) {
|
||||
var calls [][]string
|
||||
c := newCLICredential("alice@contoso.com", "t1", func() ([]account, error) {
|
||||
return []account{{user: "alice@contoso.com", tenant: "t1", anchor: "s-alice-2"}}, nil
|
||||
})
|
||||
c.run = func(_ context.Context, args ...string) ([]byte, error) {
|
||||
calls = append(calls, args)
|
||||
exp := time.Now().Add(time.Hour).Unix()
|
||||
return []byte(fmt.Sprintf(`{"accessToken": "tok", "expires_on": %d}`, exp)), nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
vault := policy.TokenRequestOptions{Scopes: []string{"https://vault.azure.net/.default"}}
|
||||
|
||||
// Same tenant (Key Vault's challenge names it): pin with --subscription only.
|
||||
vault.TenantID = "t1"
|
||||
if tok, err := c.GetToken(ctx, vault); err != nil || tok.Token != "tok" {
|
||||
t.Fatalf("token: %v, %v", tok, err)
|
||||
}
|
||||
if got := strings.Join(calls[0], " "); !strings.Contains(got, "--subscription s-alice-2") || strings.Contains(got, "--tenant") {
|
||||
t.Errorf("same tenant args: %s", got)
|
||||
}
|
||||
|
||||
// Cached: no second az call.
|
||||
if _, err := c.GetToken(ctx, vault); err != nil || len(calls) != 1 {
|
||||
t.Errorf("token not cached: %d calls", len(calls))
|
||||
}
|
||||
|
||||
// Another tenant: --tenant only, since az refuses both.
|
||||
vault.TenantID = "t2"
|
||||
if _, err := c.GetToken(ctx, vault); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(calls[1], " "); !strings.Contains(got, "--tenant t2") || strings.Contains(got, "--subscription") {
|
||||
t.Errorf("other tenant args: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLICredentialRenewsAndReportsErrors(t *testing.T) {
|
||||
n := 0
|
||||
c := newCLICredential("", "", nil)
|
||||
c.run = func(context.Context, ...string) ([]byte, error) {
|
||||
n++
|
||||
if n == 2 {
|
||||
return nil, errors.New("AADSTS700082: The refresh token has expired. Please run 'az login'")
|
||||
}
|
||||
// Expires inside the refresh margin, so the next call renews.
|
||||
return []byte(`{"accessToken": "tok", "expiresOn": "` + time.Now().Add(time.Minute).Format("2006-01-02 15:04:05.000000") + `"}`), nil
|
||||
}
|
||||
opts := policy.TokenRequestOptions{Scopes: []string{"https://management.azure.com/.default"}}
|
||||
if _, err := c.GetToken(context.Background(), opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.GetToken(context.Background(), opts); err == nil || !strings.Contains(err.Error(), "AADSTS700082") {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// jwt is an unsigned token with claims, as az would print it.
|
||||
func jwt(claims string) string {
|
||||
enc := base64.RawURLEncoding.EncodeToString
|
||||
return enc([]byte(`{"alg":"none"}`)) + "." + enc([]byte(claims)) + ".sig"
|
||||
}
|
||||
|
||||
func TestCLICredentialFollowsAzAndRefusesOtherUsers(t *testing.T) {
|
||||
// az's profile has one entry per subscription: signing in as bob,
|
||||
// who also sees s-shared, moved it from alice to bob.
|
||||
profile := []account{
|
||||
{user: "alice@contoso.com", tenant: "t1", anchor: "s-shared"},
|
||||
{user: "bob@contoso.com", tenant: "t1", anchor: "s-bob"},
|
||||
}
|
||||
var args []string
|
||||
issuedTo := "alice@contoso.com"
|
||||
c := newCLICredential("alice@contoso.com", "t1", func() ([]account, error) { return profile, nil })
|
||||
c.run = func(_ context.Context, a ...string) ([]byte, error) {
|
||||
args = a
|
||||
exp := time.Now().Add(time.Hour).Unix()
|
||||
return []byte(fmt.Sprintf(`{"accessToken": %q, "expires_on": %d}`, jwt(`{"upn":"`+issuedTo+`"}`), exp)), nil
|
||||
}
|
||||
arm := policy.TokenRequestOptions{Scopes: []string{"https://management.azure.com/.default"}}
|
||||
if _, err := c.GetToken(context.Background(), arm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(args, " "), "--subscription s-shared") {
|
||||
t.Errorf("args %q", args)
|
||||
}
|
||||
|
||||
// Now alice has another subscription, and az would hand out bob's
|
||||
// token for the old one: the new pin is used, and bob's token refused.
|
||||
profile[0].anchor = "s-alice"
|
||||
c.cache = map[string]azcore.AccessToken{}
|
||||
issuedTo = "bob@contoso.com"
|
||||
_, err := c.GetToken(context.Background(), arm)
|
||||
if !strings.Contains(strings.Join(args, " "), "--subscription s-alice") {
|
||||
t.Errorf("args %q", args)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "token for bob@contoso.com, not alice@contoso.com") {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
|
||||
// Alice gone from az entirely: sign in again.
|
||||
profile = profile[1:]
|
||||
if _, err := c.GetToken(context.Background(), arm); err == nil || !strings.Contains(err.Error(), "sign in as alice@contoso.com") {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenUser(t *testing.T) {
|
||||
users := []string{"bob@contoso.com"}
|
||||
for claims, want := range map[string]bool{
|
||||
`{"upn":"Bob@Contoso.com"}`: true,
|
||||
`{"unique_name":"live.com#bob@contoso.com"}`: true,
|
||||
`{"upn":"alice@contoso.com"}`: false,
|
||||
`{"appid":"00000000-0000-0000-0000-000000000000"}`: false,
|
||||
} {
|
||||
if _, got := tokenUser(tokenClaims(jwt(claims)), users); got != want {
|
||||
t.Errorf("%s: %v", claims, got)
|
||||
}
|
||||
}
|
||||
if _, got := tokenUser(tokenClaims("not-a-jwt"), users); got {
|
||||
t.Error("not a JWT")
|
||||
}
|
||||
}
|
||||
@@ -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,521 @@
|
||||
// Package azure is the Azure Key Vault provider. Sources are subscriptions,
|
||||
// containers are vaults (found via Resource Graph), and objects are secrets,
|
||||
// keys and certificates.
|
||||
package azure
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
|
||||
|
||||
// Provider serves one Azure identity: an az CLI account or a service
|
||||
// principal. Creating it doesn't contact Azure; authentication problems
|
||||
// surface as *provider.AuthError from the first call.
|
||||
type Provider struct {
|
||||
id, label string // "" for the single-account defaults
|
||||
|
||||
cred azcore.TokenCredential
|
||||
opts options
|
||||
account *accountDetails // nil for a service principal from AZURE_*
|
||||
subs *armsubscriptions.Client
|
||||
graph *armresourcegraph.Client
|
||||
|
||||
mu sync.Mutex
|
||||
vaults map[string]*vaultClients // by vault URL
|
||||
tenants map[string]string // tenant ID by subscription ID, for portal links
|
||||
}
|
||||
|
||||
type vaultClients struct {
|
||||
secrets *azsecrets.Client
|
||||
keys *azkeys.Client
|
||||
certs *azcertificates.Client
|
||||
}
|
||||
|
||||
var _ provider.Provider = (*Provider)(nil)
|
||||
|
||||
// Settings come from the config file.
|
||||
type Settings struct {
|
||||
// HideSubscriptions lists subscriptions to leave out, by name or ID.
|
||||
HideSubscriptions []string
|
||||
// Vaults are listed under a "Pinned vaults" source.
|
||||
Vaults []string
|
||||
}
|
||||
|
||||
// pinnedID identifies the source holding vaults from the config file.
|
||||
const pinnedID = "pinned-vaults"
|
||||
|
||||
// options lets tests point the SDK clients at a local server.
|
||||
type options struct {
|
||||
Settings
|
||||
|
||||
arm *arm.ClientOptions
|
||||
client azcore.ClientOptions
|
||||
skipChallengeValidation bool
|
||||
}
|
||||
|
||||
func newWithOptions(cred azcore.TokenCredential, opts options) (*Provider, error) {
|
||||
subs, err := armsubscriptions.NewClient(cred, opts.arm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
graph, err := armresourcegraph.NewClient(cred, opts.arm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Provider{
|
||||
cred: cred, opts: opts, subs: subs, graph: graph,
|
||||
vaults: map[string]*vaultClients{}, tenants: map[string]string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Kind() provider.Kind { return provider.KindAzure }
|
||||
|
||||
func (p *Provider) ID() string { return cmp.Or(p.id, string(provider.KindAzure)) }
|
||||
|
||||
func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindAzure)) }
|
||||
|
||||
// Sources lists the subscriptions the signed-in identity can see, skipping
|
||||
// disabled and deleted ones.
|
||||
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
var out []provider.Source
|
||||
pager := p.subs.NewListPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, s := range page.Value {
|
||||
switch deref(s.State) {
|
||||
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
|
||||
continue
|
||||
}
|
||||
id, tenant := deref(s.SubscriptionID), deref(s.TenantID)
|
||||
if config.Hidden(p.opts.HideSubscriptions, deref(s.DisplayName), id) {
|
||||
continue
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.tenants[id] = tenant
|
||||
p.mu.Unlock()
|
||||
out = append(out, provider.Source{
|
||||
Kind: provider.KindAzure,
|
||||
ID: id,
|
||||
Name: deref(s.DisplayName),
|
||||
Detail: "subscription " + id,
|
||||
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
|
||||
})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||
if len(p.opts.Vaults) > 0 {
|
||||
out = append(out, provider.Source{
|
||||
Kind: provider.KindAzure, ID: pinnedID, Name: "Pinned vaults", Detail: "from the config file",
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pinnedVaults lists the vaults from the config file. Without a resource ID
|
||||
// they have no portal link.
|
||||
func (p *Provider) pinnedVaults(src provider.Source) []provider.Container {
|
||||
out := make([]provider.Container, 0, len(p.opts.Vaults))
|
||||
for _, u := range p.opts.Vaults {
|
||||
if !strings.HasSuffix(u, "/") {
|
||||
u += "/"
|
||||
}
|
||||
name, _, _ := strings.Cut(strings.TrimPrefix(u, "https://"), ".")
|
||||
out = append(out, provider.Container{Source: src, ID: u, Name: name, Detail: u, Types: objectTypes})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Containers finds the subscription's vaults with Resource Graph, falling
|
||||
// back to the Key Vault management API if the query fails.
|
||||
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
if src.ID == pinnedID {
|
||||
return p.pinnedVaults(src), nil
|
||||
}
|
||||
vaults, err := p.graphVaults(ctx, src)
|
||||
if err != nil {
|
||||
var auth *provider.AuthError
|
||||
if errors.As(err, &auth) || ctx.Err() != nil {
|
||||
return nil, err
|
||||
}
|
||||
vaults, err = p.armVaults(ctx, src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
tenant := p.tenant(src.ID)
|
||||
for i := range vaults {
|
||||
vaults[i].URL = PortalResourceURL(tenant, vaults[i].ID, "overview")
|
||||
}
|
||||
slices.SortFunc(vaults, func(a, b provider.Container) int {
|
||||
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
})
|
||||
return vaults, nil
|
||||
}
|
||||
|
||||
func (p *Provider) tenant(subscriptionID string) string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.tenants[subscriptionID]
|
||||
}
|
||||
|
||||
func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
var out []provider.Container
|
||||
req := armresourcegraph.QueryRequest{
|
||||
Query: to.Ptr(vaultQuery),
|
||||
Subscriptions: []*string{to.Ptr(src.ID)},
|
||||
Options: &armresourcegraph.QueryRequestOptions{ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray)},
|
||||
}
|
||||
for {
|
||||
resp, err := p.graph.Resources(ctx, req, nil)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
rows, ok := resp.Data.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("resource graph: unexpected result %T", resp.Data)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if c, ok := graphVault(src, r); ok {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
if resp.SkipToken == nil || *resp.SkipToken == "" {
|
||||
return out, nil
|
||||
}
|
||||
req.Options.SkipToken = resp.SkipToken
|
||||
}
|
||||
}
|
||||
|
||||
func graphVault(src provider.Source, row any) (provider.Container, bool) {
|
||||
m, ok := row.(map[string]any)
|
||||
if !ok {
|
||||
return provider.Container{}, false
|
||||
}
|
||||
id, _ := m["id"].(string)
|
||||
name, _ := m["name"].(string)
|
||||
uri, _ := m["uri"].(string)
|
||||
if id == "" || name == "" || uri == "" {
|
||||
return provider.Container{}, false
|
||||
}
|
||||
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes, Attrs: graphInfo(m).attrs()}, true
|
||||
}
|
||||
|
||||
func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
client, err := armkeyvault.NewVaultsClient(src.ID, p.cred, p.opts.arm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Container
|
||||
pager := client.NewListBySubscriptionPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, v := range page.Value {
|
||||
if v.Properties == nil || v.Properties.VaultURI == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, provider.Container{
|
||||
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
||||
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) clients(vaultURL string) (*vaultClients, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if c, ok := p.vaults[vaultURL]; ok {
|
||||
return c, nil
|
||||
}
|
||||
skip := p.opts.skipChallengeValidation
|
||||
s, err := azsecrets.NewClient(vaultURL, p.cred, &azsecrets.ClientOptions{
|
||||
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, err := azkeys.NewClient(vaultURL, p.cred, &azkeys.ClientOptions{
|
||||
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := azcertificates.NewClient(vaultURL, p.cred, &azcertificates.ClientOptions{
|
||||
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vc := &vaultClients{secrets: s, keys: k, certs: c}
|
||||
p.vaults[vaultURL] = vc
|
||||
return vc, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
vc, err := p.clients(c.Detail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Object
|
||||
switch t {
|
||||
case provider.TypeSecret:
|
||||
pager := vc.secrets.NewListSecretPropertiesPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, s := range page.Value {
|
||||
// Certificate-backing secrets appear under Certificates.
|
||||
if s.ID == nil || deref(s.Managed) {
|
||||
continue
|
||||
}
|
||||
out = append(out, secretObject(c, s))
|
||||
}
|
||||
}
|
||||
case provider.TypeKey:
|
||||
pager := vc.keys.NewListKeyPropertiesPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, k := range page.Value {
|
||||
if k.KID == nil || deref(k.Managed) {
|
||||
continue
|
||||
}
|
||||
out = append(out, keyObject(c, k))
|
||||
}
|
||||
}
|
||||
case provider.TypeCertificate:
|
||||
pager := vc.certs.NewListCertificatePropertiesPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, cp := range page.Value {
|
||||
if cp.ID == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, certObject(c, cp))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Describe fetches key and certificate details. Secrets are returned as-is:
|
||||
// the only way to get more is GetSecret, which returns the value.
|
||||
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
|
||||
vc, err := p.clients(o.Container.Detail)
|
||||
if err != nil {
|
||||
return o, err
|
||||
}
|
||||
switch o.Type {
|
||||
case provider.TypeKey:
|
||||
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return o, cleanError(err)
|
||||
}
|
||||
if resp.Key != nil {
|
||||
if resp.Key.KID != nil {
|
||||
o.Version = resp.Key.KID.Version()
|
||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID))
|
||||
}
|
||||
o.Attrs = keyAttrs(resp.Key)
|
||||
}
|
||||
case provider.TypeCertificate:
|
||||
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return o, cleanError(err)
|
||||
}
|
||||
if resp.ID != nil {
|
||||
o.Version = resp.ID.Version()
|
||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID))
|
||||
}
|
||||
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
|
||||
o.ContentType = deref(pol.SecretProperties.ContentType)
|
||||
}
|
||||
if cert, err := x509.ParseCertificate(resp.CER); err == nil {
|
||||
o.Attrs = certAttrs(cert, resp.X509Thumbprint)
|
||||
}
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
|
||||
vc, err := p.clients(o.Container.Detail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Object
|
||||
switch o.Type {
|
||||
case provider.TypeSecret:
|
||||
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, s := range page.Value {
|
||||
if s.ID != nil {
|
||||
out = append(out, secretObject(o.Container, s))
|
||||
}
|
||||
}
|
||||
}
|
||||
case provider.TypeKey:
|
||||
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, k := range page.Value {
|
||||
if k.KID != nil {
|
||||
out = append(out, keyObject(o.Container, k))
|
||||
}
|
||||
}
|
||||
}
|
||||
case provider.TypeCertificate:
|
||||
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, cp := range page.Value {
|
||||
if cp.ID != nil {
|
||||
out = append(out, certObject(o.Container, cp))
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Reference = versionedReference(out[i])
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// versionedReference pins an object's reference to its version, e.g.
|
||||
// @Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/name/v1).
|
||||
func versionedReference(o provider.Object) string {
|
||||
if o.Version == "" {
|
||||
return o.Reference
|
||||
}
|
||||
if ref, ok := strings.CutSuffix(o.Reference, ")"); ok && o.Type == provider.TypeSecret {
|
||||
return ref + "/" + o.Version + ")"
|
||||
}
|
||||
return o.Reference + "/" + o.Version
|
||||
}
|
||||
|
||||
// Value returns a secret's value, a key's public key or a certificate's
|
||||
// public certificate, as of o.Version (latest if empty).
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
vc, err := p.clients(o.Container.Detail)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
switch o.Type {
|
||||
case provider.TypeSecret:
|
||||
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, cleanError(err)
|
||||
}
|
||||
return provider.NewSecretValue([]byte(deref(resp.Value))), nil
|
||||
case provider.TypeKey:
|
||||
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, cleanError(err)
|
||||
}
|
||||
pemKey, err := publicKeyPEM(resp.Key)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
return provider.NewSecretValue([]byte(pemKey)), nil
|
||||
case provider.TypeCertificate:
|
||||
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, cleanError(err)
|
||||
}
|
||||
if len(resp.CER) == 0 {
|
||||
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
|
||||
}
|
||||
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
|
||||
}
|
||||
return provider.SecretValue{}, provider.ErrUnsupported
|
||||
}
|
||||
|
||||
var _ provider.Locator = (*Provider)(nil)
|
||||
|
||||
// validHost guards the Resource Graph query against odd input.
|
||||
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
|
||||
|
||||
// Locate finds the subscription of the vault with the given host: a pinned
|
||||
// vault, or one Resource Graph query across the account's subscriptions.
|
||||
// Vaults in hidden subscriptions aren't found.
|
||||
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
|
||||
host = strings.ToLower(host)
|
||||
if !validHost.MatchString(host) {
|
||||
return "", false, fmt.Errorf("not a vault host: %q", host)
|
||||
}
|
||||
url := "https://" + host + "/"
|
||||
for _, v := range p.opts.Vaults {
|
||||
if strings.EqualFold(strings.TrimSuffix(v, "/")+"/", url) {
|
||||
return pinnedID, true, nil
|
||||
}
|
||||
}
|
||||
resp, err := p.graph.Resources(ctx, armresourcegraph.QueryRequest{
|
||||
Query: to.Ptr(`resources
|
||||
| where type =~ 'microsoft.keyvault/vaults' and tostring(properties.vaultUri) =~ '` + url + `'
|
||||
| project subscriptionId`),
|
||||
Options: &armresourcegraph.QueryRequestOptions{ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray)},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", false, cleanError(err)
|
||||
}
|
||||
rows, _ := resp.Data.([]any)
|
||||
for _, r := range rows {
|
||||
m, _ := r.(map[string]any)
|
||||
sub, _ := m["subscriptionId"].(string)
|
||||
if sub != "" && !config.Hidden(p.opts.HideSubscriptions, "", sub) {
|
||||
return sub, true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
type staticCred struct{ err error }
|
||||
|
||||
func (c staticCred) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
|
||||
if c.err != nil {
|
||||
return azcore.AccessToken{}, c.err
|
||||
}
|
||||
return azcore.AccessToken{Token: "token", ExpiresOn: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
|
||||
// fakeAzure serves just enough of ARM and the Key Vault data plane over TLS,
|
||||
// including Key Vault's 401 auth challenge.
|
||||
func fakeAzure(t *testing.T, cred azcore.TokenCredential, handler http.HandlerFunc) (*Provider, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
w.Header().Set("WWW-Authenticate",
|
||||
`Bearer authorization="https://login.microsoftonline.com/tenant", resource="https://vault.azure.net"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
handler(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
client := azcore.ClientOptions{
|
||||
Transport: srv.Client(),
|
||||
Retry: policy.RetryOptions{MaxRetries: -1},
|
||||
Cloud: cloud.Configuration{
|
||||
ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/",
|
||||
Services: map[cloud.ServiceName]cloud.ServiceConfiguration{
|
||||
cloud.ResourceManager: {Endpoint: srv.URL, Audience: "https://management.azure.com"},
|
||||
},
|
||||
},
|
||||
}
|
||||
p, err := newWithOptions(cred, options{
|
||||
arm: &arm.ClientOptions{ClientOptions: client},
|
||||
client: client,
|
||||
skipChallengeValidation: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p, srv
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func vault(srv *httptest.Server) provider.Container {
|
||||
return provider.Container{ID: "/subscriptions/s/vault", Name: "kv", Detail: srv.URL + "/", Types: objectTypes}
|
||||
}
|
||||
|
||||
func TestTokenFailureIsAuthError(t *testing.T) {
|
||||
login := []string{"az", "login"}
|
||||
cred := authCredential{cred: staticCred{err: errors.New("AADSTS700082: refresh token expired")}, login: login}
|
||||
p, srv := fakeAzure(t, cred, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("request sent without a token: %s", r.URL)
|
||||
})
|
||||
|
||||
check := func(name string, err error) {
|
||||
t.Helper()
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) {
|
||||
t.Fatalf("%s: error %v is not an AuthError", name, err)
|
||||
}
|
||||
if !reflect.DeepEqual(auth.Login, login) || !strings.Contains(auth.Error(), "AADSTS700082") {
|
||||
t.Errorf("%s: unexpected AuthError %+v", name, auth)
|
||||
}
|
||||
}
|
||||
_, err := p.Sources(context.Background())
|
||||
check("Sources (ARM)", err)
|
||||
_, err = p.Objects(context.Background(), vault(srv), provider.TypeSecret)
|
||||
check("Objects (Key Vault)", err)
|
||||
_, err = p.Containers(context.Background(), provider.Source{ID: "s"})
|
||||
check("Containers (no ARM fallback on auth errors)", err)
|
||||
}
|
||||
|
||||
func TestAuthErrorNamesAccount(t *testing.T) {
|
||||
cred := authCredential{cred: staticCred{err: errors.New("AADSTS53003")}, account: "Azure · admin"}
|
||||
_, err := cred.GetToken(context.Background(), policy.TokenRequestOptions{Scopes: []string{"x"}})
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) || auth.Who() != "Azure · admin" || !strings.HasPrefix(auth.Error(), "Azure · admin:") {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSecretsSkipsManagedAndPages(t *testing.T) {
|
||||
var srvURL string
|
||||
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/secrets" && r.URL.Query().Get("page") == "":
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"value": []any{
|
||||
map[string]any{"id": srvURL + "/secrets/b-secret", "contentType": "text/plain",
|
||||
"attributes": map[string]any{"enabled": true, "updated": 1767225600}, "tags": map[string]any{"env": "prod"}},
|
||||
map[string]any{"id": srvURL + "/secrets/tls-cert", "managed": true},
|
||||
},
|
||||
"nextLink": srvURL + "/secrets?page=2&api-version=" + r.URL.Query().Get("api-version"),
|
||||
})
|
||||
case r.URL.Path == "/secrets":
|
||||
writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": srvURL + "/secrets/a-secret"}}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
srvURL = srv.URL
|
||||
|
||||
got, err := p.Objects(context.Background(), vault(srv), provider.TypeSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, o := range got {
|
||||
names = append(names, o.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"a-secret", "b-secret"}) {
|
||||
t.Fatalf("names = %v, want sorted and without the managed secret", names)
|
||||
}
|
||||
b := got[1]
|
||||
if b.ContentType != "text/plain" || b.Tags["env"] != "prod" || b.Enabled == nil || !*b.Enabled ||
|
||||
b.Updated.IsZero() || b.Type != provider.TypeSecret {
|
||||
t.Errorf("metadata not mapped: %+v", b)
|
||||
}
|
||||
if want := "@Microsoft.KeyVault(SecretUri=" + srv.URL + "/secrets/b-secret)"; b.Reference != want {
|
||||
t.Errorf("Reference = %q, want %q", b.Reference, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForbiddenIsShortened(t *testing.T) {
|
||||
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 403, map[string]any{"error": map[string]any{
|
||||
"code": "Forbidden", "message": "Caller is not authorized to perform action on resource.",
|
||||
}})
|
||||
})
|
||||
_, err := p.Objects(context.Background(), vault(srv), provider.TypeKey)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource.\n\nCheck:\n• your role or group is activated in PIM\n• "; !strings.HasPrefix(got, want) {
|
||||
t.Errorf("error = %q, want %q…", got, want)
|
||||
}
|
||||
if checks := deniedChecks("Client address is not authorized and caller is not a trusted service."); !strings.Contains(checks[0], "network") {
|
||||
t.Errorf("firewall checks = %q", checks)
|
||||
}
|
||||
var re *azcore.ResponseError
|
||||
if !errors.As(err, &re) || re.StatusCode != 403 {
|
||||
t.Error("original ResponseError lost from chain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortenPolicyDenied(t *testing.T) {
|
||||
msg := "The user, group or application 'appid=04b07795;oid=1234;numgroups=83;iss=https://sts.windows.net/t/' does not have secrets list permission on key vault 'kv-app;location=norwayeast'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287"
|
||||
if got := shortenDenied(msg); got != "no secrets list permission in the vault's access policies" {
|
||||
t.Errorf("shortenDenied = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortenDenied(t *testing.T) {
|
||||
msg := "Caller is not authorized to perform action on resource.\r\nIf role assignments, deny assignments or role definitions were changed recently, please observe propagation time.\r\nCaller: appid=04b07795;oid=1234\r\nAction: 'Microsoft.KeyVault/vaults/secrets/readMetadata/action'\r\nResource: '/subscriptions/x/vaults/kv'"
|
||||
want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action"
|
||||
for _, variant := range []string{msg, strings.ReplaceAll(msg, "\r\n", " ")} {
|
||||
if got := shortenDenied(variant); got != want {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
if got := shortenDenied("Public network access is disabled."); got != "Public network access is disabled." {
|
||||
t.Errorf("unrelated message changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretValue(t *testing.T) {
|
||||
var srvURL string
|
||||
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/secrets/db-password/v2" && r.URL.Path != "/secrets/db-password/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"id": srvURL + r.URL.Path, "value": "value-of-" + r.URL.Path})
|
||||
})
|
||||
srvURL = srv.URL
|
||||
o := provider.Object{Container: vault(srv), Type: provider.TypeSecret, Name: "db-password"}
|
||||
for version, want := range map[string]string{"": "value-of-/secrets/db-password/", "v2": "value-of-/secrets/db-password/v2"} {
|
||||
o.Version = version
|
||||
v, err := p.Value(context.Background(), o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Reveal() != want {
|
||||
t.Errorf("version %q: got %q", version, v.Reveal())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeCertificate(t *testing.T) {
|
||||
der, cert := selfSigned(t)
|
||||
thumb := sha1.Sum(der)
|
||||
var srvURL string
|
||||
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"id": srvURL + "/certificates/app-tls/abc123",
|
||||
"cer": base64.StdEncoding.EncodeToString(der),
|
||||
"x5t": base64.RawURLEncoding.EncodeToString(thumb[:]),
|
||||
"policy": map[string]any{"secret_props": map[string]any{"contentType": "application/x-pem-file"}},
|
||||
})
|
||||
})
|
||||
srvURL = srv.URL
|
||||
o, err := p.Describe(context.Background(), provider.Object{Container: vault(srv), Type: provider.TypeCertificate, Name: "app-tls"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attrs := map[string]string{}
|
||||
for _, a := range o.Attrs {
|
||||
attrs[a.Label] = a.Value
|
||||
}
|
||||
if o.Version != "abc123" || o.ContentType != "application/x-pem-file" {
|
||||
t.Errorf("version/content type = %q/%q", o.Version, o.ContentType)
|
||||
}
|
||||
if attrs["subject"] != "CN=app.example.com" || attrs["SANs"] != "app.example.com, 10.0.0.1" ||
|
||||
!strings.HasPrefix(attrs["thumbprint"], hexColon(thumb[:2])) {
|
||||
t.Errorf("attrs = %v", attrs)
|
||||
}
|
||||
|
||||
v, err := p.Value(context.Background(), o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block, _ := pem.Decode([]byte(v.Reveal()))
|
||||
if block == nil || block.Type != "CERTIFICATE" || !cert.Equal(mustParse(t, block.Bytes)) {
|
||||
t.Errorf("certificate PEM does not round-trip: %q", v.Reveal())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyPEM(t *testing.T) {
|
||||
rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
ecKey, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
ecX, ecY := ecPoint(t, ecKey)
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
jwk azkeys.JSONWebKey
|
||||
pub any
|
||||
wantDesc string
|
||||
}{
|
||||
"rsa": {
|
||||
jwk: azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeRSAHSM), N: rsaKey.N.Bytes(),
|
||||
E: big.NewInt(int64(rsaKey.E)).Bytes(), KeyOps: []*azkeys.KeyOperation{to.Ptr(azkeys.KeyOperationSign)}},
|
||||
pub: &rsaKey.PublicKey, wantDesc: "RSA-HSM 2048",
|
||||
},
|
||||
"ec": {
|
||||
jwk: azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeEC), Crv: to.Ptr(azkeys.CurveNameP384), X: ecX, Y: ecY},
|
||||
pub: &ecKey.PublicKey, wantDesc: "EC P-384",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
out, err := publicKeyPEM(&tc.jwk)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block, _ := pem.Decode([]byte(out))
|
||||
got, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.(interface{ Equal(crypto.PublicKey) bool }).Equal(tc.pub) {
|
||||
t.Error("public key does not round-trip")
|
||||
}
|
||||
if desc := keyAttrs(&tc.jwk)[0].Value; desc != tc.wantDesc {
|
||||
t.Errorf("key type = %q, want %q", desc, tc.wantDesc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := publicKeyPEM(&azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeOct)}); err == nil {
|
||||
t.Error("symmetric key should have no public key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortalURLs(t *testing.T) {
|
||||
id := "/subscriptions/s/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv"
|
||||
c := provider.Container{URL: PortalResourceURL("t1", id, "overview")}
|
||||
if want := "https://portal.azure.com/#@t1/resource" + id + "/overview"; c.URL != want {
|
||||
t.Errorf("vault URL = %q", c.URL)
|
||||
}
|
||||
if got := ObjectListURL(c, provider.TypeCertificate); got != "https://portal.azure.com/#@t1/resource"+id+"/certificates" {
|
||||
t.Errorf("certificates URL = %q", got)
|
||||
}
|
||||
if got := ObjectListURL(provider.Container{}, provider.TypeSecret); got != "" {
|
||||
t.Errorf("URL without vault page = %q", got)
|
||||
}
|
||||
if got := PortalResourceURL("", "", "overview"); got != "" {
|
||||
t.Errorf("URL without resource = %q", got)
|
||||
}
|
||||
if got := portalAssetURL("", "Key", "https://kv.vault.azure.net/keys/k/v1"); got !=
|
||||
"https://portal.azure.com/#asset/Microsoft_Azure_KeyVault/Key/https://kv.vault.azure.net/keys/k/v1" {
|
||||
t.Errorf("asset URL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionedReference(t *testing.T) {
|
||||
sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
|
||||
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
|
||||
if got := versionedReference(sec); got != "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s/v1)" {
|
||||
t.Errorf("secret: %q", got)
|
||||
}
|
||||
key := provider.Object{Type: provider.TypeKey, Version: "v2", Reference: "https://kv.vault.azure.net/keys/k"}
|
||||
if got := versionedReference(key); got != "https://kv.vault.azure.net/keys/k/v2" {
|
||||
t.Errorf("key: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenSubscriptionsAndPinnedVaults(t *testing.T) {
|
||||
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"value": []any{
|
||||
map[string]any{"subscriptionId": "s1", "displayName": "keep-me", "state": "Enabled", "tenantId": "t"},
|
||||
map[string]any{"subscriptionId": "s2", "displayName": "platform-mgmt", "state": "Enabled", "tenantId": "t"},
|
||||
map[string]any{"subscriptionId": "s3", "displayName": "by-id", "state": "Enabled", "tenantId": "t"},
|
||||
}})
|
||||
})
|
||||
p.opts.Settings = Settings{
|
||||
HideSubscriptions: []string{"PLATFORM-MGMT", "s3"},
|
||||
Vaults: []string{"https://kv-pinned.vault.azure.net"},
|
||||
}
|
||||
|
||||
srcs, err := p.Sources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range srcs {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"keep-me", "Pinned vaults"}) {
|
||||
t.Fatalf("sources = %v", names)
|
||||
}
|
||||
|
||||
vaults, err := p.Containers(context.Background(), srcs[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vaults) != 1 || vaults[0].Name != "kv-pinned" || vaults[0].Detail != "https://kv-pinned.vault.azure.net/" ||
|
||||
len(vaults[0].Types) != 3 {
|
||||
t.Errorf("pinned vaults = %+v", vaults)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphVault(t *testing.T) {
|
||||
src := provider.Source{ID: "sub"}
|
||||
c, ok := graphVault(src, map[string]any{"id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/"})
|
||||
if !ok || c.Name != "kv" || c.Detail != "https://kv.vault.azure.net/" || len(c.Types) != 3 {
|
||||
t.Errorf("graphVault = %+v, %v", c, ok)
|
||||
}
|
||||
if _, ok := graphVault(src, map[string]any{"id": "/x/kv", "name": "kv"}); ok {
|
||||
t.Error("row without uri accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func selfSigned(t *testing.T) ([]byte, *x509.Certificate) {
|
||||
t.Helper()
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "app.example.com"},
|
||||
DNSNames: []string{"app.example.com"},
|
||||
IPAddresses: []net.IP{net.ParseIP("10.0.0.1")},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return der, mustParse(t, der)
|
||||
}
|
||||
|
||||
func mustParse(t *testing.T, der []byte) *x509.Certificate {
|
||||
t.Helper()
|
||||
c, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) {
|
||||
t.Helper()
|
||||
b, err := k.PublicKey.Bytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
size := (len(b) - 1) / 2
|
||||
return b[1 : 1+size], b[1+size:]
|
||||
}
|
||||
|
||||
func TestVaultSettings(t *testing.T) {
|
||||
want := []string{"group=rg-sec", "location=norwayeast", "SKU=premium", "access=Azure roles (RBAC)",
|
||||
"soft delete=on, 30 days", "purge prot.=on", "network=selected networks (1 IP ranges, 0 subnets)",
|
||||
"private=1 endpoint", "tags=env: prod", "=team: platform"}
|
||||
check := func(name string, attrs []provider.Attr) {
|
||||
t.Helper()
|
||||
var got []string
|
||||
for _, a := range attrs {
|
||||
got = append(got, a.Label+"="+a.Value)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("%s\n got %q\nwant %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Resource Graph: the query's columns, as JSON gives them.
|
||||
c, ok := graphVault(provider.Source{ID: "s"}, map[string]any{
|
||||
"id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/", "location": "norwayeast",
|
||||
"resourceGroup": "rg-sec", "tags": map[string]any{"team": "platform", "env": "prod"},
|
||||
"sku": "premium", "rbac": true, "softDelete": nil, "retention": 30.0, "purge": true,
|
||||
"publicAccess": "", "defaultAction": "Deny", "ipRules": 1.0, "subnets": 0.0, "endpoints": 1.0, "policies": 0.0,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("row not read")
|
||||
}
|
||||
check("resource graph", c.Attrs)
|
||||
|
||||
// The management API, when Resource Graph fails.
|
||||
id := "/subscriptions/s/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv"
|
||||
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": id, "name": "kv", "location": "norwayeast",
|
||||
"tags": map[string]any{"team": "platform", "env": "prod"},
|
||||
"properties": map[string]any{
|
||||
"tenantId": "t", "vaultUri": "https://kv.vault.azure.net/", "sku": map[string]any{"family": "A", "name": "premium"},
|
||||
"enableRbacAuthorization": true, "enablePurgeProtection": true, "softDeleteRetentionInDays": 30,
|
||||
"networkAcls": map[string]any{"defaultAction": "Deny", "ipRules": []any{map[string]any{"value": "10.0.0.0/8"}}},
|
||||
"privateEndpointConnections": []any{map[string]any{"id": "pe1"}},
|
||||
}}}})
|
||||
})
|
||||
vaults, err := p.armVaults(context.Background(), provider.Source{ID: "s"})
|
||||
if err != nil || len(vaults) != 1 {
|
||||
t.Fatalf("arm: %v %v", vaults, err)
|
||||
}
|
||||
check("management API", vaults[0].Attrs)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
)
|
||||
|
||||
// cliCredential gets tokens from the az CLI for one signed-in account.
|
||||
//
|
||||
// az can hold several accounts, but `az account get-access-token` can't be
|
||||
// told which user to use: it picks the account a subscription belongs to.
|
||||
// So each account is pinned with --subscription. az refuses --subscription
|
||||
// together with --tenant, which the SDK's AzureCLICredential would pass for
|
||||
// every Key Vault request (the vault's auth challenge names a tenant), so
|
||||
// this sends --tenant only for vaults in another tenant.
|
||||
//
|
||||
// Which account a subscription belongs to changes: az's profile has one
|
||||
// entry per subscription, and signing in to another account that sees it
|
||||
// moves it there. So the subscription is looked up for every new token,
|
||||
// and a token az issues for another of the signed-in users is refused.
|
||||
//
|
||||
// Tokens are cached in memory until shortly before they expire, so the
|
||||
// many SDK clients don't each start an az process.
|
||||
type cliCredential struct {
|
||||
user string // the account's user; "" for az's default account
|
||||
tenant string // the account's tenant, if known
|
||||
// accounts reads az's accounts, to pin the user; nil for the default
|
||||
// account.
|
||||
accounts func() ([]account, error)
|
||||
run func(ctx context.Context, args ...string) ([]byte, error)
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]azcore.AccessToken // by resource and tenant argument
|
||||
// last is the claims of the latest token in the account's own
|
||||
// tenant, to describe the account (Provider.Account).
|
||||
last map[string]any
|
||||
}
|
||||
|
||||
// claims are the latest token's claims, nil before the first.
|
||||
func (c *cliCredential) claims() map[string]any {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.last
|
||||
}
|
||||
|
||||
func newCLICredential(user, tenant string, accounts func() ([]account, error)) *cliCredential {
|
||||
return &cliCredential{user: user, tenant: tenant, accounts: accounts, run: runAz, cache: map[string]azcore.AccessToken{}}
|
||||
}
|
||||
|
||||
// pin is a subscription az maps to the user now, and the other users az
|
||||
// holds.
|
||||
func (c *cliCredential) pin() (subscription string, others []string, err error) {
|
||||
if c.accounts == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
accts, err := c.accounts()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
for _, a := range accts {
|
||||
switch {
|
||||
case a.user == c.user && a.tenant == c.tenant:
|
||||
subscription = a.anchor
|
||||
case !strings.EqualFold(a.user, c.user):
|
||||
others = append(others, a.user)
|
||||
}
|
||||
}
|
||||
if subscription == "" {
|
||||
return "", nil, fmt.Errorf("az has no subscriptions for %s any more: sign in as %s", c.user, c.user)
|
||||
}
|
||||
return subscription, others, nil
|
||||
}
|
||||
|
||||
// refreshMargin renews tokens this long before they expire.
|
||||
const refreshMargin = 5 * time.Minute
|
||||
|
||||
func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
|
||||
if len(opts.Scopes) != 1 {
|
||||
return azcore.AccessToken{}, errors.New("az: exactly one scope is supported")
|
||||
}
|
||||
if opts.Claims != "" {
|
||||
return azcore.AccessToken{}, errors.New("az: this request needs multifactor authentication; run az login again")
|
||||
}
|
||||
resource := strings.TrimSuffix(opts.Scopes[0], "/.default")
|
||||
otherTenant := opts.TenantID != "" && opts.TenantID != c.tenant
|
||||
key := resource + " " + opts.TenantID
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if tok, ok := c.cache[key]; ok && time.Until(tok.ExpiresOn) > refreshMargin {
|
||||
return tok, nil
|
||||
}
|
||||
args := []string{"account", "get-access-token", "--output", "json", "--resource", resource}
|
||||
subscription, others, err := c.pin()
|
||||
switch {
|
||||
case otherTenant:
|
||||
// Another tenant: az picks the account itself.
|
||||
args = append(args, "--tenant", opts.TenantID)
|
||||
case err != nil:
|
||||
return azcore.AccessToken{}, err
|
||||
case subscription != "":
|
||||
args = append(args, "--subscription", subscription)
|
||||
}
|
||||
out, err := c.run(ctx, args...)
|
||||
if err != nil {
|
||||
return azcore.AccessToken{}, err
|
||||
}
|
||||
tok, err := parseCLIToken(out)
|
||||
if err != nil {
|
||||
return azcore.AccessToken{}, err
|
||||
}
|
||||
claims := tokenClaims(tok.Token)
|
||||
if u, ok := tokenUser(claims, others); ok {
|
||||
return azcore.AccessToken{}, fmt.Errorf("az gave a token for %s, not %s: sign in as %s again", u, c.user, c.user)
|
||||
}
|
||||
if !otherTenant && claims != nil {
|
||||
c.last = claims
|
||||
}
|
||||
c.cache[key] = tok
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// tokenClaims reads a JWT's claims, without verifying it: they only
|
||||
// describe the account and catch az picking the wrong one. nil if it
|
||||
// isn't a JWT.
|
||||
func tokenClaims(token string) map[string]any {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil
|
||||
}
|
||||
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var claims map[string]any
|
||||
if json.Unmarshal(b, &claims) != nil {
|
||||
return nil
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
// tokenUser reports which of users a token's claims are for, if any.
|
||||
func tokenUser(claims map[string]any, users []string) (string, bool) {
|
||||
for _, name := range []string{"upn", "unique_name", "preferred_username", "email"} {
|
||||
v, _ := claims[name].(string)
|
||||
if i := strings.LastIndexByte(v, '#'); i >= 0 {
|
||||
v = v[i+1:] // live.com#user@example.com
|
||||
}
|
||||
for _, u := range users {
|
||||
if v != "" && strings.EqualFold(v, u) {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseCLIToken(b []byte) (azcore.AccessToken, error) {
|
||||
var t struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpiresOn int64 `json:"expires_on"` // az 2.54+
|
||||
ExpiresOnS string `json:"expiresOn"` // older az: local time
|
||||
}
|
||||
if err := json.Unmarshal(b, &t); err != nil {
|
||||
return azcore.AccessToken{}, fmt.Errorf("az: unexpected output: %w", err)
|
||||
}
|
||||
if t.AccessToken == "" {
|
||||
return azcore.AccessToken{}, errors.New("az: no access token in output")
|
||||
}
|
||||
exp := time.Unix(t.ExpiresOn, 0)
|
||||
if t.ExpiresOn == 0 {
|
||||
var err error
|
||||
exp, err = time.ParseInLocation("2006-01-02 15:04:05.999999", t.ExpiresOnS, time.Local)
|
||||
if err != nil {
|
||||
return azcore.AccessToken{}, fmt.Errorf("az: unexpected expiry %q", t.ExpiresOnS)
|
||||
}
|
||||
}
|
||||
return azcore.AccessToken{Token: t.AccessToken, ExpiresOn: exp}, nil
|
||||
}
|
||||
|
||||
// azTimeout bounds one az call; a hung az would otherwise block a pane.
|
||||
const azTimeout = 30 * time.Second
|
||||
|
||||
func runAz(ctx context.Context, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, azTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "az", args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return nil, errors.New("az CLI not found; install it or set AZURE_* for a service principal")
|
||||
}
|
||||
if err != nil {
|
||||
if msg := strings.TrimSpace(stderr.String()); msg != "" {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
func deref[T any](p *T) T {
|
||||
var zero T
|
||||
if p == nil {
|
||||
return zero
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func tags(m map[string]*string) map[string]string {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = deref(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const portal = "https://portal.azure.com/#"
|
||||
|
||||
// PortalResourceURL links to a blade of an Azure resource in the portal,
|
||||
// e.g. "overview" or "secrets". tenant may be empty.
|
||||
func PortalResourceURL(tenant, resourceID, blade string) string {
|
||||
if resourceID == "" {
|
||||
return ""
|
||||
}
|
||||
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
|
||||
}
|
||||
|
||||
// portalAssetURL links to a Key Vault object's page; objectID must include
|
||||
// the version.
|
||||
func portalAssetURL(tenant, kind, objectID string) string {
|
||||
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
|
||||
}
|
||||
|
||||
func tenantPrefix(tenant string) string {
|
||||
if tenant == "" {
|
||||
return ""
|
||||
}
|
||||
return "@" + tenant + "/"
|
||||
}
|
||||
|
||||
// ObjectListURL links to the vault's list of the given object type,
|
||||
// derived from the container's overview URL.
|
||||
func ObjectListURL(c provider.Container, t provider.ObjectType) string {
|
||||
base, ok := strings.CutSuffix(c.URL, "/overview")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch t {
|
||||
case provider.TypeKey:
|
||||
return base + "/keys"
|
||||
case provider.TypeCertificate:
|
||||
return base + "/certificates"
|
||||
}
|
||||
return base + "/secrets"
|
||||
}
|
||||
|
||||
// objectURL is the data-plane URL of an object, e.g.
|
||||
// https://kv.vault.azure.net/secrets/name.
|
||||
func objectURL(c provider.Container, collection, name string) string {
|
||||
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
|
||||
}
|
||||
|
||||
func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object {
|
||||
name := p.ID.Name()
|
||||
url := objectURL(c, "secrets", name)
|
||||
o := provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeSecret,
|
||||
ID: url,
|
||||
Name: name,
|
||||
Version: p.ID.Version(),
|
||||
ContentType: deref(p.ContentType),
|
||||
Tags: tags(p.Tags),
|
||||
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
|
||||
URL: ObjectListURL(c, provider.TypeSecret),
|
||||
}
|
||||
if a := p.Attributes; a != nil {
|
||||
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
|
||||
name := p.KID.Name()
|
||||
url := objectURL(c, "keys", name)
|
||||
o := provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeKey,
|
||||
ID: url,
|
||||
Name: name,
|
||||
Version: p.KID.Version(),
|
||||
Tags: tags(p.Tags),
|
||||
Reference: url,
|
||||
URL: ObjectListURL(c, provider.TypeKey),
|
||||
}
|
||||
if a := p.Attributes; a != nil {
|
||||
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
|
||||
name := p.ID.Name()
|
||||
url := objectURL(c, "certificates", name)
|
||||
o := provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeCertificate,
|
||||
ID: url,
|
||||
Name: name,
|
||||
Version: p.ID.Version(),
|
||||
Tags: tags(p.Tags),
|
||||
Reference: url,
|
||||
URL: ObjectListURL(c, provider.TypeCertificate),
|
||||
}
|
||||
if a := p.Attributes; a != nil {
|
||||
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
|
||||
}
|
||||
if len(p.X509Thumbprint) > 0 {
|
||||
o.Attrs = []provider.Attr{{Label: "thumbprint", Value: hexColon(p.X509Thumbprint)}}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// keyAttrs describes a JSON Web Key, e.g. "RSA-HSM 3072" or "EC P-256".
|
||||
func keyAttrs(k *azkeys.JSONWebKey) []provider.Attr {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
kty := string(deref(k.Kty))
|
||||
desc := kty
|
||||
switch {
|
||||
case len(k.N) > 0:
|
||||
desc = fmt.Sprintf("%s %d", kty, new(big.Int).SetBytes(k.N).BitLen())
|
||||
case k.Crv != nil:
|
||||
desc = kty + " " + string(*k.Crv)
|
||||
}
|
||||
attrs := []provider.Attr{{Label: "key type", Value: desc}}
|
||||
var ops []string
|
||||
for _, op := range k.KeyOps {
|
||||
ops = append(ops, string(deref(op)))
|
||||
}
|
||||
if len(ops) > 0 {
|
||||
attrs = append(attrs, provider.Attr{Label: "ops", Value: strings.Join(ops, ", ")})
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// publicKeyPEM encodes the public half of a JSON Web Key as PEM. Key Vault
|
||||
// never returns private material for keys, and this ignores it regardless.
|
||||
func publicKeyPEM(k *azkeys.JSONWebKey) (string, error) {
|
||||
if k == nil {
|
||||
return "", errors.New("key has no key material")
|
||||
}
|
||||
var pub any
|
||||
switch {
|
||||
case len(k.N) > 0 && len(k.E) > 0:
|
||||
pub = &rsa.PublicKey{N: new(big.Int).SetBytes(k.N), E: int(new(big.Int).SetBytes(k.E).Int64())}
|
||||
case len(k.X) > 0 && len(k.Y) > 0:
|
||||
var curve elliptic.Curve
|
||||
switch deref(k.Crv) {
|
||||
case azkeys.CurveNameP256:
|
||||
curve = elliptic.P256()
|
||||
case azkeys.CurveNameP384:
|
||||
curve = elliptic.P384()
|
||||
case azkeys.CurveNameP521:
|
||||
curve = elliptic.P521()
|
||||
default:
|
||||
return "", fmt.Errorf("cannot encode curve %s as PEM", deref(k.Crv))
|
||||
}
|
||||
size := (curve.Params().BitSize + 7) / 8
|
||||
point := make([]byte, 1+2*size)
|
||||
point[0] = 4 // uncompressed
|
||||
copy(point[1:], leftPad(k.X, size))
|
||||
copy(point[1+size:], leftPad(k.Y, size))
|
||||
ec, err := ecdsa.ParseUncompressedPublicKey(curve, point)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pub = ec
|
||||
default:
|
||||
return "", fmt.Errorf("%s keys have no public key", deref(k.Kty))
|
||||
}
|
||||
der, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))), nil
|
||||
}
|
||||
|
||||
func leftPad(b []byte, n int) []byte {
|
||||
if len(b) >= n {
|
||||
return b
|
||||
}
|
||||
return append(make([]byte, n-len(b)), b...)
|
||||
}
|
||||
|
||||
// certAttrs describes an X.509 certificate.
|
||||
func certAttrs(cert *x509.Certificate, thumbprint []byte) []provider.Attr {
|
||||
attrs := []provider.Attr{
|
||||
{Label: "subject", Value: cert.Subject.String()},
|
||||
{Label: "issuer", Value: cert.Issuer.String()},
|
||||
}
|
||||
if len(thumbprint) > 0 {
|
||||
attrs = append(attrs, provider.Attr{Label: "thumbprint", Value: hexColon(thumbprint)})
|
||||
}
|
||||
var sans []string
|
||||
sans = append(sans, cert.DNSNames...)
|
||||
sans = append(sans, cert.EmailAddresses...)
|
||||
for _, ip := range cert.IPAddresses {
|
||||
sans = append(sans, ip.String())
|
||||
}
|
||||
for _, u := range cert.URIs {
|
||||
sans = append(sans, u.String())
|
||||
}
|
||||
if len(sans) > 0 {
|
||||
attrs = append(attrs, provider.Attr{Label: "SANs", Value: strings.Join(sans, ", ")})
|
||||
}
|
||||
attrs = append(attrs, provider.Attr{Label: "valid from", Value: cert.NotBefore.Local().Format("2006-01-02 15:04")})
|
||||
return attrs
|
||||
}
|
||||
|
||||
func certPEM(der []byte) string {
|
||||
return strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})))
|
||||
}
|
||||
|
||||
func hexColon(b []byte) string {
|
||||
parts := make([]string, len(b))
|
||||
for i, x := range b {
|
||||
parts[i] = fmt.Sprintf("%02X", x)
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
// cleanError shortens the SDK's multi-line HTTP errors to "403 Forbidden:
|
||||
// <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
|
||||
}
|
||||
}
|
||||
}
|
||||
se := &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
|
||||
if se.status == http.StatusForbidden {
|
||||
return &provider.Denied{Err: se, Checks: deniedChecks(se.msg)}
|
||||
}
|
||||
return se
|
||||
}
|
||||
|
||||
var policyDenied = regexp.MustCompile(`does not have (\w+ \w+) permission on key vault`)
|
||||
|
||||
// shortenDenied reduces Key Vault's RBAC denial, which runs to several
|
||||
// sentences plus caller and resource IDs, to its first sentence and the
|
||||
// missing action.
|
||||
func shortenDenied(msg string) string {
|
||||
// Access policies: "The user, group or application 'appid=…;oid=…'
|
||||
// does not have secrets list permission on key vault 'kv;location=…'".
|
||||
if m := policyDenied.FindStringSubmatch(msg); m != nil {
|
||||
return "no " + m[1] + " permission in the vault's access policies"
|
||||
}
|
||||
const marker = "Action: '"
|
||||
i := strings.Index(msg, marker)
|
||||
if i < 0 {
|
||||
return msg
|
||||
}
|
||||
action, _, ok := strings.Cut(msg[i+len(marker):], "'")
|
||||
if !ok || action == "" {
|
||||
return msg
|
||||
}
|
||||
first := msg
|
||||
if k := strings.IndexAny(first, "\r\n"); k >= 0 {
|
||||
first = first[:k]
|
||||
}
|
||||
first, _, _ = strings.Cut(first, ". ")
|
||||
return strings.TrimSuffix(first, ".") + "; missing " + action
|
||||
}
|
||||
|
||||
type serviceError struct {
|
||||
status int
|
||||
msg string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *serviceError) Error() string {
|
||||
s := fmt.Sprintf("%d %s", e.status, http.StatusText(e.status))
|
||||
if e.msg != "" {
|
||||
s += ": " + e.msg
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// deniedChecks are what to check when Key Vault refuses: the network,
|
||||
// for its firewall's refusals, else access.
|
||||
func deniedChecks(msg string) []string {
|
||||
if strings.Contains(msg, "Client address is not authorized") || strings.Contains(msg, "network access") ||
|
||||
strings.Contains(msg, "private link") {
|
||||
return []string{
|
||||
"you're on a network the vault lets in (VPN, office)",
|
||||
"the vault's owner has let your network in",
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
"your role or group is activated in PIM",
|
||||
"the vault's owner has given you access: a Key Vault role, or its access policies",
|
||||
}
|
||||
}
|
||||
|
||||
func (e *serviceError) Unwrap() error { return e.err }
|
||||
@@ -0,0 +1,158 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// A vault's settings come with the listing, from Resource Graph or the
|
||||
// Key Vault management API, with no requests of their own: where it is,
|
||||
// how access to it works, and how it's protected.
|
||||
|
||||
type vaultInfo struct {
|
||||
group, location, sku string
|
||||
rbac, softDelete, purge *bool
|
||||
retention *int32
|
||||
publicAccess, defaultAction string
|
||||
ipRules, subnets, endpoints int
|
||||
policies int
|
||||
tags map[string]string
|
||||
}
|
||||
|
||||
// vaultQuery lists vaults with just the settings shown, not their whole
|
||||
// properties (access policies can be long).
|
||||
const vaultQuery = `resources
|
||||
| where type =~ 'microsoft.keyvault/vaults'
|
||||
| project id, name, uri = tostring(properties.vaultUri), location, resourceGroup, tags,
|
||||
sku = tostring(properties.sku.name), rbac = properties.enableRbacAuthorization,
|
||||
softDelete = properties.enableSoftDelete, retention = properties.softDeleteRetentionInDays,
|
||||
purge = properties.enablePurgeProtection, publicAccess = tostring(properties.publicNetworkAccess),
|
||||
defaultAction = tostring(properties.networkAcls.defaultAction),
|
||||
ipRules = array_length(properties.networkAcls.ipRules),
|
||||
subnets = array_length(properties.networkAcls.virtualNetworkRules),
|
||||
endpoints = array_length(properties.privateEndpointConnections),
|
||||
policies = array_length(properties.accessPolicies)`
|
||||
|
||||
// graphInfo reads a vaultQuery row's settings.
|
||||
func graphInfo(m map[string]any) vaultInfo {
|
||||
str := func(k string) string { s, _ := m[k].(string); return s }
|
||||
num := func(k string) int { f, _ := m[k].(float64); return int(f) }
|
||||
flag := func(k string) *bool {
|
||||
if b, ok := m[k].(bool); ok {
|
||||
return &b
|
||||
}
|
||||
return nil
|
||||
}
|
||||
v := vaultInfo{
|
||||
group: str("resourceGroup"), location: str("location"), sku: str("sku"),
|
||||
rbac: flag("rbac"), softDelete: flag("softDelete"), purge: flag("purge"),
|
||||
publicAccess: str("publicAccess"), defaultAction: str("defaultAction"),
|
||||
ipRules: num("ipRules"), subnets: num("subnets"), endpoints: num("endpoints"), policies: num("policies"),
|
||||
}
|
||||
if f, ok := m["retention"].(float64); ok {
|
||||
r := int32(f)
|
||||
v.retention = &r
|
||||
}
|
||||
if tags, ok := m["tags"].(map[string]any); ok {
|
||||
v.tags = map[string]string{}
|
||||
for k, t := range tags {
|
||||
v.tags[k] = fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// armInfo reads a vault's settings from the management API.
|
||||
func armInfo(v *armkeyvault.Vault) vaultInfo {
|
||||
info := vaultInfo{location: deref(v.Location), tags: map[string]string{}}
|
||||
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
|
||||
info.group = id.ResourceGroupName
|
||||
}
|
||||
for k, t := range v.Tags {
|
||||
info.tags[k] = deref(t)
|
||||
}
|
||||
pr := v.Properties
|
||||
if pr == nil {
|
||||
return info
|
||||
}
|
||||
if pr.SKU != nil && pr.SKU.Name != nil {
|
||||
info.sku = string(*pr.SKU.Name)
|
||||
}
|
||||
info.rbac, info.softDelete, info.purge = pr.EnableRbacAuthorization, pr.EnableSoftDelete, pr.EnablePurgeProtection
|
||||
info.retention, info.publicAccess = pr.SoftDeleteRetentionInDays, deref(pr.PublicNetworkAccess)
|
||||
if acl := pr.NetworkACLs; acl != nil {
|
||||
if acl.DefaultAction != nil {
|
||||
info.defaultAction = string(*acl.DefaultAction)
|
||||
}
|
||||
info.ipRules, info.subnets = len(acl.IPRules), len(acl.VirtualNetworkRules)
|
||||
}
|
||||
info.endpoints, info.policies = len(pr.PrivateEndpointConnections), len(pr.AccessPolicies)
|
||||
return info
|
||||
}
|
||||
|
||||
func (v vaultInfo) attrs() []provider.Attr {
|
||||
var out []provider.Attr
|
||||
add := func(label, value string) {
|
||||
if value != "" {
|
||||
out = append(out, provider.Attr{Label: label, Value: value})
|
||||
}
|
||||
}
|
||||
add("group", v.group)
|
||||
add("location", v.location)
|
||||
add("SKU", v.sku)
|
||||
// Who may read secrets is decided by Azure roles or by the vault's
|
||||
// own access policies, never both.
|
||||
if on(v.rbac, false) {
|
||||
add("access", "Azure roles (RBAC)")
|
||||
} else {
|
||||
add("access", fmt.Sprintf("access policies (%d)", v.policies))
|
||||
}
|
||||
if on(v.softDelete, true) {
|
||||
days := int32(90)
|
||||
if v.retention != nil {
|
||||
days = *v.retention
|
||||
}
|
||||
add("soft delete", fmt.Sprintf("on, %d days", days))
|
||||
} else {
|
||||
add("soft delete", "off")
|
||||
}
|
||||
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)])
|
||||
switch {
|
||||
case strings.EqualFold(v.publicAccess, "disabled"):
|
||||
add("network", "private endpoints only")
|
||||
case strings.EqualFold(v.defaultAction, "deny"):
|
||||
add("network", fmt.Sprintf("selected networks (%d IP ranges, %d subnets)", v.ipRules, v.subnets))
|
||||
default:
|
||||
add("network", "all networks")
|
||||
}
|
||||
if v.endpoints > 0 {
|
||||
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
|
||||
}
|
||||
keys := make([]string, 0, len(v.tags))
|
||||
for k := range v.tags {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
for i, k := range keys {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "tags"
|
||||
}
|
||||
out = append(out, provider.Attr{Label: label, Value: k + ": " + v.tags[k]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// on is *b, or def when it isn't set.
|
||||
func on(b *bool, def bool) bool {
|
||||
if b == nil {
|
||||
return def
|
||||
}
|
||||
return *b
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// Package databricks is the Databricks provider. Sources are workspace
|
||||
// profiles from ~/.databrickscfg, containers are secret scopes, and objects
|
||||
// are secrets. Secrets in Azure Key Vault-backed scopes are read through
|
||||
// Databricks, falling back to the vault itself.
|
||||
package databricks
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/databricks/databricks-sdk-go/apierr"
|
||||
"github.com/databricks/databricks-sdk-go/client"
|
||||
dbconfig "github.com/databricks/databricks-sdk-go/config"
|
||||
"github.com/databricks/databricks-sdk-go/service/workspace"
|
||||
"gopkg.in/ini.v1"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/azure"
|
||||
)
|
||||
|
||||
// KeyVault reads Azure Key Vault secrets directly; the Azure provider
|
||||
// implements it. Containers passed to it only have Detail (the vault URL)
|
||||
// set.
|
||||
type KeyVault interface {
|
||||
Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error)
|
||||
Value(ctx context.Context, o provider.Object) (provider.SecretValue, error)
|
||||
}
|
||||
|
||||
type profile struct {
|
||||
name string
|
||||
host string
|
||||
authType string
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
profiles []profile
|
||||
keyVault KeyVault // nil disables the fallback
|
||||
newClient func(profile) (*workspace.SecretsAPI, error)
|
||||
|
||||
mu sync.Mutex
|
||||
clients map[string]*workspace.SecretsAPI // by profile name
|
||||
vaults map[string]string // AKV-backed scope ID → vault URL
|
||||
}
|
||||
|
||||
var _ provider.Provider = (*Provider)(nil)
|
||||
|
||||
// New reads workspace profiles from the Databricks config file
|
||||
// ($DATABRICKS_CONFIG_FILE or ~/.databrickscfg), leaving out hideProfiles.
|
||||
// A missing file yields a provider with no sources.
|
||||
func New(kv KeyVault, hideProfiles []string) (*Provider, error) {
|
||||
path := os.Getenv("DATABRICKS_CONFIG_FILE")
|
||||
if path == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path = filepath.Join(home, ".databrickscfg")
|
||||
}
|
||||
all, err := readProfiles(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var profiles []profile
|
||||
for _, pr := range all {
|
||||
if !config.Hidden(hideProfiles, pr.name, pr.name) {
|
||||
profiles = append(profiles, pr)
|
||||
}
|
||||
}
|
||||
return &Provider{
|
||||
profiles: profiles,
|
||||
keyVault: kv,
|
||||
newClient: profileClient,
|
||||
clients: map[string]*workspace.SecretsAPI{},
|
||||
vaults: map[string]string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// profileClient is a client for the secrets API alone. The SDK's
|
||||
// WorkspaceClient would link in every Databricks service, some 10 MB.
|
||||
func profileClient(p profile) (*workspace.SecretsAPI, error) {
|
||||
return secretsClient(&dbconfig.Config{Profile: p.name})
|
||||
}
|
||||
|
||||
func secretsClient(cfg *dbconfig.Config) (*workspace.SecretsAPI, error) {
|
||||
c, err := client.New(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workspace.NewSecrets(c), nil
|
||||
}
|
||||
|
||||
// readProfiles returns the workspace profiles in file order, skipping
|
||||
// account-console profiles, which have no secret scopes.
|
||||
func readProfiles(path string) ([]profile, error) {
|
||||
f, err := ini.Load(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
var out []profile
|
||||
for _, s := range f.Sections() {
|
||||
host := s.Key("host").String()
|
||||
if host == "" || s.HasKey("account_id") || strings.Contains(host, "accounts.") {
|
||||
continue
|
||||
}
|
||||
out = append(out, profile{name: s.Name(), host: host, authType: s.Key("auth_type").String()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loginCommand is what signs the user back in for a profile, or nil if
|
||||
// there is nothing to run (e.g. a personal access token).
|
||||
func loginCommand(p profile) []string {
|
||||
switch p.authType {
|
||||
case "azure-cli":
|
||||
return []string{"az", "login"}
|
||||
case "pat":
|
||||
return nil
|
||||
default:
|
||||
return []string{"databricks", "auth", "login", "--profile", p.name}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Kind() provider.Kind { return provider.KindDatabricks }
|
||||
func (p *Provider) ID() string { return string(provider.KindDatabricks) }
|
||||
func (p *Provider) Label() string { return string(provider.KindDatabricks) }
|
||||
|
||||
func (p *Provider) Sources(context.Context) ([]provider.Source, error) {
|
||||
out := make([]provider.Source, 0, len(p.profiles))
|
||||
for _, pr := range p.profiles {
|
||||
host := strings.TrimPrefix(pr.host, "https://")
|
||||
out = append(out, provider.Source{
|
||||
Kind: provider.KindDatabricks, ID: pr.name, Name: pr.name,
|
||||
Detail: host, URL: "https://" + host,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) profile(name string) (profile, bool) {
|
||||
for _, pr := range p.profiles {
|
||||
if pr.name == name {
|
||||
return pr, true
|
||||
}
|
||||
}
|
||||
return profile{}, false
|
||||
}
|
||||
|
||||
func (p *Provider) client(name string) (*workspace.SecretsAPI, profile, error) {
|
||||
pr, ok := p.profile(name)
|
||||
if !ok {
|
||||
return nil, pr, fmt.Errorf("unknown profile %q", name)
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if c, ok := p.clients[name]; ok {
|
||||
return c, pr, nil
|
||||
}
|
||||
c, err := p.newClient(pr)
|
||||
if err != nil {
|
||||
return nil, pr, classify(pr, err)
|
||||
}
|
||||
p.clients[name] = c
|
||||
return c, pr, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
w, pr, err := p.client(src.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scopes, err := w.ListScopesAll(ctx)
|
||||
if err != nil {
|
||||
return nil, classify(pr, err)
|
||||
}
|
||||
out := make([]provider.Container, 0, len(scopes))
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, s := range scopes {
|
||||
c := provider.Container{Source: src, ID: src.ID + "/" + s.Name, Name: s.Name, Detail: "Databricks-backed"}
|
||||
if s.BackendType == workspace.ScopeBackendTypeAzureKeyvault && s.KeyvaultMetadata != nil {
|
||||
c.Detail = akvDetail + s.KeyvaultMetadata.DnsName
|
||||
c.URL = azure.PortalResourceURL("", s.KeyvaultMetadata.ResourceId, "overview")
|
||||
p.vaults[c.ID] = s.KeyvaultMetadata.DnsName
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Container) int {
|
||||
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls
|
||||
// back to the scope's Detail, so a scope remembered from an earlier session
|
||||
// (a favourite) works before its workspace's scopes are listed.
|
||||
func (p *Provider) vaultURL(c provider.Container) string {
|
||||
p.mu.Lock()
|
||||
url := p.vaults[c.ID]
|
||||
p.mu.Unlock()
|
||||
if url == "" {
|
||||
url, _ = strings.CutPrefix(c.Detail, akvDetail)
|
||||
if url == c.Detail {
|
||||
url = ""
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const akvDetail = "Azure Key Vault-backed: "
|
||||
|
||||
func secretObject(c provider.Container, key string, updated time.Time) provider.Object {
|
||||
return provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeSecret,
|
||||
ID: c.ID + "/" + key,
|
||||
Name: key,
|
||||
Updated: updated,
|
||||
Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key),
|
||||
URL: azure.ObjectListURL(c, provider.TypeSecret), // AKV-backed only
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
if t != provider.TypeSecret {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
w, pr, err := p.client(c.Source.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Object
|
||||
secrets, err := w.ListSecretsAll(ctx, workspace.ListSecretsRequest{Scope: c.Name})
|
||||
if err == nil {
|
||||
for _, s := range secrets {
|
||||
var updated time.Time
|
||||
if s.LastUpdatedTimestamp > 0 {
|
||||
updated = time.UnixMilli(s.LastUpdatedTimestamp)
|
||||
}
|
||||
out = append(out, secretObject(c, s.Key, updated))
|
||||
}
|
||||
} else {
|
||||
dbxErr := classify(pr, err)
|
||||
out, err = p.vaultObjects(ctx, c)
|
||||
if err != nil {
|
||||
return nil, combine(dbxErr, err)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// vaultObjects lists an AKV-backed scope's secrets from the vault itself.
|
||||
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
|
||||
url := p.vaultURL(c)
|
||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
||||
return nil, errNoFallback
|
||||
}
|
||||
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]provider.Object, 0, len(secrets))
|
||||
for _, s := range secrets {
|
||||
out = append(out, secretObject(c, s.Name, s.Updated))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
|
||||
return o, ctx.Err()
|
||||
}
|
||||
|
||||
func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object, error) {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
w, pr, err := p.client(o.Container.Source.ID)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
resp, err := w.GetSecret(ctx, workspace.GetSecretRequest{Scope: o.Container.Name, Key: o.Name})
|
||||
if err == nil {
|
||||
b, derr := base64.StdEncoding.DecodeString(resp.Value)
|
||||
if derr != nil {
|
||||
return provider.SecretValue{}, fmt.Errorf("decoding secret: %w", derr)
|
||||
}
|
||||
return provider.NewSecretValue(b), nil
|
||||
}
|
||||
dbxErr := classify(pr, err)
|
||||
|
||||
url := p.vaultURL(o.Container)
|
||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
||||
return provider.SecretValue{}, dbxErr
|
||||
}
|
||||
v, kvErr := p.keyVault.Value(ctx, provider.Object{
|
||||
Container: provider.Container{Detail: url},
|
||||
Type: provider.TypeSecret,
|
||||
Name: o.Name,
|
||||
})
|
||||
if kvErr != nil {
|
||||
return provider.SecretValue{}, combine(dbxErr, kvErr)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// deniedChecks are what to check when Databricks refuses: the network,
|
||||
// when the workspace's IP access list refused, else access, and for a
|
||||
// profile signed in through az, which account az is on.
|
||||
func deniedChecks(pr profile, msg string) []string {
|
||||
if strings.Contains(msg, "IP ACL") || strings.Contains(msg, "IP access list") {
|
||||
return []string{
|
||||
"you're on a network the workspace lets in (VPN, office)",
|
||||
"the workspace's admin has let your network in",
|
||||
}
|
||||
}
|
||||
var checks []string
|
||||
if pr.authType == "azure-cli" {
|
||||
checks = append(checks, "az is signed in as the account with access: profile "+pr.name+" uses your az login (az account show)")
|
||||
}
|
||||
return append(checks,
|
||||
"you have access to the workspace",
|
||||
"the scope's manager has given you READ on it, or a group you're in",
|
||||
)
|
||||
}
|
||||
|
||||
var errNoFallback = errors.New("no Key Vault fallback")
|
||||
|
||||
// combine reports both the Databricks and the Key Vault failure, with
|
||||
// both their checks after. An auth error from either stays reachable
|
||||
// with errors.As.
|
||||
func combine(dbxErr, kvErr error) error {
|
||||
if errors.Is(kvErr, errNoFallback) {
|
||||
return dbxErr
|
||||
}
|
||||
var checks []string
|
||||
strip := func(err error) error {
|
||||
if d, ok := err.(*provider.Denied); ok {
|
||||
for _, c := range d.Checks {
|
||||
if !slices.Contains(checks, c) {
|
||||
checks = append(checks, c)
|
||||
}
|
||||
}
|
||||
return d.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
err := fmt.Errorf("%w; Key Vault fallback: %w", strip(dbxErr), strip(kvErr))
|
||||
if len(checks) > 0 {
|
||||
return &provider.Denied{Err: err, Checks: checks}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// classify turns authentication failures into *provider.AuthError: a 401
|
||||
// from the API, or failing to get a token before the request was sent.
|
||||
func classify(pr profile, err error) error {
|
||||
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
var api *apierr.APIError
|
||||
if errors.As(err, &api) {
|
||||
if api.StatusCode == 403 {
|
||||
return &provider.Denied{Err: err, Checks: deniedChecks(pr, api.Message)}
|
||||
}
|
||||
if api.StatusCode != 401 {
|
||||
return err
|
||||
}
|
||||
} else if !strings.Contains(err.Error(), "cannot get access token") &&
|
||||
!strings.Contains(err.Error(), " auth: ") {
|
||||
return err
|
||||
}
|
||||
return &provider.AuthError{Kind: provider.KindDatabricks, Login: loginCommand(pr), Err: err}
|
||||
}
|
||||
|
||||
var _ provider.Locator = (*Provider)(nil)
|
||||
|
||||
// Locate finds the profile for a workspace host. It doesn't contact the
|
||||
// workspace.
|
||||
func (p *Provider) Locate(_ context.Context, host string) (string, bool, error) {
|
||||
want := normalizeHost(host)
|
||||
for _, pr := range p.profiles {
|
||||
if normalizeHost(pr.host) == want {
|
||||
return pr.name, true, nil
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func normalizeHost(h string) string {
|
||||
h = strings.ToLower(strings.TrimSpace(h))
|
||||
h = strings.TrimPrefix(h, "https://")
|
||||
return strings.TrimSuffix(h, "/")
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package databricks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dbconfig "github.com/databricks/databricks-sdk-go/config"
|
||||
"github.com/databricks/databricks-sdk-go/service/workspace"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
func TestReadProfiles(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "databrickscfg")
|
||||
cfg := `[DEFAULT]
|
||||
host = https://adb-1.1.azuredatabricks.net
|
||||
auth_type = azure-cli
|
||||
|
||||
[INFRA-DEV]
|
||||
host = adb-2.2.azuredatabricks.net
|
||||
auth_type = azure-cli
|
||||
|
||||
[ACCOUNT]
|
||||
host = https://accounts.azuredatabricks.net
|
||||
auth_type = azure-cli
|
||||
|
||||
[acct-by-id]
|
||||
host = https://example.cloud.databricks.com
|
||||
account_id = 123
|
||||
|
||||
[oauth]
|
||||
host = https://adb-3.3.azuredatabricks.net
|
||||
auth_type = databricks-cli
|
||||
|
||||
[no-host]
|
||||
token = x
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readProfiles(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []profile{
|
||||
{name: "DEFAULT", host: "https://adb-1.1.azuredatabricks.net", authType: "azure-cli"},
|
||||
{name: "INFRA-DEV", host: "adb-2.2.azuredatabricks.net", authType: "azure-cli"},
|
||||
{name: "oauth", host: "https://adb-3.3.azuredatabricks.net", authType: "databricks-cli"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("profiles = %+v\nwant %+v", got, want)
|
||||
}
|
||||
|
||||
if got, err := readProfiles(filepath.Join(t.TempDir(), "missing")); err != nil || got != nil {
|
||||
t.Errorf("missing file: %v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCommand(t *testing.T) {
|
||||
for authType, want := range map[string][]string{
|
||||
"azure-cli": {"az", "login"},
|
||||
"databricks-cli": {"databricks", "auth", "login", "--profile", "p"},
|
||||
"": {"databricks", "auth", "login", "--profile", "p"},
|
||||
"pat": nil,
|
||||
} {
|
||||
if got := loginCommand(profile{name: "p", authType: authType}); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%q: %v, want %v", authType, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeKeyVault stands in for the Azure provider.
|
||||
type fakeKeyVault struct {
|
||||
secrets map[string]string // name → value, for vault URL kvURL
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
const kvURL = "https://kv-app-prod.vault.azure.net/"
|
||||
|
||||
func (k *fakeKeyVault) Objects(_ context.Context, c provider.Container, _ provider.ObjectType) ([]provider.Object, error) {
|
||||
k.calls++
|
||||
if k.err != nil {
|
||||
return nil, k.err
|
||||
}
|
||||
if c.Detail != kvURL {
|
||||
return nil, errors.New("wrong vault " + c.Detail)
|
||||
}
|
||||
var out []provider.Object
|
||||
for name := range k.secrets {
|
||||
out = append(out, provider.Object{Name: name})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *fakeKeyVault) Value(_ context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
k.calls++
|
||||
if k.err != nil {
|
||||
return provider.SecretValue{}, k.err
|
||||
}
|
||||
if o.Container.Detail != kvURL {
|
||||
return provider.SecretValue{}, errors.New("wrong vault " + o.Container.Detail)
|
||||
}
|
||||
return provider.NewSecretValue([]byte(k.secrets[o.Name])), nil
|
||||
}
|
||||
|
||||
// workspaceAPI serves the secrets API. The "akv" scope behaves like an Azure
|
||||
// Key Vault-backed scope whose secrets Databricks refuses to read.
|
||||
func workspaceAPI(t *testing.T, status int) *httptest.Server {
|
||||
t.Helper()
|
||||
write := func(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if status != 0 {
|
||||
write(w, status, map[string]any{"error_code": "UNAUTHENTICATED", "message": "Token is expired"})
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
switch {
|
||||
case r.URL.Path == "/api/2.0/secrets/scopes/list":
|
||||
write(w, 200, map[string]any{"scopes": []any{
|
||||
map[string]any{"name": "app", "backend_type": "DATABRICKS"},
|
||||
map[string]any{"name": "akv", "backend_type": "AZURE_KEYVAULT",
|
||||
"keyvault_metadata": map[string]any{"dns_name": kvURL, "resource_id": "/x/kv-app-prod"}},
|
||||
}})
|
||||
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "app":
|
||||
write(w, 200, map[string]any{"secrets": []any{
|
||||
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
|
||||
map[string]any{"key": "client-secret"},
|
||||
}})
|
||||
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "locked":
|
||||
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have READ permission on scope locked"})
|
||||
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
|
||||
write(w, 200, map[string]any{"key": q.Get("key"),
|
||||
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
|
||||
case strings.HasPrefix(r.URL.Path, "/api/2.0/secrets/") && q.Get("scope") == "akv":
|
||||
write(w, 400, map[string]any{"error_code": "BAD_REQUEST",
|
||||
"message": "Reading secrets from Azure KeyVault-backed scopes is not supported"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newTestProvider(t *testing.T, srv *httptest.Server, kv KeyVault) *Provider {
|
||||
t.Helper()
|
||||
return &Provider{
|
||||
profiles: []profile{{name: "prod", host: srv.URL, authType: "azure-cli"}},
|
||||
keyVault: kv,
|
||||
newClient: func(profile) (*workspace.SecretsAPI, error) {
|
||||
return secretsClient(&dbconfig.Config{Host: srv.URL, Token: "token"})
|
||||
},
|
||||
clients: map[string]*workspace.SecretsAPI{},
|
||||
vaults: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func scopes(t *testing.T, p *Provider) map[string]provider.Container {
|
||||
t.Helper()
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
cs, err := p.Containers(context.Background(), srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]provider.Container{}
|
||||
for _, c := range cs {
|
||||
out[c.Name] = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestScopesSecretsAndValues(t *testing.T) {
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), nil)
|
||||
sc := scopes(t, p)
|
||||
if sc["app"].Detail != "Databricks-backed" || sc["akv"].Detail != "Azure Key Vault-backed: "+kvURL {
|
||||
t.Errorf("scope details = %q / %q", sc["app"].Detail, sc["akv"].Detail)
|
||||
}
|
||||
|
||||
objs, err := p.Objects(context.Background(), sc["app"], provider.TypeSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(objs) != 2 || objs[0].Name != "client-secret" || objs[1].Name != "storage-key" {
|
||||
t.Fatalf("objects = %+v", objs)
|
||||
}
|
||||
if objs[1].Updated.IsZero() || objs[1].Reference != "{{secrets/app/storage-key}}" {
|
||||
t.Errorf("metadata = %+v", objs[1])
|
||||
}
|
||||
|
||||
v, err := p.Value(context.Background(), objs[1])
|
||||
if err != nil || v.Reveal() != "value-of-storage-key" {
|
||||
t.Errorf("value = %q, %v", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKVScopeFallsBackToKeyVault(t *testing.T) {
|
||||
kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), kv)
|
||||
akv := scopes(t, p)["akv"]
|
||||
|
||||
objs, err := p.Objects(context.Background(), akv, provider.TypeSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(objs) != 1 || objs[0].Name != "db-password" || objs[0].Reference != "{{secrets/akv/db-password}}" {
|
||||
t.Fatalf("objects = %+v", objs)
|
||||
}
|
||||
v, err := p.Value(context.Background(), objs[0])
|
||||
if err != nil || v.Reveal() != "from-the-vault" {
|
||||
t.Errorf("value = %q, %v", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKVFallbackForRememberedScope(t *testing.T) {
|
||||
// A favourite from an earlier session: scopes not listed yet.
|
||||
kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), kv)
|
||||
akv := provider.Container{
|
||||
Source: provider.Source{Kind: provider.KindDatabricks, ID: "prod"},
|
||||
ID: "prod/akv", Name: "akv", Detail: "Azure Key Vault-backed: " + kvURL,
|
||||
}
|
||||
v, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"})
|
||||
if err != nil || v.Reveal() != "from-the-vault" {
|
||||
t.Errorf("value = %q, %v", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKVFallbackFailureReportsBoth(t *testing.T) {
|
||||
kvAuth := &provider.AuthError{Kind: provider.KindAzure, Login: []string{"az", "login"}, Err: errors.New("expired")}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), &fakeKeyVault{err: kvAuth})
|
||||
akv := scopes(t, p)["akv"]
|
||||
|
||||
_, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"})
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported") || !strings.Contains(err.Error(), "Key Vault fallback") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) {
|
||||
t.Error("Key Vault auth error not reachable for the sign-in prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoFallbackForDatabricksBackedScopes(t *testing.T) {
|
||||
kv := &fakeKeyVault{}
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), kv)
|
||||
app := scopes(t, p)["app"]
|
||||
if _, err := p.Value(context.Background(), provider.Object{Container: app, Name: "missing"}); err != nil {
|
||||
// app/get returns a value for any key; just make sure the vault wasn't asked
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kv.calls != 0 {
|
||||
t.Errorf("Key Vault consulted for a Databricks-backed scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorizedIsAuthError(t *testing.T) {
|
||||
p := newTestProvider(t, workspaceAPI(t, 401), nil)
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
_, err := p.Containers(context.Background(), srcs[0])
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) {
|
||||
t.Fatalf("err = %v, want AuthError", err)
|
||||
}
|
||||
if auth.Kind != provider.KindDatabricks || !reflect.DeepEqual(auth.Login, []string{"az", "login"}) {
|
||||
t.Errorf("auth = %+v", auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
pr := profile{name: "p", authType: "databricks-cli"}
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(classify(pr, errors.New("default auth: azure-cli: cannot get access token: run az login")), &auth) {
|
||||
t.Error("token failure not classified as auth")
|
||||
}
|
||||
if errors.As(classify(pr, errors.New("dial tcp: no such host")), &auth) {
|
||||
t.Error("network error classified as auth")
|
||||
}
|
||||
if errors.As(classify(pr, context.Canceled), &auth) {
|
||||
t.Error("cancellation classified as auth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeniedSaysWhatToCheck(t *testing.T) {
|
||||
p := newTestProvider(t, workspaceAPI(t, 0), nil)
|
||||
_, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret)
|
||||
var d *provider.Denied
|
||||
// The test profile signs in through az: that's the first check.
|
||||
if !errors.As(err, &d) || !strings.Contains(err.Error(), "READ permission on scope locked\n\nCheck:\n• az is signed in as the account with access: profile prod uses your az login") ||
|
||||
!strings.Contains(err.Error(), "\n• you have access to the workspace") {
|
||||
t.Errorf("err %q", err)
|
||||
}
|
||||
if got := deniedChecks(profile{authType: "pat"}, "denied"); strings.Contains(got[0], "az") {
|
||||
t.Errorf("a token profile's checks %q", got)
|
||||
}
|
||||
if got := deniedChecks(profile{}, "Source IP address: 1.2.3.4 is blocked by Databricks IP ACL for workspace: 1"); !strings.Contains(got[0], "network") {
|
||||
t.Errorf("IP ACL checks %q", got)
|
||||
}
|
||||
|
||||
// Both refused: one message, then both lists of checks.
|
||||
err = combine(&provider.Denied{Err: errors.New("dbx"), Checks: []string{"a", "b"}},
|
||||
&provider.Denied{Err: errors.New("kv"), Checks: []string{"b", "c"}})
|
||||
if got := err.Error(); got != "dbx; Key Vault fallback: kv\n\nCheck:\n• a\n• b\n• c" {
|
||||
t.Errorf("combined %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
// Package fake is an in-memory provider used by tests and --demo mode.
|
||||
package fake
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Provider serves a fixed tree of sources, containers and objects,
|
||||
// optionally sleeping before every call to simulate network latency.
|
||||
type Provider struct {
|
||||
kind provider.Kind
|
||||
id, label string
|
||||
delay time.Duration
|
||||
sources []provider.Source
|
||||
containers map[string][]provider.Container // by source ID
|
||||
objects map[listKey][]provider.Object
|
||||
errs map[listKey]error
|
||||
values map[string]string // by object ID
|
||||
versions map[string][]provider.Object // by object ID
|
||||
details map[string][]provider.Attr // by container ID
|
||||
}
|
||||
|
||||
type listKey struct {
|
||||
container string
|
||||
typ provider.ObjectType
|
||||
}
|
||||
|
||||
var _ provider.Provider = (*Provider)(nil)
|
||||
|
||||
func newProvider(kind provider.Kind, delay time.Duration) *Provider {
|
||||
return &Provider{
|
||||
kind: kind,
|
||||
delay: delay,
|
||||
containers: map[string][]provider.Container{},
|
||||
objects: map[listKey][]provider.Object{},
|
||||
errs: map[listKey]error{},
|
||||
values: map[string]string{},
|
||||
versions: map[string][]provider.Object{},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Kind() provider.Kind { return p.kind }
|
||||
|
||||
func (p *Provider) ID() string {
|
||||
if p.id != "" {
|
||||
return p.id
|
||||
}
|
||||
return string(p.kind)
|
||||
}
|
||||
|
||||
func (p *Provider) Label() string {
|
||||
if p.label != "" {
|
||||
return p.label
|
||||
}
|
||||
return string(p.kind)
|
||||
}
|
||||
|
||||
// As renames the provider, to simulate several accounts of one kind.
|
||||
func (p *Provider) As(id, label string) *Provider {
|
||||
p.id, p.label = id, label
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.sources, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.containers[src.ID], nil
|
||||
}
|
||||
|
||||
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k := listKey{p.containerID(c), t}
|
||||
if err := p.errs[k]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.objects[k], nil
|
||||
}
|
||||
|
||||
// Describe returns o unchanged; the fixtures already carry their details.
|
||||
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
|
||||
return o, ctx.Err()
|
||||
}
|
||||
|
||||
func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.kind != provider.KindAzure { // only Key Vault keeps versions
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
vs := p.versions[o.ID]
|
||||
if len(vs) == 0 { // every object has at least its current version
|
||||
return []provider.Object{o}, nil
|
||||
}
|
||||
out := make([]provider.Object, len(vs))
|
||||
for i, v := range vs {
|
||||
v.Container, v.Type, v.ID, v.Name, v.Reference, v.URL = o.Container, o.Type, o.ID, o.Name, o.Reference, o.URL
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
if o.ID == "" { // built from a link, like the real providers read it
|
||||
o.ID = p.containerID(o.Container) + "/" + strings.ToLower(string(cmp.Or(o.Type, provider.TypeSecret))) + "/" + o.Name
|
||||
}
|
||||
v, ok := p.values[o.ID+"@"+o.Version]
|
||||
if !ok {
|
||||
v, ok = p.values[o.ID]
|
||||
}
|
||||
if !ok {
|
||||
return provider.SecretValue{}, fmt.Errorf("%s %q not found", strings.ToLower(string(o.Type)), o.Name)
|
||||
}
|
||||
return provider.NewSecretValue([]byte(v)), nil
|
||||
}
|
||||
|
||||
// Locate finds the source holding the vault (by host) or the workspace.
|
||||
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
for _, src := range p.sources {
|
||||
if p.kind == provider.KindDatabricks && src.Detail == host {
|
||||
return src.ID, true, nil
|
||||
}
|
||||
for _, c := range p.containers[src.ID] {
|
||||
if c.Detail == "https://"+host {
|
||||
return src.ID, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func (p *Provider) wait(ctx context.Context) error {
|
||||
if p.delay <= 0 {
|
||||
return ctx.Err()
|
||||
}
|
||||
t := time.NewTimer(p.delay)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
const portal = "https://portal.azure.com/#resource"
|
||||
|
||||
// containerID finds a container by ID, or by vault URL like the real Azure
|
||||
// provider, which only needs the URL (vaults opened from links).
|
||||
func (p *Provider) containerID(c provider.Container) string {
|
||||
for _, cs := range p.containers {
|
||||
for _, x := range cs {
|
||||
if x.ID == c.ID {
|
||||
return x.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not one listed here (built from a link): by vault URL, or a scope
|
||||
// by name in its workspace.
|
||||
for _, cs := range p.containers {
|
||||
for _, x := range cs {
|
||||
if c.Detail != "" && strings.TrimSuffix(x.Detail, "/") == strings.TrimSuffix(c.Detail, "/") {
|
||||
return x.ID
|
||||
}
|
||||
if c.Detail == "" && c.Name == x.Name && c.Source.ID != "" && c.Source.ID == x.Source.ID {
|
||||
return x.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.ID
|
||||
}
|
||||
|
||||
func (p *Provider) addSource(id, name, detail string) provider.Source {
|
||||
s := provider.Source{Kind: p.kind, ID: id, Name: name, Detail: detail}
|
||||
if p.kind == provider.KindAzure {
|
||||
s.URL = portal + "/subscriptions/" + id + "/overview"
|
||||
} else {
|
||||
s.URL = "https://" + detail
|
||||
}
|
||||
p.sources = append(p.sources, s)
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *Provider) addContainer(src provider.Source, name, detail string, types ...provider.ObjectType) provider.Container {
|
||||
c := provider.Container{Source: src, ID: src.ID + "/" + name, Name: name, Detail: detail, Types: types}
|
||||
vault := name
|
||||
if p.kind == provider.KindDatabricks {
|
||||
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
|
||||
}
|
||||
if p.kind == provider.KindAzure || (p.kind == provider.KindDatabricks && vault != detail) {
|
||||
c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview"
|
||||
}
|
||||
p.containers[src.ID] = append(p.containers[src.ID], c)
|
||||
return c
|
||||
}
|
||||
|
||||
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
|
||||
o.Container = c
|
||||
o.Type = t
|
||||
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
|
||||
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
|
||||
o.URL = base + map[provider.ObjectType]string{
|
||||
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
|
||||
}[t]
|
||||
}
|
||||
k := listKey{c.ID, t}
|
||||
p.objects[k] = append(p.objects[k], o)
|
||||
p.values[o.ID] = value
|
||||
return o
|
||||
}
|
||||
|
||||
func (p *Provider) fail(c provider.Container, err error, types ...provider.ObjectType) {
|
||||
for _, t := range types {
|
||||
p.errs[listKey{c.ID, t}] = err
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
|
||||
yes = true
|
||||
no = false
|
||||
)
|
||||
|
||||
// ErrForbidden mimics a data-plane permission error from a backend.
|
||||
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
|
||||
|
||||
// ErrForbiddenKeys mimics a vault where the caller can read secrets but not keys.
|
||||
var ErrForbiddenKeys = errors.New("403 Forbidden: caller lacks permission to list keys")
|
||||
|
||||
const (
|
||||
demoPublicKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAdemo\n-----END PUBLIC KEY-----"
|
||||
demoCert = "-----BEGIN CERTIFICATE-----\nMIIBszCCAVmgAwIBAgIUDEMO\nDEMODEMODEMO\n-----END CERTIFICATE-----"
|
||||
)
|
||||
|
||||
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
|
||||
|
||||
// Azure returns a fake Azure provider with two subscriptions.
|
||||
func Azure(delay time.Duration) *Provider {
|
||||
p := newProvider(provider.KindAzure, delay)
|
||||
secretRef := func(vault, name string) string {
|
||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
||||
}
|
||||
objectURL := func(vault, kind, name string) string {
|
||||
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
|
||||
}
|
||||
|
||||
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
||||
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
|
||||
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
|
||||
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
|
||||
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
|
||||
}, "correct-horse-battery-staple")
|
||||
p.versions[dbPassword.ID] = []provider.Object{
|
||||
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
|
||||
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
|
||||
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
|
||||
}
|
||||
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
|
||||
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
||||
Reference: secretRef("kv-app-prod", "api-key"),
|
||||
}, "sk_live_51HxDEMO0000000000000000")
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
|
||||
Reference: secretRef("kv-app-prod", "legacy-token"),
|
||||
}, "deprecated")
|
||||
|
||||
p.add(kv, provider.TypeKey, provider.Object{
|
||||
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "key type", Value: "RSA 2048"},
|
||||
{Label: "ops", Value: "sign, verify"},
|
||||
},
|
||||
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
|
||||
}, demoPublicKey)
|
||||
p.add(kv, provider.TypeKey, provider.Object{
|
||||
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "key type", Value: "EC P-256"},
|
||||
{Label: "ops", Value: "wrapKey, unwrapKey"},
|
||||
},
|
||||
Reference: objectURL("kv-app-prod", "keys", "data-encryption"),
|
||||
}, demoPublicKey)
|
||||
|
||||
p.add(kv, provider.TypeCertificate, provider.Object{
|
||||
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0),
|
||||
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "subject", Value: "CN=app.example.com"},
|
||||
{Label: "issuer", Value: "CN=Example Issuing CA"},
|
||||
{Label: "thumbprint", Value: "3B:1F:9A:0C:44:E2:7D:15"},
|
||||
{Label: "SANs", Value: "app.example.com, www.app.example.com"},
|
||||
{Label: "valid from", Value: "2026-08-01 09:30"},
|
||||
},
|
||||
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
|
||||
}, demoCert)
|
||||
p.add(kv, provider.TypeCertificate, provider.Object{
|
||||
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
||||
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "subject", Value: "CN=old.example.com"},
|
||||
{Label: "issuer", Value: "CN=Example Issuing CA"},
|
||||
{Label: "thumbprint", Value: "A0:77:12:DE:9B:03:C6:58"},
|
||||
},
|
||||
Reference: objectURL("kv-app-prod", "certificates", "old-tls"),
|
||||
}, demoCert)
|
||||
|
||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||
p.add(shared, provider.TypeSecret, provider.Object{
|
||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||
}, "smtp-demo-password")
|
||||
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
|
||||
|
||||
locked := p.addContainer(prod, "kv-restricted", "https://kv-restricted.vault.azure.net", allTypes...)
|
||||
p.fail(locked, ErrForbidden, allTypes...)
|
||||
|
||||
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
|
||||
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
|
||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
|
||||
}, "dev-password")
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// AzureOps returns a second fake Azure account. It shares platform-prod
|
||||
// with Azure but sees a different vault there, and has a subscription of
|
||||
// its own, like an admin account next to a personal one.
|
||||
func AzureOps(delay time.Duration) *Provider {
|
||||
p := newProvider(provider.KindAzure, delay)
|
||||
secretRef := func(vault, name string) string {
|
||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
||||
}
|
||||
|
||||
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
||||
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
|
||||
p.add(breakGlass, provider.TypeSecret, provider.Object{
|
||||
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
|
||||
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
||||
}, "break-glass-demo-password")
|
||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||
p.add(shared, provider.TypeSecret, provider.Object{
|
||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||
}, "smtp-demo-password")
|
||||
// Unlike the first account, ops may read keys here.
|
||||
p.add(shared, provider.TypeKey, provider.Object{
|
||||
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
||||
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
|
||||
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
|
||||
}, demoPublicKey)
|
||||
|
||||
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
|
||||
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
|
||||
}, "pd-demo-token")
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Databricks returns a fake Databricks provider with two workspace profiles,
|
||||
// including an Azure Key Vault-backed scope. Scopes hold secrets only.
|
||||
func Databricks(delay time.Duration) *Provider {
|
||||
p := newProvider(provider.KindDatabricks, delay)
|
||||
secret := func(c provider.Container, key, value string) {
|
||||
p.add(c, provider.TypeSecret, provider.Object{
|
||||
Name: key, Updated: base, Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key),
|
||||
}, value)
|
||||
}
|
||||
|
||||
prod := p.addSource("dbx-prod", "prod", "adb-1111111111111111.11.azuredatabricks.net")
|
||||
app := p.addContainer(prod, "app", "Databricks-backed")
|
||||
secret(app, "storage-key", "storage-demo-key")
|
||||
secret(app, "sp-client-secret", "sp-demo-secret")
|
||||
akv := p.addContainer(prod, "kv-app-prod", "Azure Key Vault-backed: kv-app-prod")
|
||||
secret(akv, "db-password", "correct-horse-battery-staple")
|
||||
|
||||
dev := p.addSource("dbx-dev", "dev", "adb-2222222222222222.2.azuredatabricks.net")
|
||||
devApp := p.addContainer(dev, "app", "Databricks-backed")
|
||||
secret(devApp, "storage-key", "dev-storage-key")
|
||||
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package fake
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
var _ provider.ContainerDetails = (*Provider)(nil)
|
||||
|
||||
// Details returns a file's details, like the repo provider's.
|
||||
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
|
||||
attrs, ok := p.details[c.ID]
|
||||
if !ok {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
return attrs, ctx.Err()
|
||||
}
|
||||
|
||||
// Repos returns a fake repo provider: three git repos with SOPS and plain
|
||||
// .env files, the first one the repo lazysecrets was started in.
|
||||
func Repos() *Provider {
|
||||
p := newProvider(provider.KindRepo, 0)
|
||||
p.id, p.label = "Repos", "Repos"
|
||||
p.details = map[string][]provider.Attr{}
|
||||
|
||||
repo := func(name string, here bool) provider.Source {
|
||||
s := p.addSource("/home/dev/git/"+name, name, "github.com/acme/"+name)
|
||||
s.Here = here
|
||||
p.sources[len(p.sources)-1] = s
|
||||
return s
|
||||
}
|
||||
// file adds a file and its keys, alternating name and value. git and
|
||||
// commit are what git says of it: "tracked", "ignored" or "untracked".
|
||||
file := func(src provider.Source, path, method, git, commit string, kv ...string) {
|
||||
sops := method != ""
|
||||
detail := "env · plain text"
|
||||
if sops {
|
||||
detail = "sops · " + method
|
||||
}
|
||||
c := p.addContainer(src, path, detail, provider.TypeSecret)
|
||||
attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
|
||||
if sops {
|
||||
attrs = []provider.Attr{{Label: "format", Value: "sops " + format(path)}, {Label: "encryption", Value: method}}
|
||||
}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
o := provider.Object{Name: kv[i], Attrs: attrs}
|
||||
if sops {
|
||||
o.Updated = base
|
||||
}
|
||||
p.add(c, provider.TypeSecret, o, kv[i+1])
|
||||
}
|
||||
|
||||
d := []provider.Attr{{Label: "encryption", Value: "none: plain text"}}
|
||||
if sops {
|
||||
d = []provider.Attr{{Label: "encryption", Value: detail}}
|
||||
keys := map[string]string{
|
||||
"age": "age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p",
|
||||
"azure_kv": "azure_kv https://kv-sops-prod.vault.azure.net/keys/sops/1f2e3d4c",
|
||||
}
|
||||
for i, m := range strings.Split(method, ", ") {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "keys" // on the first only, like the repo provider
|
||||
}
|
||||
d = append(d, provider.Attr{Label: label, Value: keys[m]})
|
||||
}
|
||||
d = append(d, provider.Attr{Label: "encrypted", Value: base.Local().Format("2006-01-02 15:04")})
|
||||
}
|
||||
switch {
|
||||
case git == "tracked" && !sops:
|
||||
d = append(d, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
|
||||
case git == "untracked":
|
||||
d = append(d, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !sops})
|
||||
default:
|
||||
d = append(d, provider.Attr{Label: "git", Value: git})
|
||||
}
|
||||
if commit != "" {
|
||||
d = append(d, provider.Attr{Label: "commit", Value: commit})
|
||||
}
|
||||
p.details[c.ID] = d
|
||||
}
|
||||
|
||||
api := repo("lazysecrets-demo", true)
|
||||
file(api, "deploy/prod/secrets.yaml", "age, azure_kv", "tracked", "4f9c2ab · 3 days ago",
|
||||
"database.password", "pr0d-Pg-7f3k9QwZ",
|
||||
"database.url", "postgres://app:pr0d-Pg-7f3k9QwZ@pg-demo-prod:5432/app",
|
||||
"stripe.secret_key", "sk_live_demo_51NxDemoKey0000",
|
||||
"jwt.signing_key", "c2lnbmluZy1rZXktZm9yLXRoZS1kZW1v",
|
||||
"gcp.service_account", `{"type":"service_account","project_id":"lazysecrets-demo","client_email":"app@lazysecrets-demo.iam.gserviceaccount.com","private_key_id":"demo1234"}`)
|
||||
file(api, "deploy/staging/secrets.yaml", "age", "tracked", "a71e05d · 2 weeks ago",
|
||||
"database.password", "stg-Pg-2b8d1L",
|
||||
"database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app",
|
||||
"stripe.secret_key", "sk_test_demo_51NxDemoKey0000")
|
||||
file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago",
|
||||
"github.client_secret", "ghs_demo0000000000000000000000",
|
||||
"google.client_secret", "GOCSPX-demo-000000000000")
|
||||
file(api, ".env", "", "ignored", "",
|
||||
"DATABASE_URL", "postgres://app:local@localhost:5432/app",
|
||||
"STRIPE_SECRET_KEY", "sk_test_demo_local",
|
||||
"LOG_LEVEL", "debug")
|
||||
|
||||
infra := repo("platform-infra", false)
|
||||
file(infra, "terraform/prod/secrets.enc.yaml", "azure_kv", "tracked", "9d40c3e · yesterday",
|
||||
"postgres.admin_password", "infra-Pg-admin-0Xq",
|
||||
"grafana.admin_password", "grafana-demo-admin",
|
||||
"acr.pull_token", "acr-demo-pull-token")
|
||||
file(infra, "k8s/monitoring/alertmanager.enc.yaml", "age", "tracked", "1c58e7a · 5 days ago",
|
||||
"slack.webhook_url", "https://hooks.slack.com/services/T000/B000/demo",
|
||||
"pagerduty.routing_key", "pd-demo-routing-key")
|
||||
file(infra, "scripts/.env", "", "tracked", "77b2d90 · 8 months ago",
|
||||
"AZURE_CLIENT_SECRET", "azure-demo-client-secret",
|
||||
"ARM_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000")
|
||||
|
||||
web := repo("web-frontend", false)
|
||||
file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago",
|
||||
"SENTRY_AUTH_TOKEN", "sntrys_demo_token",
|
||||
"NEXT_PUBLIC_API_URL", "https://api.acme.example")
|
||||
file(web, ".env.local", "", "untracked", "",
|
||||
"NEXT_PUBLIC_API_URL", "http://localhost:8080",
|
||||
"ANALYTICS_KEY", "demo-analytics-key")
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// format is a SOPS file's format, by its name.
|
||||
func format(path string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(path, ".json"):
|
||||
return "json"
|
||||
case strings.HasSuffix(path, ".yaml"):
|
||||
return "yaml"
|
||||
}
|
||||
return "dotenv"
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Package provider defines the read-only interface every secret backend
|
||||
// implements, and the types the UI works with.
|
||||
//
|
||||
// All backends map onto the same hierarchy:
|
||||
//
|
||||
// Source Azure subscription | Databricks workspace profile
|
||||
// Container Key Vault | secret scope
|
||||
// Object secret, key or certificate | secret
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kind identifies a backend.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindAzure Kind = "Azure"
|
||||
KindDatabricks Kind = "Databricks"
|
||||
// KindRepo is a git repository's encrypted secret files (SOPS).
|
||||
KindRepo Kind = "Repo"
|
||||
)
|
||||
|
||||
// AuthError reports that the user must (re)authenticate with a backend, for
|
||||
// example because their az CLI login has expired.
|
||||
type AuthError struct {
|
||||
Kind Kind
|
||||
// Account names the identity when a provider has several, e.g.
|
||||
// "Azure · admin.alice"; empty otherwise.
|
||||
Account string
|
||||
// Login is the command that signs the user in, e.g. ["az", "login"].
|
||||
Login []string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *AuthError) Error() string {
|
||||
return fmt.Sprintf("%s: not signed in or session expired: %v", e.Who(), e.Err)
|
||||
}
|
||||
|
||||
// Who is the account, or the kind if there is just one.
|
||||
func (e *AuthError) Who() string {
|
||||
if e.Account != "" {
|
||||
return e.Account
|
||||
}
|
||||
return string(e.Kind)
|
||||
}
|
||||
|
||||
func (e *AuthError) Unwrap() error { return e.Err }
|
||||
|
||||
// ErrUnsupported is returned by optional operations a backend cannot perform,
|
||||
// such as listing versions of a Databricks secret.
|
||||
var ErrUnsupported = errors.New("not supported by this provider")
|
||||
|
||||
// Source is a top-level account: an Azure subscription or a Databricks
|
||||
// workspace profile.
|
||||
type Source struct {
|
||||
Kind Kind
|
||||
// Provider is the ID of the provider instance the source came from.
|
||||
// The app sets it; it routes requests when several providers share a
|
||||
// Kind, e.g. one per Azure account.
|
||||
Provider string
|
||||
// ProviderLabel is that provider's Label, also set by the app.
|
||||
ProviderLabel string
|
||||
ID string
|
||||
Name string
|
||||
Detail string // e.g. tenant or workspace host
|
||||
URL string // web page for the source, if any
|
||||
// Here marks the source lazysecrets was started in (the current git
|
||||
// repo): listed first, starred or not, and labelled.
|
||||
Here bool
|
||||
}
|
||||
|
||||
// ObjectType is the kind of object stored in a container.
|
||||
type ObjectType string
|
||||
|
||||
const (
|
||||
TypeSecret ObjectType = "Secret"
|
||||
TypeKey ObjectType = "Key"
|
||||
TypeCertificate ObjectType = "Certificate"
|
||||
)
|
||||
|
||||
// Container holds objects: a Key Vault or a Databricks secret scope.
|
||||
type Container struct {
|
||||
Source Source
|
||||
ID string
|
||||
Name string
|
||||
Detail string // e.g. vault URL, or "Azure Key Vault-backed: kv-name"
|
||||
// Types lists the object types the container holds; empty means
|
||||
// secrets only.
|
||||
Types []ObjectType
|
||||
URL string // web page for the container, if any
|
||||
// Attrs describe it further, from the listing (a vault's settings).
|
||||
Attrs []Attr `json:",omitempty"`
|
||||
}
|
||||
|
||||
// ObjectTypes returns the container's object types, defaulting to secrets.
|
||||
func (c Container) ObjectTypes() []ObjectType {
|
||||
if len(c.Types) == 0 {
|
||||
return []ObjectType{TypeSecret}
|
||||
}
|
||||
return c.Types
|
||||
}
|
||||
|
||||
// Attr is a type-specific metadata field, shown in provider order.
|
||||
type Attr struct {
|
||||
Label string
|
||||
Value string
|
||||
Warn bool // worth a look (a plain .env tracked by git): shown in red
|
||||
}
|
||||
|
||||
// Object describes a secret, key or certificate without its value.
|
||||
type Object struct {
|
||||
Container Container
|
||||
Type ObjectType
|
||||
ID string
|
||||
Name string
|
||||
Version string
|
||||
Enabled *bool
|
||||
Updated time.Time
|
||||
Expires time.Time
|
||||
ContentType string
|
||||
Tags map[string]string
|
||||
// Attrs holds type-specific metadata, e.g. key type and size, or a
|
||||
// certificate's subject, issuer and thumbprint.
|
||||
Attrs []Attr
|
||||
// Reference is how the object is referenced from config, e.g.
|
||||
// "@Microsoft.KeyVault(SecretUri=...)" or "{{secrets/scope/key}}".
|
||||
Reference string
|
||||
URL string // web page for the object (or its list), if any
|
||||
}
|
||||
|
||||
// Provider is a read-only secret backend. Every method may block on the
|
||||
// network and must honour ctx cancellation.
|
||||
type Provider interface {
|
||||
Kind() Kind
|
||||
// ID identifies the instance, stable across runs, e.g. "Azure" or
|
||||
// "Azure:user@example.com/tenant".
|
||||
ID() string
|
||||
// Label names the instance in the UI, e.g. "Azure" or "Azure · user".
|
||||
Label() string
|
||||
Sources(ctx context.Context) ([]Source, error)
|
||||
Containers(ctx context.Context, src Source) ([]Container, error)
|
||||
// Objects lists objects of type t, one of c.ObjectTypes(). It returns
|
||||
// metadata only; values are fetched with Value.
|
||||
Objects(ctx context.Context, c Container, t ObjectType) ([]Object, error)
|
||||
// Describe returns o with details that listing doesn't include, such as
|
||||
// a key's type or a certificate's subject. It never fetches secret
|
||||
// values; it may return o unchanged.
|
||||
Describe(ctx context.Context, o Object) (Object, error)
|
||||
// Versions may return ErrUnsupported.
|
||||
Versions(ctx context.Context, o Object) ([]Object, error)
|
||||
// Value returns a secret's value, or the public material of a key (PEM
|
||||
// public key) or certificate (PEM certificate). Private key material is
|
||||
// never returned for keys.
|
||||
Value(ctx context.Context, o Object) (SecretValue, error)
|
||||
}
|
||||
|
||||
// Current is implemented by providers that know the source lazysecrets
|
||||
// was started in (the current git repo) and can find it faster than
|
||||
// Sources lists everything, so it shows first.
|
||||
type Current interface {
|
||||
Current(ctx context.Context) (Source, bool)
|
||||
}
|
||||
|
||||
// Locator is implemented by providers that can find where a container lives,
|
||||
// to open links. address is a vault host (kv.vault.azure.net) or a
|
||||
// workspace host. It returns the ID of the source holding it.
|
||||
type Locator interface {
|
||||
Locate(ctx context.Context, address string) (sourceID string, ok bool, err error)
|
||||
}
|
||||
|
||||
// Files is implemented by providers whose containers are files on disk
|
||||
// (SOPS and .env files in repos), so they can be opened in an editor.
|
||||
type Files interface {
|
||||
// File is where c is, and for o (nil for the whole file) the line its
|
||||
// key is on.
|
||||
File(c Container, o *Object) (File, error)
|
||||
}
|
||||
|
||||
// File is a container's file, to open.
|
||||
type File struct {
|
||||
Path string
|
||||
Line int // 1-based; 0 for the top
|
||||
Sops string // the sops command to edit it with; empty for a plain file
|
||||
}
|
||||
|
||||
// Accounts is implemented by providers that can describe the identity
|
||||
// they use, from what they have locally: for the Details pane on the
|
||||
// provider's row. ok is false when there's nothing to say.
|
||||
type Accounts interface {
|
||||
Account() (Account, bool)
|
||||
}
|
||||
|
||||
// Account describes a signed-in identity.
|
||||
type Account struct {
|
||||
Fields []Attr
|
||||
// Copy is what y copies on the provider's row, named CopyLabel.
|
||||
Copy, CopyLabel string
|
||||
}
|
||||
|
||||
// Denied is a refusal with what to check, from what the service said: a
|
||||
// role to activate in PIM, access to ask for, the network. Error is the
|
||||
// refusal, then the checks as a list.
|
||||
type Denied struct {
|
||||
Err error
|
||||
Checks []string
|
||||
}
|
||||
|
||||
func (d *Denied) Error() string {
|
||||
if len(d.Checks) == 0 {
|
||||
return d.Err.Error()
|
||||
}
|
||||
return d.Err.Error() + "\n\nCheck:\n• " + strings.Join(d.Checks, "\n• ")
|
||||
}
|
||||
|
||||
func (d *Denied) Unwrap() error { return d.Err }
|
||||
|
||||
// ContainerDetails is implemented by providers that can say more about a
|
||||
// container than its listing does: how a file is encrypted and what git
|
||||
// knows of it. ErrUnsupported means there's nothing more for c.
|
||||
type ContainerDetails interface {
|
||||
Details(ctx context.Context, c Container) ([]Attr, error)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Details are how a file is encrypted and for whom, and what git knows of
|
||||
// it: a plain .env file that git tracks, or could, is marked.
|
||||
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
|
||||
r, ok := p.repo(c.Source.ID)
|
||||
if !ok {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
rel, err := filepath.Rel(r.root, c.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []provider.Attr
|
||||
meta, isSops := readMeta(c.ID)
|
||||
if isSops {
|
||||
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
|
||||
for i, k := range meta.recipients {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "keys"
|
||||
}
|
||||
out = append(out, provider.Attr{Label: label, Value: k})
|
||||
}
|
||||
if !meta.modified.IsZero() {
|
||||
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
|
||||
}
|
||||
} else {
|
||||
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
|
||||
}
|
||||
|
||||
git := func(args ...string) (string, bool) {
|
||||
b, err := exec.CommandContext(ctx, "git", append([]string{"-C", r.root}, args...)...).Output()
|
||||
return strings.TrimSpace(string(b)), err == nil
|
||||
}
|
||||
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
|
||||
case tracked && !isSops:
|
||||
out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
|
||||
case tracked:
|
||||
out = append(out, provider.Attr{Label: "git", Value: "tracked"})
|
||||
default:
|
||||
if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
|
||||
out = append(out, provider.Attr{Label: "git", Value: "ignored"})
|
||||
} else {
|
||||
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if last, ok := git("log", "-1", "--format=%h · %ar", "--", rel); ok && last != "" {
|
||||
out = append(out, provider.Attr{Label: "last commit", Value: last})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,523 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// A sops-shaped YAML file: listing reads its keys and metadata only.
|
||||
const sopsYAML = `db:
|
||||
password: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
|
||||
port: ENC[AES256_GCM,data:x,iv:x,tag:x,type:int]
|
||||
list:
|
||||
- ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
|
||||
public_unencrypted: hello
|
||||
sops:
|
||||
age:
|
||||
- recipient: age1xxxx
|
||||
enc: x
|
||||
lastmodified: "2026-10-09T10:05:29Z"
|
||||
mac: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
|
||||
version: 3.13.3
|
||||
`
|
||||
|
||||
const sopsEnv = "API_KEY=ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]\n" +
|
||||
"sops_age__list_0__map_recipient=age1xxxx\nsops_mac=ENC[x]\nsops_lastmodified=2026-10-09T10:05:29Z\nsops_version=3.13.3\n"
|
||||
|
||||
// gitRepo makes a git repository with files, all committed, and returns
|
||||
// its root.
|
||||
func gitRepo(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
root := t.TempDir()
|
||||
root, _ = filepath.EvalSymlinks(root) // macOS: /var is /private/var
|
||||
for name, body := range files {
|
||||
p := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"init", "-q"}, {"add", "."},
|
||||
{"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "files"},
|
||||
{"remote", "add", "origin", "git@git.example.com:team/infra.git"},
|
||||
} {
|
||||
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v %s", args, err, out)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func names[T any](items []T, name func(T) string) []string {
|
||||
var out []string
|
||||
for _, it := range items {
|
||||
out = append(out, name(it))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRepoFilesAndKeys(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
"secrets/app.yaml": sopsYAML,
|
||||
"svc/billing/prod.env": sopsEnv,
|
||||
"svc/billing/plain.yaml": "not: sops\n",
|
||||
"README.md": "# hi\n",
|
||||
})
|
||||
p := New(Settings{Dir: filepath.Join(root, "svc", "billing")})
|
||||
ctx := context.Background()
|
||||
|
||||
srcs, err := p.Sources(ctx)
|
||||
if err != nil || len(srcs) != 1 {
|
||||
t.Fatalf("sources %v, %v", srcs, err)
|
||||
}
|
||||
src := srcs[0]
|
||||
if src.Name != filepath.Base(root) || src.Detail != "git.example.com/team/infra" || src.URL != "https://git.example.com/team/infra" {
|
||||
t.Errorf("source %+v", src)
|
||||
}
|
||||
|
||||
files, err := p.Containers(ctx, src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Only sops files; those under where it started first.
|
||||
if got := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(got, []string{"svc/billing/prod.env", "secrets/app.yaml"}) {
|
||||
t.Errorf("files %v", got)
|
||||
}
|
||||
if files[1].Detail != "sops · age" {
|
||||
t.Errorf("detail %q", files[1].Detail)
|
||||
}
|
||||
|
||||
keys, err := p.Objects(ctx, files[1], provider.TypeSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(keys, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"db.password", "db.port", "list[0]", "public_unencrypted"}) {
|
||||
t.Errorf("keys %v", got)
|
||||
}
|
||||
if keys[0].Updated.IsZero() {
|
||||
t.Error("lastmodified not read")
|
||||
}
|
||||
if _, extract, _ := strings.Cut(keys[2].ID, "\x00"); extract != `["list"][0]` {
|
||||
t.Errorf("extract %q", extract)
|
||||
}
|
||||
|
||||
env, _ := p.Objects(ctx, files[0], provider.TypeSecret)
|
||||
if got := names(env, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"API_KEY"}) {
|
||||
t.Errorf("env keys %v", got)
|
||||
}
|
||||
|
||||
if id, ok, _ := p.Locate(ctx, "git.example.com/team/infra"); !ok || id != root {
|
||||
t.Errorf("locate: %q %v", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhichFiles(t *testing.T) {
|
||||
files := map[string]string{
|
||||
"secrets/a.yaml": sopsYAML,
|
||||
"other/b.yaml": sopsYAML,
|
||||
"other/testdata/c.yaml": sopsYAML,
|
||||
}
|
||||
list := func(root string, s Settings) []string {
|
||||
s.Dir = root
|
||||
p := New(s)
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
cs, err := p.Containers(context.Background(), srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(cs, func(c provider.Container) string { return c.Name })
|
||||
slices.Sort(got)
|
||||
return got
|
||||
}
|
||||
|
||||
// No rules: every tracked sops file.
|
||||
root := gitRepo(t, files)
|
||||
if got := list(root, Settings{}); len(got) != 3 {
|
||||
t.Errorf("whole repo: %v", got)
|
||||
}
|
||||
// The config's globs.
|
||||
if got := list(root, Settings{Include: []string{"**/*.yaml"}, Exclude: []string{"**/testdata/**"}}); !slices.Equal(got, []string{"other/b.yaml", "secrets/a.yaml"}) {
|
||||
t.Errorf("config globs: %v", got)
|
||||
}
|
||||
|
||||
// .sops.yaml says where sops files go.
|
||||
withRules := map[string]string{".sops.yaml": "creation_rules:\n - path_regex: secrets/.*\\.yaml$\n age: age1x\n"}
|
||||
for k, v := range files {
|
||||
withRules[k] = v
|
||||
}
|
||||
root = gitRepo(t, withRules)
|
||||
if got := list(root, Settings{}); !slices.Equal(got, []string{"secrets/a.yaml"}) {
|
||||
t.Errorf(".sops.yaml rules: %v", got)
|
||||
}
|
||||
|
||||
// The repo's own .lazysecrets.yaml wins over both.
|
||||
withRules[".lazysecrets.yaml"] = "include: [\"other/**\"]\nexclude: [\"**/testdata/**\"]\n"
|
||||
root = gitRepo(t, withRules)
|
||||
if got := list(root, Settings{Include: []string{"secrets/**"}}); !slices.Equal(got, []string{"other/b.yaml"}) {
|
||||
t.Errorf(".lazysecrets.yaml: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRemote(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"https://git.kebcloud.no/keb/nix-config.git": "git.kebcloud.no/keb/nix-config",
|
||||
"git@github.com:Team/Repo.git": "github.com/team/repo",
|
||||
"ssh://git@git.example.com:2222/team/infra": "git.example.com/team/infra",
|
||||
"https://user@dev.azure.com/org/proj/_git/x": "dev.azure.com/org/proj/_git/x",
|
||||
} {
|
||||
if got, _ := normalizeRemote(in); got != want {
|
||||
t.Errorf("%s: %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With sops and age installed: real encryption and decryption, with a
|
||||
// throwaway key.
|
||||
func TestDecrypt(t *testing.T) {
|
||||
for _, bin := range []string{"sops", "age-keygen"} {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
t.Skip("needs " + bin)
|
||||
}
|
||||
}
|
||||
dir := t.TempDir()
|
||||
keyFile := filepath.Join(dir, "key.txt")
|
||||
if out, err := exec.Command("age-keygen", "-o", keyFile).CombinedOutput(); err != nil {
|
||||
t.Fatalf("age-keygen: %v %s", err, out)
|
||||
}
|
||||
b, _ := os.ReadFile(keyFile)
|
||||
var recipient string
|
||||
for _, f := range strings.Fields(string(b)) {
|
||||
if strings.HasPrefix(f, "age1") {
|
||||
recipient = f
|
||||
}
|
||||
}
|
||||
plain := filepath.Join(dir, "plain.yaml")
|
||||
os.WriteFile(plain, []byte("db:\n password: hunter2\nlist:\n - first\n"), 0o600)
|
||||
enc, err := exec.Command("sops", "encrypt", "--age", recipient, plain).Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := gitRepo(t, map[string]string{"secrets.yaml": string(enc)})
|
||||
t.Setenv("SOPS_AGE_KEY_FILE", keyFile)
|
||||
|
||||
p := New(Settings{Dir: root})
|
||||
ctx := context.Background()
|
||||
srcs, _ := p.Sources(ctx)
|
||||
cs, _ := p.Containers(ctx, srcs[0])
|
||||
keys, _ := p.Objects(ctx, cs[0], provider.TypeSecret)
|
||||
for _, k := range keys {
|
||||
v, err := p.Value(ctx, k)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", k.Name, err)
|
||||
}
|
||||
want := map[string]string{"db.password": "hunter2", "list[0]": "first"}[k.Name]
|
||||
if v.Reveal() != want {
|
||||
t.Errorf("%s = %q, want %q", k.Name, v.Reveal(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// From a link, without the listing: the extract path from the name.
|
||||
v, err := p.Value(ctx, provider.Object{Container: cs[0], Name: "list[0]"})
|
||||
if err != nil || v.Reveal() != "first" {
|
||||
t.Errorf("from a link: %q %v", v.Reveal(), err)
|
||||
}
|
||||
|
||||
// Without the key: sops' own error.
|
||||
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
|
||||
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
|
||||
t.Errorf("no key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoWithoutSecretsIsntListed(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{"README.md": "# no secrets\n", "config.yaml": "plain: yaml\n"})
|
||||
srcs, err := New(Settings{Dir: root}).Sources(context.Background())
|
||||
if err != nil || len(srcs) != 0 {
|
||||
t.Errorf("sources %v, %v", srcs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainEnvFiles(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
".gitignore": ".env\n.env.local\n",
|
||||
".env.example": "TOKEN=changeme\n",
|
||||
"secrets/app.yaml": sopsYAML,
|
||||
})
|
||||
// Ignored, as they usually are: written after the commit.
|
||||
os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nexport TOKEN=\"s3cr3t value\"\nURL=https://x.example # the API\nQUOTED='a#b'\n"), 0o600)
|
||||
os.MkdirAll(filepath.Join(root, "node_modules", "pkg"), 0o755)
|
||||
os.WriteFile(filepath.Join(root, "node_modules", "pkg", ".env"), []byte("X=1\n"), 0o600)
|
||||
|
||||
p := New(Settings{Dir: root})
|
||||
ctx := context.Background()
|
||||
srcs, _ := p.Sources(ctx)
|
||||
files, err := p.Containers(ctx, srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, c := range files {
|
||||
got[c.Name] = c.Detail
|
||||
}
|
||||
if len(got) != 2 || got[".env"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
|
||||
t.Fatalf("files %v (want .env and the sops file; not the example or node_modules)", got)
|
||||
}
|
||||
|
||||
var env provider.Container
|
||||
for _, c := range files {
|
||||
if c.Name == ".env" {
|
||||
env = c
|
||||
}
|
||||
}
|
||||
keys, _ := p.Objects(ctx, env, provider.TypeSecret)
|
||||
want := map[string]string{"TOKEN": "s3cr3t value", "URL": "https://x.example", "QUOTED": "a#b"}
|
||||
if len(keys) != len(want) {
|
||||
t.Fatalf("keys %v", names(keys, func(o provider.Object) string { return o.Name }))
|
||||
}
|
||||
for _, k := range keys {
|
||||
v, err := p.Value(ctx, k) // no sops involved
|
||||
if err != nil || v.Reveal() != want[k.Name] {
|
||||
t.Errorf("%s = %q, %v; want %q", k.Name, v.Reveal(), err, want[k.Name])
|
||||
}
|
||||
}
|
||||
|
||||
// .sops.yaml rules limit SOPS files, not plain env files.
|
||||
os.WriteFile(filepath.Join(root, ".sops.yaml"), []byte("creation_rules:\n - path_regex: nowhere/.*\n"), 0o644)
|
||||
exec.Command("git", "-C", root, "add", ".sops.yaml").Run()
|
||||
files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0])
|
||||
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env"}) {
|
||||
t.Errorf("with rules elsewhere: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoots(t *testing.T) {
|
||||
withSecrets := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
|
||||
without := gitRepo(t, map[string]string{"README.md": "# none\n"})
|
||||
nested := gitRepo(t, map[string]string{".env.example": "X=y\n", "prod.env": sopsEnv})
|
||||
|
||||
// A folder of repos: two at the top, one under an org folder.
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
os.Rename(withSecrets, filepath.Join(root, "infra"))
|
||||
os.Rename(without, filepath.Join(root, "docs"))
|
||||
os.MkdirAll(filepath.Join(root, "acme"), 0o755)
|
||||
os.Rename(nested, filepath.Join(root, "acme", "billing"))
|
||||
|
||||
outside := t.TempDir() // not in a repo
|
||||
srcs, err := New(Settings{Dir: outside, Roots: []string{root}}).Sources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(srcs, func(s provider.Source) string { return s.Name })
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, []string{"acme/billing", "infra"}) {
|
||||
t.Errorf("repos under the root: %v (want the two with secret files)", got)
|
||||
}
|
||||
|
||||
// Inside a repo: the same repos, that one first and marked Here.
|
||||
srcs, _ = New(Settings{Dir: filepath.Join(root, "acme", "billing"), Roots: []string{root}}).Sources(context.Background())
|
||||
got = names(srcs, func(s provider.Source) string { return s.Name })
|
||||
if !slices.Equal(got, []string{"billing", "infra"}) || !srcs[0].Here || srcs[1].Here {
|
||||
t.Errorf("inside a repo: %v, want billing (here) then infra", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarredReposAlongside(t *testing.T) {
|
||||
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
|
||||
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
|
||||
other := gitRepo(t, map[string]string{"secrets/b.yaml": sopsYAML})
|
||||
_ = other
|
||||
|
||||
srcs, err := New(Settings{Dir: here, Starred: []string{starred, here}}).Sources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(srcs, func(s provider.Source) string { return s.ID })
|
||||
if !slices.Equal(got, []string{here, starred}) {
|
||||
t.Errorf("sources %v, want this repo then the starred one (not under a root)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// sops' output when no key opens the file, as sops 3.13 prints it.
|
||||
const noKeyStderr = `Failed to get the data key required to decrypt the SOPS file.
|
||||
|
||||
Group 0: FAILED
|
||||
age1el8zh94re2488t5rrpqxk5xcxhs2cg6xfcccntz6l4ggv0rsf9yq44c0cs: FAILED
|
||||
- | failed to create reader for decrypting sops data key with
|
||||
| age: no identity matched any of the recipients. Did not find
|
||||
| keys in locations 'SOPS_AGE_SSH_PRIVATE_KEY_FILE'.
|
||||
https://kv-app.vault.azure.net/keys/sops/abc: FAILED
|
||||
- | failed to decrypt sops data key with Azure Key Vault key
|
||||
| 'https://kv-app.vault.azure.net/keys/sops/abc': DefaultAzureCredential: failed to acquire a token.
|
||||
|
||||
Recovery failed because no master key was able to decrypt the file. In
|
||||
order for SOPS to recover the file, at least one key has to be successful,
|
||||
but none were.
|
||||
`
|
||||
|
||||
func TestSopsError(t *testing.T) {
|
||||
got := sopsError(noKeyStderr, errors.New("exit status 128"))
|
||||
if got != "sops couldn't decrypt it:\n"+strings.TrimSpace(noKeyStderr) {
|
||||
t.Errorf("not sops' whole message:\n%s", got)
|
||||
}
|
||||
if got := sopsError("", errors.New("exit status 1")); got != "sops couldn't decrypt it:\nexit status 1" {
|
||||
t.Errorf("without output: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(s string) string {
|
||||
p := filepath.Join(dir, "config")
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
return p
|
||||
}
|
||||
for _, c := range []struct {
|
||||
config, url string
|
||||
ok bool
|
||||
}{
|
||||
{"[core]\n\tbare = false\n[remote \"origin\"]\n\turl = git@git.example.com:team/infra.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n", "git@git.example.com:team/infra.git", true},
|
||||
{"[remote \"upstream\"]\n\turl = https://a/b\n[remote \"origin\"]\n\turl=https://git.example.com/x.git\n", "https://git.example.com/x.git", true},
|
||||
{"[core]\n\tbare = false\n", "", true}, // no origin
|
||||
{"[url \"git@host:\"]\n\tinsteadOf = https://host/\n[remote \"origin\"]\n\turl = https://host/x\n", "", false},
|
||||
} {
|
||||
url, ok := originURL(write(c.config))
|
||||
if url != c.url || ok != c.ok {
|
||||
t.Errorf("%q: %q %v, want %q %v", c.config, url, ok, c.url, c.ok)
|
||||
}
|
||||
}
|
||||
if _, ok := originURL(filepath.Join(dir, "missing")); ok {
|
||||
t.Error("a missing config should leave it to git")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvFilesSkipPackages(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
"app/main.go": "package main\n",
|
||||
"vendor/lib/x.go": "package lib\n",
|
||||
"web/node_modules/pkg/index.js": "x\n",
|
||||
"secrets/sops.yaml": sopsYAML,
|
||||
"web/node_modules/pkg/sops.yaml": sopsYAML,
|
||||
})
|
||||
// Untracked, as .env files usually are: next to tracked code, in a
|
||||
// package folder, and in a folder with nothing tracked.
|
||||
for f, body := range map[string]string{
|
||||
"app/.env": "A=1\n",
|
||||
"web/node_modules/pkg/.env": "B=2\n",
|
||||
"vendor/lib/.env": "C=3\n",
|
||||
"scratch/.env": "D=4\n",
|
||||
} {
|
||||
os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755)
|
||||
os.WriteFile(filepath.Join(root, f), []byte(body), 0o644)
|
||||
}
|
||||
p := New(Settings{Dir: root})
|
||||
srcs, _ := p.Sources(context.Background())
|
||||
files, err := p.Containers(context.Background(), srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(files, func(c provider.Container) string { return c.Name })
|
||||
slices.Sort(got)
|
||||
if want := []string{"app/.env", "secrets/sops.yaml"}; !slices.Equal(got, want) {
|
||||
t.Errorf("files %v, want %v (nothing from node_modules or vendor)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFile(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
".gitignore": ".env\n",
|
||||
"secrets/app.yaml": sopsYAML,
|
||||
})
|
||||
os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nTOKEN=x\n\nURL=y\n"), 0o600)
|
||||
p := New(Settings{Dir: root, Sops: "/opt/sops"})
|
||||
ctx := context.Background()
|
||||
srcs, _ := p.Sources(ctx)
|
||||
files, err := p.Containers(ctx, srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := map[string]int{}
|
||||
for _, c := range files {
|
||||
f, err := p.File(c, nil)
|
||||
if err != nil || f.Path != filepath.Join(root, c.Name) || f.Line != 0 {
|
||||
t.Errorf("%s: %+v, %v", c.Name, f, err)
|
||||
}
|
||||
if sops := f.Sops != ""; sops != (c.Name == "secrets/app.yaml") || (sops && f.Sops != "/opt/sops") {
|
||||
t.Errorf("%s: sops %q", c.Name, f.Sops)
|
||||
}
|
||||
keys, _ := p.Objects(ctx, c, provider.TypeSecret)
|
||||
for _, k := range keys {
|
||||
f, err := p.File(c, &k)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines[c.Name+" "+k.Name] = f.Line
|
||||
}
|
||||
}
|
||||
want := map[string]int{
|
||||
".env TOKEN": 2, ".env URL": 4,
|
||||
"secrets/app.yaml db.password": 2, "secrets/app.yaml db.port": 3,
|
||||
"secrets/app.yaml list[0]": 5, "secrets/app.yaml public_unencrypted": 6,
|
||||
}
|
||||
if !maps.Equal(lines, want) {
|
||||
t.Errorf("lines %v\nwant %v", lines, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDetails(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
".gitignore": ".env\n",
|
||||
"secrets/app.yaml": sopsYAML,
|
||||
"prod.env": sopsEnv,
|
||||
"tracked.env": "TOKEN=x\n", // a plain .env in git: worth a look
|
||||
})
|
||||
os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=x\n"), 0o600)
|
||||
os.WriteFile(filepath.Join(root, "local.env"), []byte("TOKEN=x\n"), 0o600)
|
||||
p := New(Settings{Dir: root})
|
||||
ctx := context.Background()
|
||||
srcs, _ := p.Sources(ctx)
|
||||
details := func(name string) map[string]provider.Attr {
|
||||
t.Helper()
|
||||
attrs, err := p.Details(ctx, provider.Container{Source: srcs[0], ID: filepath.Join(root, name), Name: name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]provider.Attr{}
|
||||
for _, a := range attrs {
|
||||
out[a.Label] = a
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
d := details("secrets/app.yaml")
|
||||
if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" ||
|
||||
!strings.HasSuffix(d["last commit"].Value, " ago") || strings.Contains(d["last commit"].Value, ":") || d["encrypted"].Value == "" {
|
||||
t.Errorf("sops yaml: %+v", d)
|
||||
}
|
||||
if d := details("prod.env"); d["keys"].Value != "age age1xxxx" {
|
||||
t.Errorf("sops env: %+v", d)
|
||||
}
|
||||
if d := details("tracked.env"); d["encryption"].Value != "none: plain text" || !d["git"].Warn {
|
||||
t.Errorf("tracked plain env: %+v", d)
|
||||
}
|
||||
if d := details(".env"); d["git"].Value != "ignored" || d["git"].Warn {
|
||||
t.Errorf("ignored env: %+v", d)
|
||||
}
|
||||
if d := details("local.env"); !d["git"].Warn || !strings.Contains(d["git"].Value, "not ignored") {
|
||||
t.Errorf("untracked env: %+v", d)
|
||||
}
|
||||
}
|
||||
@@ -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,342 @@
|
||||
// Package store keeps what lazysecrets remembers between runs in one JSON
|
||||
// file in the XDG state directory: a record per subscription, vault or
|
||||
// secret the user has starred, annotated or used, keyed by its
|
||||
// lazysecrets:// link, and the layout. It never holds secret values.
|
||||
//
|
||||
// JSON rather than YAML (the config's format) because the app writes it,
|
||||
// and so an editor plugin can read it with a built-in parser
|
||||
// (vim.json.decode).
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Node is what's remembered about one subscription, vault or secret. Name,
|
||||
// Path, Source and Account are context, so lists of nodes (favourites,
|
||||
// recent) can show and jump to them before their subscription is loaded.
|
||||
type Node struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Path []string `json:"path,omitempty"` // parents' names: [source] or [source, vault]
|
||||
Source string `json:"source,omitempty"` // ID of the subscription or workspace profile
|
||||
Account string `json:"account,omitempty"` // provider ID that last read it
|
||||
|
||||
// StarredBy lists the accounts (provider IDs) the item is starred
|
||||
// for: a star is the item through that account. A note describes the
|
||||
// item itself, whichever account shows it.
|
||||
StarredBy []string `json:"starredBy,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
|
||||
LastUsed time.Time `json:"lastUsed,omitzero"`
|
||||
Uses int `json:"uses,omitempty"`
|
||||
}
|
||||
|
||||
// keep reports whether the record holds anything worth keeping; context
|
||||
// alone isn't.
|
||||
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
|
||||
|
||||
// StarredFor reports whether the item is starred for account.
|
||||
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
|
||||
|
||||
// ToggleStar stars or unstars the item for account, reporting whether
|
||||
// it's starred now.
|
||||
func (n *Node) ToggleStar(account string) bool {
|
||||
if i := slices.Index(n.StarredBy, account); i >= 0 {
|
||||
n.StarredBy = slices.Delete(n.StarredBy, i, i+1)
|
||||
return false
|
||||
}
|
||||
n.StarredBy = append(n.StarredBy, account)
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateCheck is the last check for a newer release.
|
||||
func (s *Store) UpdateCheck() UpdateCheck {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.Update
|
||||
}
|
||||
|
||||
// SetUpdateCheck records a check for a newer release.
|
||||
func (s *Store) SetUpdateCheck(c UpdateCheck) error {
|
||||
return s.save(func(d *file) { d.Update = c })
|
||||
}
|
||||
|
||||
// Place is where lazysecrets was left when it last ran in dir.
|
||||
func (s *Store) Place(dir string) (Place, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p, ok := s.data.Places[dir]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// SetPlace remembers where lazysecrets was left in dir.
|
||||
func (s *Store) SetPlace(dir string, p Place) error {
|
||||
return s.save(func(d *file) {
|
||||
if d.Places == nil {
|
||||
d.Places = map[string]Place{}
|
||||
}
|
||||
d.Places[dir] = p
|
||||
for len(d.Places) > maxPlaces {
|
||||
oldest := ""
|
||||
for k, v := range d.Places {
|
||||
if oldest == "" || v.At.Before(d.Places[oldest].At) {
|
||||
oldest = k
|
||||
}
|
||||
}
|
||||
delete(d.Places, oldest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Layout is the user's pane sizing, as offsets from the default layout so
|
||||
// it adapts to the terminal size.
|
||||
type Layout struct {
|
||||
Split int `json:"split,omitempty"` // columns the divider moved
|
||||
Heights [3]int `json:"heights,omitzero"` // rows added to each left pane
|
||||
}
|
||||
|
||||
type file struct {
|
||||
Version int `json:"version"`
|
||||
Nodes map[string]Node `json:"nodes"`
|
||||
Layout Layout `json:"layout,omitzero"`
|
||||
// Flags are one-off choices, like "don't ask again".
|
||||
Flags map[string]bool `json:"flags,omitempty"`
|
||||
// Update is the last check for a newer release.
|
||||
Update UpdateCheck `json:"update,omitzero"`
|
||||
// Places is where lazysecrets was left, by the folder it ran in.
|
||||
Places map[string]Place `json:"places,omitempty"`
|
||||
}
|
||||
|
||||
// Place is what was selected when lazysecrets quit: an item's link, and
|
||||
// the account and source it was read through.
|
||||
type Place struct {
|
||||
Link string `json:"link"`
|
||||
Account string `json:"account"`
|
||||
Source string `json:"source"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// maxPlaces bounds Places: the folders used most recently are kept.
|
||||
const maxPlaces = 100
|
||||
|
||||
// UpdateCheck is the last look for a newer release: when, and what the
|
||||
// latest was (its tag, changelog and page), so the next start within a
|
||||
// day shows it without asking again.
|
||||
type UpdateCheck struct {
|
||||
At time.Time `json:"at,omitzero"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
const version = 1
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
data file
|
||||
}
|
||||
|
||||
// Load reads path; a missing file is an empty store. path "" gives a store
|
||||
// that never saves, for tests and --demo.
|
||||
func Load(path string) (*Store, error) {
|
||||
s := &Store{path: path}
|
||||
d, err := read(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.data = d
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func read(path string) (file, error) {
|
||||
d := file{Version: version, Nodes: map[string]Node{}}
|
||||
if path == "" {
|
||||
return d, nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return d, nil
|
||||
}
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &d); err != nil {
|
||||
return d, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if d.Version > version {
|
||||
return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version)
|
||||
}
|
||||
if d.Nodes == nil {
|
||||
d.Nodes = map[string]Node{}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Node returns the record for key, empty if there is none.
|
||||
func (s *Store) Node(key string) Node {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.Nodes[key]
|
||||
}
|
||||
|
||||
// Has reports whether there's a record for key.
|
||||
func (s *Store) Has(key string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, ok := s.data.Nodes[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Nodes returns a copy of all records.
|
||||
func (s *Store) Nodes() map[string]Node {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return maps.Clone(s.data.Nodes)
|
||||
}
|
||||
|
||||
// Update changes the record for key and saves. Another lazysecrets may
|
||||
// have saved in the meantime, so the file is read again first and only
|
||||
// this record is changed in it. A record left with nothing worth keeping
|
||||
// is removed.
|
||||
func (s *Store) Update(key string, change func(*Node)) error {
|
||||
return s.save(func(d *file) {
|
||||
n := d.Nodes[key]
|
||||
change(&n)
|
||||
if n.keep() {
|
||||
d.Nodes[key] = n
|
||||
} else {
|
||||
delete(d.Nodes, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Usage records — ones kept only for their use — are pruned when they
|
||||
// go unused this long, and beyond this many, oldest first.
|
||||
const (
|
||||
usageMaxAge = 90 * 24 * time.Hour
|
||||
usageMax = 100
|
||||
)
|
||||
|
||||
// Use counts a use of key at the given time, with change updating its
|
||||
// context, and prunes old usage records.
|
||||
func (s *Store) Use(key string, at time.Time, change func(*Node)) error {
|
||||
return s.save(func(d *file) {
|
||||
n := d.Nodes[key]
|
||||
change(&n)
|
||||
n.Uses++
|
||||
n.LastUsed = at
|
||||
d.Nodes[key] = n
|
||||
prune(d.Nodes, at)
|
||||
})
|
||||
}
|
||||
|
||||
// prune drops usage records unused since usageMaxAge before now, and the
|
||||
// oldest beyond usageMax. Starred and noted records stay.
|
||||
func prune(nodes map[string]Node, now time.Time) {
|
||||
var usage []string
|
||||
for k, n := range nodes {
|
||||
if len(n.StarredBy) > 0 || n.Note != "" {
|
||||
continue
|
||||
}
|
||||
if now.Sub(n.LastUsed) > usageMaxAge {
|
||||
delete(nodes, k)
|
||||
continue
|
||||
}
|
||||
usage = append(usage, k)
|
||||
}
|
||||
if len(usage) <= usageMax {
|
||||
return
|
||||
}
|
||||
slices.SortFunc(usage, func(a, b string) int { return nodes[b].LastUsed.Compare(nodes[a].LastUsed) })
|
||||
for _, k := range usage[usageMax:] {
|
||||
delete(nodes, k)
|
||||
}
|
||||
}
|
||||
|
||||
// Flag reports a one-off choice.
|
||||
func (s *Store) Flag(name string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.Flags[name]
|
||||
}
|
||||
|
||||
// SetFlag records a one-off choice.
|
||||
func (s *Store) SetFlag(name string, on bool) error {
|
||||
return s.save(func(d *file) {
|
||||
if d.Flags == nil {
|
||||
d.Flags = map[string]bool{}
|
||||
}
|
||||
if on {
|
||||
d.Flags[name] = true
|
||||
} else {
|
||||
delete(d.Flags, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) Layout() Layout {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.data.Layout
|
||||
}
|
||||
|
||||
func (s *Store) SetLayout(l Layout) error {
|
||||
return s.save(func(d *file) { d.Layout = l })
|
||||
}
|
||||
|
||||
// save applies change to the file as it is now on disk (or to memory, for
|
||||
// an unsaved store) and writes it atomically, readable by the user only.
|
||||
func (s *Store) save(change func(*file)) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
d := s.data
|
||||
if s.path != "" {
|
||||
var err error
|
||||
if d, err = read(s.path); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
d.Nodes = maps.Clone(d.Nodes)
|
||||
}
|
||||
change(&d)
|
||||
d.Version = version
|
||||
if s.path != "" {
|
||||
b, err := json.MarshalIndent(d, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(s.path, append(b, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.data = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAtomic(path string, b []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // no-op after a successful rename
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
@@ -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: never secret values or errors. It's kept between
|
||||
// runs in the cache file (persist.go).
|
||||
type cache struct {
|
||||
containers map[string]entry[[]provider.Container] // by source key
|
||||
objects map[string]entry[[]provider.Object] // by container key (ID|type)
|
||||
details map[string]entry[provider.Object] // Describe results by ID@version
|
||||
// cursor remembers the selected child per parent key, so returning to
|
||||
// a subscription or vault puts the cursor back where it was.
|
||||
cursor map[string]string
|
||||
}
|
||||
|
||||
type entry[T any] struct {
|
||||
value T
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func newCache() cache {
|
||||
return cache{
|
||||
containers: map[string]entry[[]provider.Container]{},
|
||||
objects: map[string]entry[[]provider.Object]{},
|
||||
details: map[string]entry[provider.Object]{},
|
||||
cursor: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (e entry[T]) fresh(now time.Time) bool { return now.Sub(e.at) < cacheTTL }
|
||||
|
||||
// detailKey includes the account: the result carries its container, and
|
||||
// later requests for the object are routed by it.
|
||||
func detailKey(o provider.Object) string {
|
||||
return containerKey(o.Container) + "|" + o.ID + "@" + o.Version
|
||||
}
|
||||
|
||||
func (c cache) rememberCursor(parent, child string) {
|
||||
if parent != "" && child != "" {
|
||||
c.cursor[parent] = child
|
||||
}
|
||||
}
|
||||
@@ -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,321 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
// Custom commands (customCommands in the config) run a shell command on
|
||||
// the selected item: by their key in the pane they're for, or from the
|
||||
// commands menu (x). Prompts ask for text first; commands that get the
|
||||
// secret value ask before running. The value reaches the command on its
|
||||
// input or in an environment variable, never on its command line.
|
||||
|
||||
// commandTimeout bounds commands whose output is captured; terminal ones
|
||||
// run as long as the user likes.
|
||||
const commandTimeout = 5 * time.Minute
|
||||
|
||||
type commandValueMsg struct {
|
||||
cmd commands.Command
|
||||
rendered string
|
||||
value provider.SecretValue
|
||||
err error
|
||||
}
|
||||
|
||||
type commandDoneMsg struct {
|
||||
cmd commands.Command
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
// paneContext is the custom command context of a pane.
|
||||
func paneContext(p pane) string {
|
||||
switch p {
|
||||
case paneSources:
|
||||
return commands.Sources
|
||||
case paneContainers:
|
||||
return commands.Vaults
|
||||
}
|
||||
return commands.Secrets
|
||||
}
|
||||
|
||||
// commandsHere are the custom commands for the focused pane.
|
||||
func (m *Model) commandsHere() []commands.Command {
|
||||
if m.focus == paneStatus {
|
||||
return nil
|
||||
}
|
||||
var out []commands.Command
|
||||
for _, c := range m.commands {
|
||||
if c.Context == commands.Global || c.Context == paneContext(m.focus) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// commandForKey is the custom command a key runs here, if any.
|
||||
func (m *Model) commandForKey(msg tea.KeyPressMsg) (commands.Command, bool) {
|
||||
for _, c := range m.commandsHere() {
|
||||
if c.Key != "" && c.Key == msg.String() {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return commands.Command{}, false
|
||||
}
|
||||
|
||||
// showCommands lists the custom commands for the focused pane.
|
||||
func (m *Model) showCommands() tea.Cmd {
|
||||
cmds := m.commandsHere()
|
||||
if len(cmds) == 0 {
|
||||
m.flash = warning("no custom commands for the " + strings.ToLower(paneNames[m.focus]) + " pane: see customCommands in the config")
|
||||
return nil
|
||||
}
|
||||
var opts []option
|
||||
for _, c := range cmds {
|
||||
label := c.Description
|
||||
if label == "" {
|
||||
label = c.Command
|
||||
}
|
||||
if c.Key != "" {
|
||||
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
|
||||
}
|
||||
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
|
||||
}
|
||||
return m.showFilterMenu("Commands", opts)
|
||||
}
|
||||
|
||||
// startCommand runs c on the selection: prompts, then confirmation, then
|
||||
// the command.
|
||||
func (m *Model) startCommand(c commands.Command) tea.Cmd {
|
||||
it, obj, ok := m.commandItem(c.Context)
|
||||
if !ok {
|
||||
m.flash = warning(map[string]string{
|
||||
commands.Sources: "select a subscription or workspace to run " + c.Description + " on",
|
||||
commands.Vaults: "select a vault or scope to run " + c.Description + " on",
|
||||
commands.Secrets: "select a secret to run " + c.Description + " on",
|
||||
}[c.Context])
|
||||
return nil
|
||||
}
|
||||
it.Prompt = map[string]commands.Q{}
|
||||
return m.askPrompt(c, it, obj, 0)
|
||||
}
|
||||
|
||||
// askPrompt asks c's prompts in turn, then confirms or runs it.
|
||||
func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Object, i int) tea.Cmd {
|
||||
if i == len(c.Prompts) {
|
||||
return m.confirmCommand(c, it, obj)
|
||||
}
|
||||
p := c.Prompts[i]
|
||||
in := newTextInput(p.Title+": ", 48)
|
||||
in.SetValue(p.Default)
|
||||
in.CursorEnd()
|
||||
m.prompt = &modal{
|
||||
title: cmpOr(c.Description, "Command"), input: &in,
|
||||
submit: func(m *Model, text string) tea.Cmd {
|
||||
it.Prompt[p.Name] = commands.Q(text)
|
||||
return m.askPrompt(c, it, obj, i+1)
|
||||
},
|
||||
footer: footer(hint(m.keys.PopupSelect, "next"), hint(m.keys.PopupClose, "cancel")),
|
||||
}
|
||||
return m.prompt.input.Focus()
|
||||
}
|
||||
|
||||
func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provider.Object) tea.Cmd {
|
||||
rendered, err := c.Render(it)
|
||||
if err != nil {
|
||||
m.flash = flash{text: c.Description + ": " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
if !c.Confirms() {
|
||||
return m.runCommand(c, rendered, obj)
|
||||
}
|
||||
var lines []string
|
||||
for _, l := range strings.Split(ansi.Wrap(rendered, 70, " "), "\n") {
|
||||
lines = append(lines, m.st.Muted.Render(l))
|
||||
}
|
||||
switch {
|
||||
case c.Value == "stdin":
|
||||
lines = append(lines, "", "It gets the value of "+string(it.Name)+" on its input.")
|
||||
case strings.HasPrefix(c.Value, "env:"):
|
||||
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
|
||||
}
|
||||
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{
|
||||
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCommand fetches the value if the command takes it, then runs it.
|
||||
func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Object) tea.Cmd {
|
||||
if c.Value == "" || obj == nil {
|
||||
return m.execCommand(c, rendered, provider.SecretValue{})
|
||||
}
|
||||
o := *obj
|
||||
p := m.providerFor(o.Container.Source)
|
||||
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
|
||||
timeout := m.timeout
|
||||
m.usedObject(o)
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
v, err := p.Value(ctx, o)
|
||||
return commandValueMsg{cmd: c, rendered: rendered, value: v, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// execCommand runs the command as its output mode says.
|
||||
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
|
||||
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh")
|
||||
setValue := func(cmd *exec.Cmd) {
|
||||
switch {
|
||||
case c.Value == "stdin":
|
||||
cmd.Stdin = strings.NewReader(value.Reveal())
|
||||
case strings.HasPrefix(c.Value, "env:"):
|
||||
cmd.Env = append(os.Environ(), strings.TrimPrefix(c.Value, "env:")+"="+value.Reveal())
|
||||
}
|
||||
}
|
||||
if c.Out() == commands.Terminal {
|
||||
// Pause before coming back, reading the terminal itself: the
|
||||
// command's input may be taken.
|
||||
script := rendered + "\nprintf '\\n%s' 'Press enter to return to lazysecrets'; read -r _ </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,224 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/browser"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Opening a repo's file (e): SOPS files with sops edit, which decrypts
|
||||
// them for the editor and encrypts them again after. Other files, inside
|
||||
// nvim: in that nvim, once lazysecrets has quit; else in $VISUAL or
|
||||
// $EDITOR, coming back after; else in the system's default app. On a
|
||||
// secret, editors that can open at a line start on its key.
|
||||
|
||||
type editDoneMsg struct {
|
||||
c provider.Container
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
// sopsUnchanged is sops edit's exit code when the file wasn't changed.
|
||||
const sopsUnchanged = 200
|
||||
|
||||
// edit opens the selected file, or the file of the selected secret.
|
||||
func (m *Model) edit() tea.Cmd {
|
||||
if m.focus == paneDetail && m.fields.Shown() {
|
||||
f, ok := m.fields.Selected()
|
||||
if !ok || f.Path == "" {
|
||||
m.flash = warning("nothing to open in an editor here")
|
||||
return nil
|
||||
}
|
||||
name := filepath.Base(f.Path)
|
||||
return m.openInEditor(provider.File{Path: f.Path}, func(err error) tea.Msg { return editDoneMsg{name: name, err: err} })
|
||||
}
|
||||
var c provider.Container
|
||||
var o *provider.Object
|
||||
switch m.focus {
|
||||
case paneSources:
|
||||
m.flash = warning("select a file to open it")
|
||||
return nil
|
||||
case paneContainers:
|
||||
sel, ok := m.containers.Selected()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
c = sel
|
||||
default:
|
||||
sel, ok := m.detail.Secret()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
c, o = sel.Container, &sel
|
||||
}
|
||||
files, ok := m.providerFor(c.Source).(provider.Files)
|
||||
if !ok {
|
||||
m.flash = warning("only SOPS and .env files in repos can be opened")
|
||||
return nil
|
||||
}
|
||||
f, err := files.File(c, o)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
if o != nil {
|
||||
m.usedObject(*o)
|
||||
} else {
|
||||
m.usedContainer(c)
|
||||
}
|
||||
name := filepath.Base(f.Path)
|
||||
finished := func(err error) tea.Msg { return editDoneMsg{c: c, name: name, err: err} }
|
||||
|
||||
if f.Sops != "" {
|
||||
cmd := exec.Command(f.Sops, "edit", f.Path)
|
||||
cmd.Dir = filepath.Dir(f.Path) // sops looks for .sops.yaml from here
|
||||
// sops starts SOPS_EDITOR (else EDITOR, else vim) on the decrypted
|
||||
// copy, which keeps the file's lines: start it on the key.
|
||||
editor := cmp.Or(m.getenv("SOPS_EDITOR"), m.getenv("EDITOR"), "vim")
|
||||
if at, ok := editorAtLine(editor, f.Line); ok {
|
||||
cmd.Env = append(os.Environ(), "SOPS_EDITOR="+at)
|
||||
}
|
||||
m.flash = flash{}
|
||||
return tea.ExecProcess(cmd, finished)
|
||||
}
|
||||
return m.openInEditor(f, finished)
|
||||
}
|
||||
|
||||
// plusLine are editors that open at a line with +N before the file.
|
||||
var plusLine = map[string]bool{
|
||||
"vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true,
|
||||
"micro": true, "kak": true, "mg": true, "ne": true, "joe": true, "vis": true,
|
||||
}
|
||||
|
||||
// editorAtLine is editor starting at line, for editors that take +N:
|
||||
// sops adds the file after it.
|
||||
func editorAtLine(editor string, line int) (string, bool) {
|
||||
fields := strings.Fields(editor)
|
||||
if line == 0 || len(fields) == 0 || !plusLine[filepath.Base(fields[0])] {
|
||||
return "", false
|
||||
}
|
||||
return editor + " +" + strconv.Itoa(line), true
|
||||
}
|
||||
|
||||
// editorArgs opens f at its line, for editors known to take one.
|
||||
func editorArgs(editor string, f provider.File) []string {
|
||||
fields := strings.Fields(editor)
|
||||
if f.Line == 0 || len(fields) == 0 {
|
||||
return []string{f.Path}
|
||||
}
|
||||
at := f.Path + ":" + strconv.Itoa(f.Line)
|
||||
switch base := filepath.Base(fields[0]); {
|
||||
case plusLine[base]:
|
||||
return []string{"+" + strconv.Itoa(f.Line), f.Path}
|
||||
case base == "hx" || base == "helix" || base == "subl" || base == "zed":
|
||||
return []string{at}
|
||||
case base == "code" || base == "codium" || base == "cursor" || base == "windsurf":
|
||||
return []string{"-g", at}
|
||||
}
|
||||
return []string{f.Path}
|
||||
}
|
||||
|
||||
// handleEditDone reports how editing went and reloads the file's secrets:
|
||||
// their names, lines or values may have changed.
|
||||
func (m *Model) handleEditDone(msg editDoneMsg) tea.Cmd {
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case errors.As(msg.err, &exit) && exit.ExitCode() == sopsUnchanged:
|
||||
m.flash = flash{text: msg.name + " unchanged"}
|
||||
return nil
|
||||
case errors.Is(msg.err, exec.ErrNotFound):
|
||||
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error() + " (is sops installed?)", isErr: true}
|
||||
return nil
|
||||
case msg.err != nil:
|
||||
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
m.flash = done("closed " + msg.name)
|
||||
if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) {
|
||||
m.hideValue()
|
||||
m.secrets.Refresh()
|
||||
return m.loadObjects(c, m.objType, m.selContainer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nvimLua opens a file in the nvim lazysecrets runs in. If lazysecrets is
|
||||
// that terminal's own process (the nvim plugin, or :terminal lazysecrets),
|
||||
// it waits for the terminal to close, so the file opens in the window
|
||||
// below rather than in one about to close. Otherwise (a shell in a
|
||||
// terminal) it opens it in the previous window now. The autocmd isn't
|
||||
// the buffer's own: snacks wipes the buffer when the terminal closes,
|
||||
// which would delete it before it runs.
|
||||
const nvimLua = `local path, line, pid = ...
|
||||
local function open()
|
||||
local ok, err = pcall(vim.cmd, "edit " .. vim.fn.fnameescape(path))
|
||||
if not ok then vim.notify("lazysecrets: " .. err, vim.log.levels.ERROR) return end
|
||||
if line > 0 then pcall(vim.api.nvim_win_set_cursor, 0, { line, 0 }) vim.cmd("normal! zz") end
|
||||
end
|
||||
for _, b in ipairs(vim.api.nvim_list_bufs()) do
|
||||
if vim.bo[b].buftype == "terminal" and vim.b[b].terminal_job_pid == pid then
|
||||
vim.api.nvim_create_autocmd("TermClose", { callback = function(ev)
|
||||
if ev.buf ~= b then return end
|
||||
vim.defer_fn(open, 50)
|
||||
return true
|
||||
end })
|
||||
return
|
||||
end
|
||||
end
|
||||
pcall(vim.cmd, "wincmd p")
|
||||
open()`
|
||||
|
||||
// nvimOpen asks the nvim at server to open path at line.
|
||||
func nvimOpen(server, path string, line int) error {
|
||||
q := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
||||
lua := strings.ReplaceAll(nvimLua, "\n", " ")
|
||||
expr := "luaeval('load(_A[1])(_A[2], _A[3], _A[4])', [" + q(lua) + ", " + q(path) + ", " +
|
||||
strconv.Itoa(line) + ", " + strconv.Itoa(os.Getpid()) + "])"
|
||||
out, err := exec.Command("nvim", "--server", server, "--remote-expr", expr).CombinedOutput()
|
||||
if err != nil {
|
||||
if msg := strings.TrimSpace(string(out)); msg != "" {
|
||||
return errors.New(msg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openInEditor opens f: in the nvim lazysecrets runs in, quitting; else
|
||||
// in $VISUAL or $EDITOR, coming back after; else in the default app.
|
||||
func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.Cmd {
|
||||
name := filepath.Base(f.Path)
|
||||
if server := m.getenv("NVIM"); server != "" {
|
||||
open := m.nvimOpen
|
||||
return func() tea.Msg {
|
||||
if err := open(server, f.Path, f.Line); err != nil {
|
||||
return finished(err)
|
||||
}
|
||||
return tea.QuitMsg{}
|
||||
}
|
||||
}
|
||||
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
|
||||
// Through the shell, so EDITOR can carry arguments ("code -w").
|
||||
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
|
||||
m.flash = flash{}
|
||||
return tea.ExecProcess(exec.Command("/bin/sh", args...), finished)
|
||||
}
|
||||
switch err := m.openURL(f.Path); {
|
||||
case errors.Is(err, browser.ErrRemote):
|
||||
m.flash = warning("set $EDITOR to open files over SSH")
|
||||
case err != nil:
|
||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
||||
default:
|
||||
m.flash = done("opened " + name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// The status pane lists this session's errors, newest first: what a pane
|
||||
// showed (a list that couldn't load) and what the footer flashed (a copy
|
||||
// that failed), which go when you move on. Only the first line of each,
|
||||
// and never kept after quitting.
|
||||
|
||||
// maxErrors is how many errors are kept.
|
||||
const maxErrors = 20
|
||||
|
||||
type loggedErr struct {
|
||||
at time.Time
|
||||
text string
|
||||
}
|
||||
|
||||
// logErr records text; an error already there moves to the top, with
|
||||
// the time it happened again.
|
||||
func (m *Model) logErr(text string) {
|
||||
text, _, _ = strings.Cut(strings.TrimSpace(text), "\n")
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
m.errLog = slices.DeleteFunc(m.errLog, func(e loggedErr) bool { return e.text == text })
|
||||
m.errLog = append([]loggedErr{{m.now(), text}}, m.errLog[:min(len(m.errLog), maxErrors-1)]...)
|
||||
}
|
||||
|
||||
// logErrOf records err, about what, except a sign-in, which the footer
|
||||
// and the account's details say.
|
||||
func (m *Model) logErrOf(what string, err error) {
|
||||
var auth *provider.AuthError
|
||||
if !errors.As(err, &auth) {
|
||||
m.logErr(what + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// logFlash records an error the footer is showing, once.
|
||||
func (m *Model) logFlash() {
|
||||
if m.flash.isErr && m.flash.text != m.loggedFlash {
|
||||
m.logErr(m.flash.text)
|
||||
}
|
||||
m.loggedFlash = m.flash.text
|
||||
}
|
||||
|
||||
// errorFields are the status pane's rows for them.
|
||||
func (m Model) errorFields() []panes.Field {
|
||||
out := make([]panes.Field, len(m.errLog))
|
||||
for i, e := range m.errLog {
|
||||
out[i] = panes.Field{Value: e.at.Format("15:04") + " " + e.text, Err: true}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
out[0].Label = "errors"
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// The status pane lists what has expired or will soon, across every vault
|
||||
// loaded (opened, searched, or from the cache): expiry dates come with
|
||||
// the lists, so this makes no requests. enter on one goes to it.
|
||||
|
||||
type expiringItem struct {
|
||||
o provider.Object
|
||||
row string // its source's row key, to go to it
|
||||
}
|
||||
|
||||
// expiringItems are the loaded objects that have expired or will within
|
||||
// the warning period, soonest first.
|
||||
func (m Model) expiringItems() []expiringItem {
|
||||
var out []expiringItem
|
||||
for _, p := range m.providers {
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
for _, c := range m.cache.containers[row].value {
|
||||
for _, t := range c.ObjectTypes() {
|
||||
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
||||
if state, _ := m.expiry(o); state != notExpiring {
|
||||
out = append(out, expiringItem{o, row})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b expiringItem) int {
|
||||
return cmp.Or(a.o.Expires.Compare(b.o.Expires), cmp.Compare(a.o.Name, b.o.Name))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// expiringFields are the status pane's rows for them, and how much of
|
||||
// everything they cover.
|
||||
func (m Model) expiringFields() []panes.Field {
|
||||
items := m.expiringItems()
|
||||
var out []panes.Field
|
||||
for _, it := range items {
|
||||
state, left := m.expiry(it.o)
|
||||
mark := "⚠ " + daysLeft(left)
|
||||
if state == expired {
|
||||
mark = "✗ expired"
|
||||
}
|
||||
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
|
||||
if r, err := ref.ForObject(it.o, ""); err == nil {
|
||||
f.Link = r.String()
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
cov := m.coverageOf(ofKind(provider.KindAzure))
|
||||
switch {
|
||||
case cov.vaults == 0 && len(items) == 0:
|
||||
return nil
|
||||
case !cov.complete():
|
||||
out = append(out, panes.Field{Value: fmt.Sprintf("in %d of %d vaults", cov.vaultsLoaded, cov.vaults) +
|
||||
withKey(m.keys.SearchLoadAll, " (%s in search loads all)"), Info: true})
|
||||
case len(items) == 0:
|
||||
out = append(out, panes.Field{Value: fmt.Sprintf("nothing within %d days", int(m.expiryWarning.Hours()/24)), Info: true})
|
||||
}
|
||||
out[0].Label = "expiring"
|
||||
return out
|
||||
}
|
||||
@@ -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,436 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// jump opens the favourites list, filters it and jumps to the first match.
|
||||
func (d *driver) jump(filter string) {
|
||||
d.t.Helper()
|
||||
d.keys("F")
|
||||
if d.m.search == nil || d.m.search.scope().id != scopeFavs {
|
||||
d.t.Fatalf("F didn't open Go to on Favourites:\n%s", d.screen())
|
||||
}
|
||||
for _, r := range filter {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.keys("enter")
|
||||
}
|
||||
|
||||
func TestStarVaultSortsFirstAndJumps(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("2", "j", "f") // star kv-shared-prod
|
||||
if items := d.m.containers.Items(); items[0].Name != "kv-shared-prod" {
|
||||
t.Errorf("starred vault not first: %s", items[0].Name)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "★ kv-shared-prod") {
|
||||
t.Errorf("no star:\n%s", d.screen())
|
||||
}
|
||||
if strings.Contains(d.screen(), "Favourites (") {
|
||||
t.Error("the old Favourites row is back")
|
||||
}
|
||||
|
||||
d.keys("1", "j") // elsewhere
|
||||
d.keys("F")
|
||||
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod") {
|
||||
t.Fatalf("favourites list:\n%s", d.screen())
|
||||
}
|
||||
d.keys("esc")
|
||||
d.jump("shared")
|
||||
if src, c, _ := d.selected(); src != "platform-prod" || c != "kv-shared-prod" {
|
||||
t.Errorf("jumped to %s/%s", src, c)
|
||||
}
|
||||
if d.m.focus != paneContainers {
|
||||
t.Errorf("focus %d after jumping to a vault", d.m.focus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJumpToStarredSourceAndSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "j", "f") // star api-key in platform-prod / kv-app-prod
|
||||
d.keys("1", "j", "f") // star platform-dev (it moves to the top of its group)
|
||||
d.keys("j", "j", "j") // down to Databricks prod
|
||||
|
||||
d.jump("platform-dev")
|
||||
if src, _, _ := d.selected(); src != "platform-dev" || d.m.focus != paneContainers {
|
||||
t.Errorf("source jump: %s, focus %d", src, d.m.focus)
|
||||
}
|
||||
d.jump("api")
|
||||
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
|
||||
t.Errorf("secret jump: %s/%s/%s", src, c, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavouritesListEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("F")
|
||||
if d.m.search == nil || !strings.Contains(d.screen(), "nothing starred yet") {
|
||||
t.Errorf("empty favourites:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarSecretSortsFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "j", "f") // star api-key
|
||||
if _, _, s := d.selected(); s != "api-key" {
|
||||
t.Errorf("cursor left the starred item: %q", s)
|
||||
}
|
||||
rows := d.m.secrets.Items()
|
||||
if rows[0].Name != "api-key" || !strings.Contains(d.screen(), "★ api-key") {
|
||||
t.Errorf("starred secret not first: %v\n%s", rows[0].Name, d.screen())
|
||||
}
|
||||
// Still first after leaving and coming back (cached list).
|
||||
d.keys("2", "j", "k")
|
||||
if d.m.secrets.Items()[0].Name != "api-key" {
|
||||
t.Error("starred secret not first after returning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoStarringVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("v", "f")
|
||||
if d.m.store.Node(objectKey(*d.m.versionsOf)).StarredFor("Azure") || !strings.Contains(d.screen(), "rather than a version") {
|
||||
t.Errorf("starring a version:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavouritesPersist(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
load := func() *store.Store {
|
||||
s, err := store.Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
d := newDriverOpts(t, Options{Store: load()})
|
||||
d.keys("2", "f", "3", "j", "f")
|
||||
|
||||
d2 := newDriverOpts(t, Options{Store: load()})
|
||||
d2.keys("F")
|
||||
if !strings.Contains(d2.screen(), "platform-prod / kv-app-prod") ||
|
||||
!strings.Contains(d2.screen(), "platform-prod / kv-app-prod / api-key") {
|
||||
t.Errorf("favourites not restored:\n%s", d2.screen())
|
||||
}
|
||||
d2.keys("esc")
|
||||
if d2.m.secrets.Items()[0].Name != "api-key" {
|
||||
t.Error("secret favourite not restored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarSourceMovesItToTheTop(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("j", "f") // star platform-dev
|
||||
if src, _, _ := d.selected(); src != "platform-dev" {
|
||||
t.Errorf("cursor left the starred source: %q", src)
|
||||
}
|
||||
rows := d.m.sources.Items()
|
||||
if rows[1].source.Name != "platform-dev" || !strings.Contains(d.screen(), "★ platform-dev") {
|
||||
t.Errorf("not first in its group: %q\n%s", rows[1].source.Name, d.screen())
|
||||
}
|
||||
|
||||
// Flat: starred sources come before every other provider's.
|
||||
// platform-dev now leads its group: platform-prod, header, prod, dev.
|
||||
d.keys("j", "j", "j", "j", "f", "`") // also star Databricks dev
|
||||
var flat []string
|
||||
for _, r := range d.m.sources.Items() {
|
||||
flat = append(flat, r.source.Name)
|
||||
}
|
||||
if strings.Join(flat[:2], ",") != "platform-dev,dev" {
|
||||
t.Errorf("flat order = %v", flat)
|
||||
}
|
||||
|
||||
d.keys("`", "k") // grouped again; dev leads its group, so k is the header
|
||||
d.keys("f")
|
||||
if !strings.Contains(d.screen(), "select a subscription or workspace to star") {
|
||||
t.Errorf("starring a header:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
// denying refuses to list objects, like an account without access.
|
||||
type denying struct{ *counting }
|
||||
|
||||
func (d denying) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) {
|
||||
d.objects++
|
||||
return nil, fake.ErrForbidden
|
||||
}
|
||||
|
||||
// kvKey is kv-app-prod's store key, kvSharedKey kv-shared-prod's.
|
||||
const (
|
||||
kvKey = "lazysecrets://azure/kv-app-prod.vault.azure.net"
|
||||
kvSharedKey = "lazysecrets://azure/kv-shared-prod.vault.azure.net"
|
||||
)
|
||||
|
||||
// starredWithoutAccount is kv-app-prod starred with no account recorded,
|
||||
// like one starred by an account that's no longer signed in.
|
||||
func starredWithoutAccount(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
s, _ := store.Load("")
|
||||
if err := s.Update(kvKey, func(n *store.Node) {
|
||||
n.Name, n.Path, n.Source, n.StarredBy = "kv-app-prod", []string{"platform-prod"}, "sub-prod", []string{"Azure:carol"}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestOldFavouriteFindsTheAccountWithAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
|
||||
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
|
||||
mem := starredWithoutAccount(t)
|
||||
d := newDriverOpts(t, Options{Store: mem}, denying{alice}, bob)
|
||||
|
||||
d.jump("kv-app") // both accounts see platform-prod: which one?
|
||||
if !strings.Contains(d.screen(), "Several accounts can see kv-app-prod") {
|
||||
t.Fatalf("no account choice:\n%s", d.screen())
|
||||
}
|
||||
d.keys("2") // bob
|
||||
if _, c, s := d.selected(); c != "kv-app-prod" || s != "db-password" {
|
||||
t.Fatalf("favourite not browsable: %q/%q\n%s", c, s, d.screen())
|
||||
}
|
||||
if got := mem.Node(kvKey).Account; got != "Azure:bob" {
|
||||
t.Errorf("favourite bound to %q, want Azure:bob", got)
|
||||
}
|
||||
d.keys("R") // used through bob now
|
||||
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod · bob") {
|
||||
t.Errorf("recent should show the account:\n%s", d.screen())
|
||||
}
|
||||
d.keys("esc")
|
||||
|
||||
// Reads go through bob, and alice isn't asked again.
|
||||
before := alice.objects
|
||||
d.keys("space")
|
||||
if bob.values != 1 || alice.values != 0 {
|
||||
t.Errorf("reveal: alice %d, bob %d", alice.values, bob.values)
|
||||
}
|
||||
d.keys("2", "r")
|
||||
if alice.objects != before {
|
||||
t.Errorf("alice asked again after binding: %d → %d", before, alice.objects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldFavouriteNoAccountHasAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
|
||||
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
|
||||
mem := starredWithoutAccount(t)
|
||||
d := newDriverOpts(t, Options{Store: mem}, denying{alice}, denying{bob})
|
||||
|
||||
d.jump("kv-app")
|
||||
d.keys("1") // alice: no access
|
||||
if !strings.Contains(d.screen(), "403 Forbidden") {
|
||||
t.Errorf("no access error:\n%s", d.screen())
|
||||
}
|
||||
if mem.Node(kvKey).Account != "" {
|
||||
t.Error("favourite bound although no account worked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavouritesPopupKeepsItsSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("j", "f", "2", "j", "f", "3", "j", "f") // a subscription, a vault, a secret
|
||||
d.keys("F")
|
||||
size := func() (int, int) {
|
||||
box := d.m.searchViewBox()
|
||||
return lipgloss.Width(box), lipgloss.Height(box)
|
||||
}
|
||||
w0, h0 := size()
|
||||
for _, k := range []string{"down", "down", "up"} {
|
||||
d.send(tea.KeyPressMsg{Code: map[string]rune{"down": tea.KeyDown, "up": tea.KeyUp}[k]})
|
||||
if w, h := size(); w != w0 || h != h0 {
|
||||
t.Fatalf("popup changed from %dx%d to %dx%d moving %s", w0, h0, w, h, k)
|
||||
}
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: 'z', Text: "z"}) // filter down to nothing
|
||||
if w, h := size(); w != w0 || h != h0 {
|
||||
t.Errorf("popup changed from %dx%d to %dx%d while filtering", w0, h0, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreKeysAreLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("f", "2", "f", "3", "j", "f")
|
||||
var keys []string
|
||||
for k := range d.m.store.Nodes() {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
want := []string{
|
||||
"lazysecrets://azure/kv-app-prod.vault.azure.net",
|
||||
"lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key",
|
||||
"lazysecrets://azure/subscriptions/sub-prod",
|
||||
}
|
||||
if !slices.Equal(keys, want) {
|
||||
t.Errorf("keys = %v", keys)
|
||||
}
|
||||
n := d.m.store.Node(want[1])
|
||||
if n.Name != "api-key" || n.Source != "sub-prod" || n.Account != "Azure" || !slices.Equal(n.Path, []string{"platform-prod", "kv-app-prod"}) {
|
||||
t.Errorf("context = %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavouritesShowNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("2", "j", "f", "n") // star kv-shared-prod and note it
|
||||
for _, r := range "shared certs" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.keys("enter", "F")
|
||||
line := ""
|
||||
for l := range strings.SplitSeq(d.screen(), "\n") {
|
||||
if strings.Contains(l, "kv-shared-prod") && strings.Contains(l, "│") && strings.Contains(l, "/") {
|
||||
line = l
|
||||
}
|
||||
}
|
||||
if !strings.Contains(line, "shared certs") {
|
||||
t.Fatalf("no note in the favourites row %q:\n%s", line, d.screen())
|
||||
}
|
||||
for _, r := range "certs" { // the filter matches notes
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
if len(d.m.search.shown) != 1 {
|
||||
t.Errorf("filtering by note: %d entries", len(d.m.search.shown))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretWithoutContextJumpsThroughItsVault(t *testing.T) {
|
||||
t.Parallel()
|
||||
mem, _ := store.Load("")
|
||||
_ = mem.Update(kvKey, func(n *store.Node) {
|
||||
n.Name, n.Path, n.Source, n.Account, n.StarredBy = "kv-app-prod", []string{"platform-prod"}, "sub-prod", "Azure", []string{"Azure"}
|
||||
})
|
||||
_ = mem.Update(kvKey+"/secrets/api-key", func(n *store.Node) { n.StarredBy = []string{"Azure"} })
|
||||
// counting hides Locate, like an account that can't find the vault
|
||||
// in Resource Graph: opening it as a link would land in Opened links.
|
||||
d := newDriverOpts(t, Options{Store: mem}, &counting{Provider: fake.Azure(0)})
|
||||
d.keys("j") // elsewhere
|
||||
d.jump("api-key")
|
||||
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
|
||||
t.Errorf("jumped to %s/%s/%s", src, c, s)
|
||||
}
|
||||
if strings.Contains(d.screen(), "Opened links") {
|
||||
t.Errorf("went through Opened links:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkedVaultKeepsKnownContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
n := store.Node{Source: "sub-prod", Path: []string{"platform-prod", "kv-app-prod"}}
|
||||
// A vault opened from a link: account known, subscription not.
|
||||
withContext("api-key", []string{"kv-app-prod.vault.azure.net", "kv-app-prod"},
|
||||
provider.Source{Kind: provider.KindAzure, Provider: "Azure:bob"})(&n)
|
||||
if n.Source != "sub-prod" || n.Path[0] != "platform-prod" || n.Account != "Azure:bob" {
|
||||
t.Errorf("context = %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleStarInGoTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("2", "j", "f") // star kv-shared-prod
|
||||
d.keys("3", "j", "space", "2") // use smtp-password, unstarred
|
||||
smtp := kvSharedKey + "/secrets/smtp-password"
|
||||
ctrlF := tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}
|
||||
|
||||
d.keys("R", "/")
|
||||
d.typeText("smtp")
|
||||
d.send(ctrlF) // star it from Recent, typing
|
||||
if !d.m.store.Node(smtp).StarredFor("Azure") || !strings.Contains(d.screen(), "★ ") {
|
||||
t.Fatalf("ctrl+f didn't star a recent item:\n%s", d.screen())
|
||||
}
|
||||
d.send(ctrlF) // and back
|
||||
if d.m.store.Node(smtp).StarredFor("Azure") {
|
||||
t.Error("second ctrl+f didn't unstar")
|
||||
}
|
||||
if d.m.search == nil || d.m.search.input.Value() != "smtp" || len(d.m.search.shown) == 0 {
|
||||
t.Fatalf("entry or query gone after toggling:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// Unstarring a favourite keeps it listed until Go to opens again.
|
||||
d.keys("esc", "esc", "F") // stop typing, close, open on Favourites
|
||||
v := d.m.search
|
||||
for v.res < len(v.shown)-1 && v.shown[v.res].star != kvSharedKey {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
|
||||
}
|
||||
if v.shown[v.res].star != kvSharedKey {
|
||||
t.Fatalf("selected %q", v.shown[v.res].star)
|
||||
}
|
||||
d.keys("f") // browsing: f
|
||||
if d.m.store.Node(kvSharedKey).StarredFor("Azure") {
|
||||
t.Error("favourite still starred")
|
||||
}
|
||||
if v.shown[v.res].star != kvSharedKey || v.shown[v.res].starred {
|
||||
t.Error("unstarred favourite didn't stay in place, unstarred")
|
||||
}
|
||||
d.keys("esc")
|
||||
if strings.Contains(d.screen(), "★ kv-shared-prod") {
|
||||
t.Errorf("Vaults pane still shows the star:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarWhileFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
for _, r := range "api" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||
if !d.m.store.Node(kvKey + "/secrets/api-key").StarredFor("Azure") {
|
||||
t.Errorf("ctrl+f while filtering didn't star:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarIsPerAccount(t *testing.T) {
|
||||
t.Parallel()
|
||||
alice := fake.Azure(0).As("Azure:alice", "Azure · alice")
|
||||
bob := fake.Azure(0).As("Azure:bob", "Azure · bob")
|
||||
d := newDriverOpts(t, Options{}, alice, bob)
|
||||
// Rows: alice header, platform-prod, platform-dev, bob header, ….
|
||||
d.keys("f") // alice's platform-prod
|
||||
n := d.m.store.Node("lazysecrets://azure/subscriptions/sub-prod")
|
||||
if !n.StarredFor("Azure:alice") || n.StarredFor("Azure:bob") {
|
||||
t.Errorf("starred by %v, want alice only", n.StarredBy)
|
||||
}
|
||||
if c := strings.Count(d.screen(), "★ platform-prod"); c != 1 {
|
||||
t.Errorf("star shown %d times, want once:\n%s", c, d.screen())
|
||||
}
|
||||
// A note is about the thing: it shows under both.
|
||||
d.keys("n")
|
||||
d.typeText("shared note")
|
||||
d.keys("enter")
|
||||
// On both accounts' rows, and in the selected one's details.
|
||||
if n := strings.Count(d.screen(), "shared note"); n != 3 {
|
||||
t.Errorf("note shown %d times, want 3:\n%s", n, d.screen())
|
||||
}
|
||||
d.keys("F")
|
||||
var labels []string
|
||||
for _, o := range d.m.search.shown {
|
||||
labels = append(labels, ansiStrip(o.label))
|
||||
}
|
||||
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
|
||||
t.Errorf("favourites should list alice's star only: %q", labels)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// The fourth pane shows Fields for anything but a secret. On a provider's
|
||||
// row: its account, from what the provider knows locally.
|
||||
|
||||
// A file's details beyond its listing load once a
|
||||
// session, when the cursor rests on it, and again with refresh. They're
|
||||
// extra: moving on cancels the load, and search never waits for them.
|
||||
type detailsState struct {
|
||||
attrs []provider.Attr
|
||||
err error
|
||||
loading bool
|
||||
}
|
||||
|
||||
type detailsMsg struct {
|
||||
req int
|
||||
key string
|
||||
attrs []provider.Attr
|
||||
err error
|
||||
}
|
||||
|
||||
// detailOf is the pane the fourth pane describes: the one in focus, or,
|
||||
// with the fourth in focus, the one it was entered from.
|
||||
func (m Model) detailOf() pane {
|
||||
if m.focus != paneDetail {
|
||||
return m.focus
|
||||
}
|
||||
return m.lastLeft
|
||||
}
|
||||
|
||||
// syncFields shows the Fields pane for what the focused pane has selected,
|
||||
// unless that's a secret (or a vault with the Secrets pane in focus), which
|
||||
// the secret pane shows, as it does a revealed value whatever the focus.
|
||||
func (m *Model) syncFields() tea.Cmd {
|
||||
if m.detail.State() != panes.ValueHidden {
|
||||
// A value revealed (or being fetched) from any pane stays in
|
||||
// view until it's hidden.
|
||||
m.fields.Hide()
|
||||
return nil
|
||||
}
|
||||
m.fields.Header, m.fields.HeaderShort = nil, ""
|
||||
switch m.detailOf() {
|
||||
case paneSources:
|
||||
r, ok := m.sources.Selected()
|
||||
switch {
|
||||
case m.nothingFound():
|
||||
m.fields.Show("get started", m.getStartedFields())
|
||||
case !ok:
|
||||
m.fields.Show("", nil)
|
||||
case m.selectedProblem() != "":
|
||||
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
|
||||
case r.kind == rowGroup:
|
||||
m.showAccount(r)
|
||||
case r.kind == rowSource:
|
||||
m.fields.Show(r.source.Name, m.sourceFields(r.source))
|
||||
default:
|
||||
m.fields.Show("", nil)
|
||||
}
|
||||
case paneStatus:
|
||||
m.fields.Header, m.fields.HeaderShort = logo, strings.TrimSpace("lazysecrets "+m.versionLabel())
|
||||
m.fields.Show("lazysecrets", m.statusFields())
|
||||
case paneContainers:
|
||||
if c, ok := m.containers.Selected(); ok {
|
||||
cmd := m.loadDetails(c)
|
||||
m.fields.Show(c.Name, m.containerFields(c))
|
||||
return cmd
|
||||
} else {
|
||||
m.fields.Show("", nil)
|
||||
}
|
||||
default:
|
||||
m.fields.Hide()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// showAccount shows a provider's account.
|
||||
func (m *Model) showAccount(r sourceRow) {
|
||||
p := m.byID[r.provider]
|
||||
var fields []panes.Field
|
||||
if a, ok := p.(provider.Accounts); ok {
|
||||
if acct, ok := a.Account(); ok {
|
||||
for _, f := range acct.Fields {
|
||||
fields = append(fields, panes.Field{Label: f.Label, Value: f.Value})
|
||||
}
|
||||
}
|
||||
}
|
||||
if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 {
|
||||
hint := emptyHint(p.Kind())
|
||||
fields = append(fields, panes.Field{Label: "nothing", Value: "found: try", Info: true}, panes.Field{Value: hint.Value, URL: hint.URL})
|
||||
}
|
||||
m.fields.Show(p.Label(), fields)
|
||||
}
|
||||
|
||||
// sourceFields describe a subscription, workspace or repo.
|
||||
func (m Model) sourceFields(src provider.Source) []panes.Field {
|
||||
var out []panes.Field
|
||||
add := func(label, value string) {
|
||||
if value != "" {
|
||||
out = append(out, panes.Field{Label: label, Value: value})
|
||||
}
|
||||
}
|
||||
// Azure's detail is "subscription <ID>": the ID row says it.
|
||||
id, detail := "ID", ""
|
||||
switch src.Kind {
|
||||
case provider.KindDatabricks:
|
||||
id, detail = "profile", "host"
|
||||
case provider.KindRepo:
|
||||
id, detail = "path", "remote"
|
||||
}
|
||||
add("name", src.Name)
|
||||
add("note", m.store.Node(sourceKey(src)).Note)
|
||||
if src.ID != src.Name {
|
||||
add(id, src.ID)
|
||||
}
|
||||
if detail != "" {
|
||||
add(detail, src.Detail)
|
||||
}
|
||||
if src.ProviderLabel != string(src.Kind) {
|
||||
add("account", src.ProviderLabel)
|
||||
}
|
||||
add("web", src.URL)
|
||||
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
|
||||
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
|
||||
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// containerFields describe a vault, scope or file.
|
||||
func (m Model) containerFields(c provider.Container) []panes.Field {
|
||||
var out []panes.Field
|
||||
add := func(label, value string) {
|
||||
if value != "" {
|
||||
out = append(out, panes.Field{Label: label, Value: value})
|
||||
}
|
||||
}
|
||||
add("name", c.Name)
|
||||
add("note", m.store.Node(storeKey(c)).Note)
|
||||
// The vault's URI, the scope's backend, the file's format.
|
||||
switch c.Source.Kind {
|
||||
case provider.KindAzure:
|
||||
add("URI", c.Detail)
|
||||
case provider.KindDatabricks:
|
||||
add("backend", c.Detail)
|
||||
case provider.KindRepo:
|
||||
add("format", c.Detail)
|
||||
}
|
||||
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
||||
add("web", c.URL)
|
||||
for _, t := range c.ObjectTypes() {
|
||||
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok {
|
||||
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
|
||||
}
|
||||
}
|
||||
for _, a := range c.Attrs {
|
||||
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
|
||||
}
|
||||
switch st := m.details[containerKey(c)]; {
|
||||
case st.loading:
|
||||
out = append(out, panes.Field{Label: "details", Value: "loading…", Info: true})
|
||||
case errors.Is(st.err, provider.ErrUnsupported):
|
||||
case st.err != nil:
|
||||
out = append(out, panes.Field{Label: "details", Value: "couldn't load them: " + st.err.Error(), Info: true, Err: true})
|
||||
}
|
||||
for _, a := range m.details[containerKey(c)].attrs {
|
||||
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// loadDetails loads c's details, if its provider has them and they
|
||||
// aren't loaded yet.
|
||||
func (m *Model) loadDetails(c provider.Container) tea.Cmd {
|
||||
d, ok := m.providerFor(c.Source).(provider.ContainerDetails)
|
||||
key := containerKey(c)
|
||||
if _, seen := m.details[key]; !ok || seen {
|
||||
return nil
|
||||
}
|
||||
// One at a time: the previous one, if it's still loading, is
|
||||
// cancelled and forgotten, to load when it's shown again.
|
||||
for k, st := range m.details {
|
||||
if st.loading {
|
||||
delete(m.details, k)
|
||||
}
|
||||
}
|
||||
m.details[key] = detailsState{loading: true}
|
||||
ctx, req := m.begin(slotDetails)
|
||||
return m.settled(ctx, func() tea.Msg {
|
||||
attrs, err := d.Details(ctx, c)
|
||||
if ctx.Err() != nil {
|
||||
err = ctx.Err()
|
||||
}
|
||||
return detailsMsg{req: req, key: key, attrs: attrs, err: err}
|
||||
})
|
||||
}
|
||||
|
||||
// problemFields show why an account has nothing to show, wrapped to the
|
||||
// pane, in red.
|
||||
func (m Model) problemFields(problem string) []panes.Field {
|
||||
var out []panes.Field
|
||||
for _, l := range strings.Split(ansi.Wrap(problem, max(20, m.fields.Width()-16), " "), "\n") {
|
||||
out = append(out, panes.Field{Value: l, Info: true, Err: true})
|
||||
}
|
||||
out[0].Label = "problem"
|
||||
return out
|
||||
}
|
||||
|
||||
// fieldName names a copied field: its label, or a row of several under
|
||||
// one label (a tag), its value.
|
||||
func fieldName(f panes.Field) string {
|
||||
return cmpOr(f.Label, f.Value)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
|
||||
// When no account has anything to show, as on a first run, the right
|
||||
// pane says how to get started: the sign-in for each kind of account
|
||||
// (y copies it, L runs az login), and the demo.
|
||||
|
||||
// nothingFound reports whether every account has loaded, with nothing.
|
||||
func (m Model) nothingFound() bool {
|
||||
if m.sourcesPending > 0 {
|
||||
return false
|
||||
}
|
||||
for _, p := range m.providers {
|
||||
if len(m.sourceResults[p.ID()].sources) > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// getStartedFields are the rows saying how to get started.
|
||||
func (m Model) getStartedFields() []panes.Field {
|
||||
out := []panes.Field{{Value: "Nothing to show yet: sign in where your secrets are.", Info: true}}
|
||||
seen := map[provider.Kind]bool{}
|
||||
for _, p := range m.providers {
|
||||
if seen[p.Kind()] {
|
||||
continue
|
||||
}
|
||||
seen[p.Kind()] = true
|
||||
f := emptyHint(p.Kind())
|
||||
var auth *provider.AuthError
|
||||
switch err := m.sourceResults[p.ID()].err; {
|
||||
case errors.As(err, &auth) && len(auth.Login) > 0:
|
||||
f.Provider = p.ID() // L signs in
|
||||
out = append(out, f, panes.Field{Value: withKey(m.keys.Login, "%s runs it here"), Info: true})
|
||||
case err != nil:
|
||||
out = append(out, f, panes.Field{Value: err.Error(), Info: true, Err: true})
|
||||
default:
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return append(out,
|
||||
panes.Field{Label: "try", Value: "lazysecrets --demo"},
|
||||
panes.Field{Value: "built-in sample data, to look around", Info: true},
|
||||
panes.Field{Label: "docs", Value: "getting started", URL: update.DefaultRepo + "#get-started"},
|
||||
)
|
||||
}
|
||||
|
||||
// emptyHint is how an account of kind gets something to show.
|
||||
func emptyHint(kind provider.Kind) panes.Field {
|
||||
switch kind {
|
||||
case provider.KindDatabricks:
|
||||
return panes.Field{Label: "Databricks", Value: "databricks auth login --host https://<workspace>"}
|
||||
case provider.KindRepo:
|
||||
return panes.Field{Label: "repos", Value: "SOPS and .env files in your git repos",
|
||||
URL: update.DefaultRepo + "/src/branch/main/docs/repos.md"}
|
||||
}
|
||||
return panes.Field{Label: string(kind), Value: "az login"}
|
||||
}
|
||||
@@ -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,335 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
// The help bar shows the keys that matter in the focused pane; ? lists all
|
||||
// of them in a menu (showKeybindings).
|
||||
|
||||
// newHelp styles the help bar to read clearly: keys like labels elsewhere,
|
||||
// descriptions in the normal text colour, only the separators dimmed. The
|
||||
// bubbles defaults are grey on grey.
|
||||
func newHelp(st styles.Styles) help.Model {
|
||||
h := help.New()
|
||||
h.Styles.ShortKey = st.Label
|
||||
h.Styles.ShortDesc = lipgloss.NewStyle()
|
||||
h.Styles.ShortSeparator = st.Muted
|
||||
h.Styles.Ellipsis = st.Muted
|
||||
h.Styles.FullKey, h.Styles.FullDesc, h.Styles.FullSeparator = h.Styles.ShortKey, h.Styles.ShortDesc, h.Styles.ShortSeparator
|
||||
return h
|
||||
}
|
||||
|
||||
// contextHelp is the help bar for the focused pane.
|
||||
type contextHelp struct {
|
||||
k keys.Map
|
||||
focus pane
|
||||
commands bool // custom commands apply here
|
||||
account bool // the fourth pane shows Fields (fields.go)
|
||||
of pane // what the fourth pane describes
|
||||
// revealed, when set, says whether the selected value is shown, so
|
||||
// the reveal key says just what it'll do: show or hide, the same
|
||||
// width so the bar doesn't shift.
|
||||
revealed *bool
|
||||
}
|
||||
|
||||
func (c contextHelp) ShortHelp() []key.Binding {
|
||||
k := c.k
|
||||
var b []key.Binding
|
||||
if k.Login.Enabled() {
|
||||
b = append(b, k.Login)
|
||||
}
|
||||
copyField := key.NewBinding(key.WithKeys(k.Copy.Keys()...), key.WithHelp(k.Copy.Help().Key, "copy"))
|
||||
switch {
|
||||
case c.focus == paneStatus:
|
||||
details := key.NewBinding(key.WithKeys(k.Enter.Keys()...), key.WithHelp(k.Enter.Help().Key, "details"))
|
||||
return append(b, details, k.Update, k.Help, k.Quit)
|
||||
case c.focus == paneDetail && c.account && c.of == paneStatus:
|
||||
open := key.NewBinding(key.WithKeys(k.Open.Keys()...), key.WithHelp(k.Open.Help().Key, "open link"))
|
||||
return append(b, k.Up, k.Down, copyField, open, k.Edit, k.Filter, k.Help, k.Quit)
|
||||
case c.focus == paneDetail && c.account:
|
||||
return append(b, k.Up, k.Down, copyField, k.Filter, k.Help, k.Quit)
|
||||
}
|
||||
switch c.focus {
|
||||
case paneSources:
|
||||
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Filter)
|
||||
case paneContainers:
|
||||
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
|
||||
default:
|
||||
reveal := k.Reveal
|
||||
if c.revealed != nil {
|
||||
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed]))
|
||||
}
|
||||
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
|
||||
}
|
||||
if c.commands {
|
||||
b = append(b, k.Commands)
|
||||
}
|
||||
return append(b, k.OpenRef, k.Help, k.Quit)
|
||||
}
|
||||
|
||||
func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.ShortHelp()} }
|
||||
|
||||
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
|
||||
|
||||
// showKeybindings opens the ? menu, like lazygit's: every binding with
|
||||
// what it does, the focused pane's first, filterable by key, description
|
||||
// and group. Enter runs the selected one.
|
||||
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
|
||||
|
||||
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with
|
||||
// what it does, filterable by key, description and group; enter runs the
|
||||
// selected one. Opened from the panes, it lists the focused pane's keys
|
||||
// ("") first, then the rest; from a popup (keys.GoTo, keys.Menu), only
|
||||
// the keys that work there, and it comes back to the popup when closed.
|
||||
func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
|
||||
// Where it was opened from, to come back to.
|
||||
search, prompt := m.search, m.prompt
|
||||
m.search = nil
|
||||
|
||||
actions := m.keys.Actions()
|
||||
id := func(a keys.Action) string { return a.Context + "." + a.Name }
|
||||
here := map[string]bool{}
|
||||
var local []keys.Action
|
||||
switch ctx {
|
||||
case "":
|
||||
for _, b := range (contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}).ShortHelp() {
|
||||
for _, a := range actions {
|
||||
if a.Context == keys.Lists && !here[id(a)] && slices.Equal(a.Binding.Keys(), b.Keys()) && a.Desc == b.Help().Desc {
|
||||
here[id(a)] = true
|
||||
local = append(local, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
for _, a := range actions {
|
||||
if a.Context == ctx {
|
||||
here[id(a)] = true
|
||||
local = append(local, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
var rest []keys.Action
|
||||
for _, a := range actions {
|
||||
if !here[id(a)] && a.Binding.Enabled() && !(a.Context == keys.Lists && a.Name == "help") {
|
||||
rest = append(rest, a)
|
||||
}
|
||||
}
|
||||
var opts []option
|
||||
section := func(title string, list []keys.Action) {
|
||||
opts = append(opts, option{label: title, header: true})
|
||||
for _, a := range list {
|
||||
h := a.Binding.Help()
|
||||
if len(a.Binding.Keys()) == 0 {
|
||||
continue // unbound
|
||||
}
|
||||
msg := keyPress(a.Binding.Keys()[0])
|
||||
// Go to's typing keys need Go to typing: start it first.
|
||||
typing := ctx == keys.GoTo && (a.Context == keys.Popup || a.Context == keys.Search)
|
||||
label := m.st.Label.Render(fmt.Sprintf("%-14s", h.Key)) + fmt.Sprintf("%-26s", h.Desc) + m.st.Muted.Render(a.Group)
|
||||
opts = append(opts, option{label: label, tip: a.Tip, run: func(m *Model) tea.Cmd {
|
||||
send := func() tea.Msg { return msg }
|
||||
if typing && m.search != nil && !m.search.typing {
|
||||
// Typing starts now; its command only blinks the cursor.
|
||||
return tea.Batch(m.setTyping(true), send)
|
||||
}
|
||||
return send
|
||||
}})
|
||||
}
|
||||
}
|
||||
title := map[string]string{"": paneNames[m.focus] + " pane", keys.GoTo: "Go to", keys.Menu: "This menu"}[ctx]
|
||||
section(title, local)
|
||||
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
|
||||
opts = append(opts, option{label: "Custom commands", header: true})
|
||||
for _, c := range cmds {
|
||||
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom")
|
||||
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
|
||||
}
|
||||
}
|
||||
switch ctx {
|
||||
case "":
|
||||
section("Global", rest)
|
||||
case keys.GoTo:
|
||||
// Only what works here: Go to's keys, browsing and typing.
|
||||
var typing []keys.Action
|
||||
for _, a := range actions {
|
||||
if a.Context == keys.Popup || a.Context == keys.Search {
|
||||
typing = append(typing, a)
|
||||
}
|
||||
}
|
||||
section("While typing, after "+keys.Name(m.keys.GoFilter), typing)
|
||||
}
|
||||
cmd := m.showFilterMenu("Keybindings", opts)
|
||||
m.prompt.footer = footer(move(m.keys.PopupUp, m.keys.PopupDown), hint(m.keys.PopupSelect, "run"), hint(m.keys.PopupClose, "close"))
|
||||
if ctx != "" {
|
||||
m.prompt.onClose = func(m *Model) { m.search, m.prompt = search, prompt }
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// versionLabel is the version as shown: "v0.1.0", or the build for
|
||||
// development builds ("dev-c1a4f90").
|
||||
func (m Model) versionLabel() string {
|
||||
if m.version == "" || strings.HasPrefix(m.version, "dev") || strings.HasPrefix(m.version, "v") {
|
||||
return m.version
|
||||
}
|
||||
return "v" + m.version
|
||||
}
|
||||
|
||||
// helpBar draws bindings as the help bar. When they don't fit, entries
|
||||
// before the last keep (like "? help • q quit") give way, marked "…",
|
||||
// so those always show.
|
||||
func (m Model) helpBar(bindings []key.Binding, keep int) string {
|
||||
var bs []key.Binding
|
||||
for _, b := range bindings {
|
||||
if b.Enabled() {
|
||||
bs = append(bs, b)
|
||||
}
|
||||
}
|
||||
h := m.help
|
||||
h.SetWidth(0) // measure untruncated
|
||||
limit := max(0, m.w-1)
|
||||
if v := m.versionLabel(); v != "" {
|
||||
limit -= ansi.StringWidth(v) + 2 // a gap, the version, the margin
|
||||
}
|
||||
if full := h.ShortHelpView(bs); ansi.StringWidth(full) <= limit {
|
||||
return full
|
||||
}
|
||||
keep = min(keep, len(bs))
|
||||
head, tail := bs[:len(bs)-keep], bs[len(bs)-keep:]
|
||||
sep := h.Styles.ShortSeparator.Render(h.ShortSeparator)
|
||||
more := h.Styles.Ellipsis.Render("…")
|
||||
for n := len(head); n >= 0; n-- {
|
||||
line := more + sep + h.ShortHelpView(tail)
|
||||
if n > 0 {
|
||||
line = h.ShortHelpView(head[:n]) + sep + line
|
||||
}
|
||||
if ansi.StringWidth(line) <= limit || n == 0 {
|
||||
return ansi.Truncate(line, limit, "…")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// popupKeys are the open popup's keys for the help bar: Go to's for its
|
||||
// mode and tab, a menu's, or a text field's.
|
||||
func (m Model) popupKeys() []key.Binding {
|
||||
km := m.keys
|
||||
describe := func(b key.Binding, desc string) key.Binding {
|
||||
b.SetHelp(b.Help().Key, desc)
|
||||
return b
|
||||
}
|
||||
if v := m.search; v != nil {
|
||||
inSearch := v.tab == tabSearch
|
||||
if v.typing {
|
||||
b := []key.Binding{km.PopupDown, km.PopupUp}
|
||||
if inSearch {
|
||||
b = append(b, km.SearchSources, km.SearchResults, km.SearchLoad, km.SearchLoadAll)
|
||||
}
|
||||
return append(b, km.PopupStar, km.PopupSelect, describe(km.PopupClose, "stop typing"))
|
||||
}
|
||||
b := []key.Binding{km.GoDown, km.GoUp, km.GoPrevTab, km.GoNextTab}
|
||||
if inSearch {
|
||||
b = append(b, km.GoSources, km.GoResults, km.GoLoad, km.GoLoadAll)
|
||||
}
|
||||
return append(b, km.GoFilter, km.GoStar, km.GoSelect, km.GoClose, km.Help)
|
||||
}
|
||||
switch p := m.prompt; {
|
||||
case p.input != nil:
|
||||
return []key.Binding{describe(km.PopupSelect, "confirm"), describe(km.PopupClose, "cancel")}
|
||||
case p.filter != nil:
|
||||
return []key.Binding{km.PopupDown, km.PopupUp, km.PopupSelect, km.PopupClose}
|
||||
}
|
||||
return []key.Binding{km.MenuDown, km.MenuUp, km.MenuSelect, km.MenuClose, km.Help}
|
||||
}
|
||||
|
||||
// A keyHint is a key and what it does, for footers and hints, read from
|
||||
// the bindings so they show what's bound.
|
||||
type keyHint struct{ key, desc string }
|
||||
|
||||
func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), desc} }
|
||||
|
||||
// move is "↑/↓ move" for an up and a down binding.
|
||||
func move(up, down key.Binding) keyHint {
|
||||
u, d := keys.Name(up), keys.Name(down)
|
||||
if u == "" || d == "" {
|
||||
return keyHint{u + d, "move"}
|
||||
}
|
||||
return keyHint{u + "/" + d, "move"}
|
||||
}
|
||||
|
||||
// withKey fills a hint's format with the binding's key ("· %s loads
|
||||
// everything"), or is empty when the action is unbound.
|
||||
func withKey(b key.Binding, format string) string {
|
||||
if k := keys.Name(b); k != "" {
|
||||
return fmt.Sprintf(format, k)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// joinKeys is "ctrl+h/ctrl+l" for two bindings, or whichever is bound.
|
||||
func joinKeys(a, b key.Binding) string {
|
||||
x, y := keys.Name(a), keys.Name(b)
|
||||
if x != "" && y != "" {
|
||||
return x + "/" + y
|
||||
}
|
||||
return x + y
|
||||
}
|
||||
|
||||
// footer joins hints as "↑/↓ move · enter go", leaving out unbound keys.
|
||||
func footer(hints ...keyHint) string {
|
||||
var parts []string
|
||||
for _, h := range hints {
|
||||
if h.key != "" {
|
||||
parts = append(parts, h.key+" "+h.desc)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// keyPress builds the key press a binding's key name stands for, so the ?
|
||||
// menu can run it.
|
||||
func keyPress(name string) tea.KeyPressMsg {
|
||||
var mod tea.KeyMod
|
||||
for {
|
||||
switch {
|
||||
case strings.HasPrefix(name, "ctrl+") && len(name) > 5:
|
||||
mod |= tea.ModCtrl
|
||||
name = name[5:]
|
||||
continue
|
||||
case strings.HasPrefix(name, "shift+") && len(name) > 6:
|
||||
mod |= tea.ModShift
|
||||
name = name[6:]
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
named := map[string]rune{
|
||||
"space": tea.KeySpace, "enter": tea.KeyEnter, "tab": tea.KeyTab, "esc": tea.KeyEscape,
|
||||
"up": tea.KeyUp, "down": tea.KeyDown, "left": tea.KeyLeft, "right": tea.KeyRight,
|
||||
"home": tea.KeyHome, "end": tea.KeyEnd, "backspace": tea.KeyBackspace,
|
||||
}
|
||||
if code, ok := named[name]; ok {
|
||||
k := tea.KeyPressMsg{Code: code, Mod: mod}
|
||||
if code == tea.KeySpace && mod == 0 {
|
||||
k.Text = " "
|
||||
}
|
||||
return k
|
||||
}
|
||||
r := []rune(name)[0]
|
||||
if mod != 0 {
|
||||
return tea.KeyPressMsg{Code: r, Mod: mod}
|
||||
}
|
||||
return tea.KeyPressMsg{Code: r, Text: name}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// The Favourites and Recent scopes of the Go to popup list what's
|
||||
// remembered in the store, so they work before anything has loaded.
|
||||
|
||||
// storeEntries lists starred items (sources first, then vaults, then
|
||||
// secrets) and recently used ones (newest first), as Go to entries.
|
||||
// Entries read as paths: " platform-prod / kv-app-prod / api-key · dev",
|
||||
// the account last, and only when there are several.
|
||||
func (m *Model) storeEntries() (favs, recent []option) {
|
||||
type entry struct {
|
||||
depth int // 0 source, 1 vault, 2 secret: sources list first
|
||||
sort string
|
||||
opt option
|
||||
}
|
||||
nodes := m.store.Nodes()
|
||||
keys := slices.Sorted(maps.Keys(nodes))
|
||||
|
||||
// entries lists jumps to the item through each of accounts; nil
|
||||
// means through whichever accounts list a source, else the account
|
||||
// that last read the item.
|
||||
entries := func(prefix, key string, n store.Node, accounts []string) []entry {
|
||||
r, err := ref.Parse(key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []entry
|
||||
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
|
||||
account := ""
|
||||
if p, ok := m.byID[acct]; ok {
|
||||
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
|
||||
}
|
||||
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
||||
out = append(out, entry{depth, sort, option{
|
||||
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
|
||||
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
|
||||
run: func(m *Model) tea.Cmd {
|
||||
m.used(key)
|
||||
return run(m)
|
||||
}}})
|
||||
}
|
||||
if r.IsSource() {
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != r.Kind || (accounts != nil && !slices.Contains(accounts, p.ID())) {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
if !sourceMatches(r, src) {
|
||||
continue
|
||||
}
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
add([]string{src.Name}, p.ID(), 0, func(m *Model) tea.Cmd {
|
||||
m.nav = &nav{sourceRow: row, sourceOnly: true}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
if accounts == nil {
|
||||
accounts = []string{n.Account}
|
||||
}
|
||||
depth := 1
|
||||
if !r.IsContainer() {
|
||||
depth = 2
|
||||
}
|
||||
path := slices.Clone(n.Path)
|
||||
if len(path) == 0 && !r.IsContainer() {
|
||||
path = []string{r.ContainerName()} // no context recorded
|
||||
}
|
||||
for _, acct := range accounts {
|
||||
via := n
|
||||
via.Account = acct
|
||||
add(append(slices.Clone(path), cmp.Or(n.Name, r.Name, r.ContainerName())), acct, depth,
|
||||
func(m *Model) tea.Cmd { return m.jumpTo(r, via) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var starred []entry
|
||||
for _, k := range keys {
|
||||
if n := nodes[k]; len(n.StarredBy) > 0 {
|
||||
starred = append(starred, entries("fav|", k, n, n.StarredBy)...)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(starred, func(a, b entry) int {
|
||||
return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort))
|
||||
})
|
||||
for _, e := range starred {
|
||||
favs = append(favs, e.opt)
|
||||
}
|
||||
|
||||
if m.history {
|
||||
var used []string
|
||||
for _, k := range keys {
|
||||
if !nodes[k].LastUsed.IsZero() {
|
||||
used = append(used, k)
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(used, func(a, b string) int { return nodes[b].LastUsed.Compare(nodes[a].LastUsed) })
|
||||
for _, k := range used[:min(len(used), recentMax)] {
|
||||
for _, e := range entries("recent|", k, nodes[k], nil) {
|
||||
recent = append(recent, e.opt)
|
||||
}
|
||||
}
|
||||
}
|
||||
return favs, recent
|
||||
}
|
||||
|
||||
// recentMax is how many recently used items Recent lists.
|
||||
const recentMax = 20
|
||||
|
||||
// pathLabel draws a jump list entry: the provider's icon and the path in
|
||||
// its colour, then the account.
|
||||
func (m Model) pathLabel(kind provider.Kind, path []string, account string) string {
|
||||
style := m.st.Kind(kind)
|
||||
parts := make([]string, len(path))
|
||||
for i, p := range path {
|
||||
parts[i] = style.Render(p)
|
||||
}
|
||||
label := style.Render(m.icons.Prefix(kind)) + strings.Join(parts, m.st.Muted.Render(" / "))
|
||||
if account != "" {
|
||||
label += m.st.Muted.Render(" · " + account)
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// sourceMatches reports whether src is the subscription or workspace r
|
||||
// links to.
|
||||
func sourceMatches(r ref.Ref, src provider.Source) bool {
|
||||
if r.Kind != provider.KindAzure { // by host: workspaces, repos
|
||||
return strings.EqualFold(strings.TrimSuffix(src.Detail, "/"), r.Host)
|
||||
}
|
||||
return src.ID == r.Subscription
|
||||
}
|
||||
|
||||
// accountOf is the account part of a source's provider label ("alice" in
|
||||
// "Azure · alice"), or "" when there's a single account.
|
||||
func accountOf(src provider.Source) string {
|
||||
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
// jumpTo goes to a remembered vault or secret. With the account that last
|
||||
// read it signed in, it goes straight there; otherwise it uses the
|
||||
// accounts that list its subscription, asking if there are several, or
|
||||
// finds it like a link. Reaching it updates the remembered account.
|
||||
func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
|
||||
if n.Source == "" && !r.IsContainer() {
|
||||
// A secret without context: its vault's record may have it.
|
||||
parent := r
|
||||
parent.Type, parent.Name, parent.Version = "", "", ""
|
||||
if p := m.store.Node(parent.String()); p.Source != "" {
|
||||
n.Source, n.Account = p.Source, cmp.Or(n.Account, p.Account)
|
||||
}
|
||||
}
|
||||
if _, ok := m.byID[n.Account]; ok && n.Source != "" {
|
||||
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
|
||||
return nil
|
||||
}
|
||||
var choices []choice
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
if src.ID == n.Source && n.Source != "" {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
}
|
||||
}
|
||||
switch len(choices) {
|
||||
case 0:
|
||||
return m.openRef(r, actGo)
|
||||
case 1:
|
||||
m.startNav(r, actGo, choices[0].sourceRow)
|
||||
default:
|
||||
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// Layout: the left column stacks Sources, Vaults and Secrets; the detail
|
||||
// pane fills the rest. The user can move the divider between them, change
|
||||
// the left panes' heights, and zoom the focused pane.
|
||||
|
||||
type zoom int
|
||||
|
||||
const (
|
||||
zoomNormal zoom = iota
|
||||
zoomHalf // the focused pane gets most of the screen
|
||||
zoomFull // only the focused pane
|
||||
zoomCount
|
||||
)
|
||||
|
||||
const (
|
||||
// Divider between the left column and the detail pane.
|
||||
splitStep = 4
|
||||
minLeftW = 20
|
||||
minDetailW = 30
|
||||
defaultMinW = 28
|
||||
defaultMaxW = 48
|
||||
|
||||
// Heights of the left panes.
|
||||
heightStep = 2
|
||||
minPaneH = 4 // border, title and two rows
|
||||
tinyPaneH = 3 // a squeezed pane in half zoom: border and one row
|
||||
)
|
||||
|
||||
func (m Model) defaultLeftWidth() int { return max(defaultMinW, min(defaultMaxW, m.w/3)) }
|
||||
|
||||
// leftWidth is the default width moved by the user's divider offset,
|
||||
// kept within bounds that leave both sides usable.
|
||||
func (m Model) leftWidth() int {
|
||||
return max(minLeftW, min(m.w-minDetailW, m.defaultLeftWidth()+m.split))
|
||||
}
|
||||
|
||||
// moveSplit moves the divider by delta columns. The offset is stored as
|
||||
// what actually applied, so after hitting a limit the other direction
|
||||
// responds immediately.
|
||||
func (m *Model) moveSplit(delta int) {
|
||||
m.split += delta
|
||||
m.split = m.leftWidth() - m.defaultLeftWidth()
|
||||
}
|
||||
|
||||
func defaultHeights(avail int) [3]int {
|
||||
src, cont := max(minPaneH, avail/4), max(minPaneH, avail/3)
|
||||
return [3]int{src, cont, avail - src - cont}
|
||||
}
|
||||
|
||||
// leftHeights applies the user's height changes to the default split.
|
||||
func (m Model) leftHeights(avail int) [3]int {
|
||||
h := defaultHeights(avail)
|
||||
for i := range h {
|
||||
h[i] += m.heights[i]
|
||||
}
|
||||
return fitHeights(h, avail, minPaneH)
|
||||
}
|
||||
|
||||
// fitHeights makes the heights sum to avail with none below lo, taking
|
||||
// from or giving to the tallest panes.
|
||||
func fitHeights(h [3]int, avail, lo int) [3]int {
|
||||
for i := range h {
|
||||
h[i] = max(lo, h[i])
|
||||
}
|
||||
for sum(h) > avail {
|
||||
i := tallest(h)
|
||||
if h[i] <= lo {
|
||||
break // the terminal is too short; the layout check catches it
|
||||
}
|
||||
h[i]--
|
||||
}
|
||||
for sum(h) < avail {
|
||||
h[tallest(h)]++
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func sum(h [3]int) int { return h[0] + h[1] + h[2] }
|
||||
|
||||
func tallest(h [3]int) int {
|
||||
i := 0
|
||||
for j := range h {
|
||||
if h[j] > h[i] {
|
||||
i = j
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// resizeFocused makes the focused left pane taller (delta > 0) or shorter,
|
||||
// trading rows with the pane below it, or above it for the bottom pane.
|
||||
// It reports false if the detail pane is focused.
|
||||
func (m *Model) resizeFocused(delta int) bool {
|
||||
if m.focus > paneSecrets {
|
||||
return false
|
||||
}
|
||||
avail := m.listAvail()
|
||||
h := m.leftHeights(avail)
|
||||
i := int(m.focus)
|
||||
j := i + 1
|
||||
if i == int(paneSecrets) {
|
||||
j = i - 1
|
||||
}
|
||||
delta = max(delta, minPaneH-h[i]) // don't shrink below the minimum
|
||||
delta = min(delta, h[j]-minPaneH) // or squeeze the neighbour below it
|
||||
h[i] += delta
|
||||
h[j] -= delta
|
||||
base := defaultHeights(avail)
|
||||
for k := range h {
|
||||
m.heights[k] = h[k] - base[k]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// saveLayout remembers the divider and heights for the next run.
|
||||
func (m *Model) saveLayout() {
|
||||
if err := m.store.SetLayout(store.Layout{Split: m.split, Heights: m.heights}); err != nil {
|
||||
m.flash = flash{text: "saving layout: " + err.Error(), isErr: true}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) leftAvail() int { return m.h - lipgloss.Height(m.footerView()) }
|
||||
|
||||
// listAvail is the rows the three list panes share, under the status pane.
|
||||
func (m Model) listAvail() int { return m.leftAvail() - statusH }
|
||||
|
||||
func (m *Model) cycleZoom(delta int) {
|
||||
m.zoom = (m.zoom + zoom(delta) + zoomCount) % zoomCount
|
||||
}
|
||||
|
||||
func (m *Model) layout() {
|
||||
if m.w < minWidth || m.h < minHeight {
|
||||
return
|
||||
}
|
||||
m.help.SetWidth(max(0, m.w-1)) // one column in, like messages
|
||||
avail := m.leftAvail()
|
||||
leftW, h := m.leftWidth(), m.leftHeights(avail-statusH)
|
||||
|
||||
switch m.zoom {
|
||||
case zoomHalf:
|
||||
if m.focus == paneDetail || m.focus == paneStatus {
|
||||
leftW = minLeftW
|
||||
} else {
|
||||
leftW = max(m.leftWidth(), m.w/2)
|
||||
h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH}
|
||||
h[m.focus] = avail - statusH
|
||||
h = fitHeights(h, avail-statusH, tinyPaneH)
|
||||
}
|
||||
case zoomFull:
|
||||
// Only the focused pane is drawn; it gets the whole screen.
|
||||
switch m.focus {
|
||||
case paneDetail, paneStatus:
|
||||
leftW = 0
|
||||
default:
|
||||
leftW = m.w
|
||||
h = [3]int{}
|
||||
h[m.focus] = avail
|
||||
}
|
||||
}
|
||||
|
||||
m.statusW = leftW
|
||||
m.sources.SetSize(leftW, h[0])
|
||||
m.containers.SetSize(leftW, h[1])
|
||||
m.secrets.SetSize(leftW, h[2])
|
||||
m.detail.SetSize(m.w-leftW, avail)
|
||||
m.fields.SetSize(m.w-leftW, avail)
|
||||
}
|
||||
|
||||
// active reports whether p is drawn focused: it has focus and the terminal
|
||||
// does too. Like lazygit, nothing is highlighted while you're in another
|
||||
// window, so the focus is easy to spot when you come back.
|
||||
func (m Model) active(p pane) bool { return m.focus == p && !m.termBlurred }
|
||||
|
||||
func (m Model) panesView() string {
|
||||
spin := m.spin.View()
|
||||
views := [paneStatus]func() string{
|
||||
func() string { return m.sources.View(m.st, m.active(paneSources), spin) },
|
||||
func() string { return m.containers.View(m.st, m.active(paneContainers), spin) },
|
||||
func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) },
|
||||
func() string {
|
||||
if m.fields.Shown() {
|
||||
return m.fields.View(m.st, m.active(paneDetail), spin)
|
||||
}
|
||||
return m.detail.View(m.st, m.active(paneDetail), spin)
|
||||
},
|
||||
}
|
||||
if m.zoom == zoomFull {
|
||||
if m.focus == paneStatus {
|
||||
return views[paneDetail]() // its rows
|
||||
}
|
||||
return views[m.focus]()
|
||||
}
|
||||
left := lipgloss.JoinVertical(lipgloss.Left, m.statusView(), views[0](), views[1](), views[2]())
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, views[3]())
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// paneHeights reads the left panes' heights back from the screen: each
|
||||
// starts with a "╭─ [n]" line.
|
||||
func (d *driver) paneHeights() [3]int {
|
||||
lines := strings.Split(d.screen(), "\n")
|
||||
var starts []int
|
||||
for i, l := range lines {
|
||||
for n := 1; n <= 3; n++ {
|
||||
if strings.HasPrefix(l, "╭─ ["+string(rune('0'+n))+"]") {
|
||||
starts = append(starts, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(starts) != 3 {
|
||||
d.t.Fatalf("found %d left panes:\n%s", len(starts), d.screen())
|
||||
}
|
||||
bottom := len(lines) - 1 - strings.Count(d.m.footerView(), "\n") // first footer line
|
||||
return [3]int{starts[1] - starts[0], starts[2] - starts[1], bottom - starts[2]}
|
||||
}
|
||||
|
||||
func TestPaneHeights(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t) // 30 rows, one footer line: 29 for panes
|
||||
start := d.paneHeights()
|
||||
if sum(start) != 29 {
|
||||
t.Fatalf("heights %v don't fill the column", start)
|
||||
}
|
||||
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModCtrl}) // Sources taller, from Vaults
|
||||
got := d.paneHeights()
|
||||
if got[0] != start[0]+heightStep || got[1] != start[1]-heightStep || got[2] != start[2] {
|
||||
t.Errorf("ctrl+↓ on Sources: %v → %v", start, got)
|
||||
}
|
||||
|
||||
d.keys("3", "}") // Secrets is the bottom pane: it takes from Vaults above
|
||||
got2 := d.paneHeights()
|
||||
if got2[2] != got[2]+heightStep || got2[1] != got[1]-heightStep {
|
||||
t.Errorf("} on Secrets: %v → %v", got, got2)
|
||||
}
|
||||
|
||||
for range 20 {
|
||||
d.keys("{") // shrink Secrets as far as it goes
|
||||
}
|
||||
if h := d.paneHeights(); h[2] != minPaneH || sum(h) != 29 {
|
||||
t.Errorf("minimum height: %v", h)
|
||||
}
|
||||
|
||||
d.keys("4", "}")
|
||||
if !strings.Contains(d.screen(), "focus a left pane") {
|
||||
t.Error("no hint when resizing from the detail pane")
|
||||
}
|
||||
|
||||
d.keys("=")
|
||||
if h := d.paneHeights(); h != start {
|
||||
t.Errorf("= didn't reset heights: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoom(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "+") // half: Secrets gets most of the column and half the width
|
||||
h := d.paneHeights()
|
||||
if h[0] != tinyPaneH || h[1] != tinyPaneH || h[2] != 29-2*tinyPaneH {
|
||||
t.Errorf("half zoom heights = %v", h)
|
||||
}
|
||||
if got := d.leftEdge(); got != 50 {
|
||||
t.Errorf("half zoom width = %d, want half of 100", got)
|
||||
}
|
||||
|
||||
d.keys("+") // full: only Secrets
|
||||
screen := d.screen()
|
||||
if !strings.Contains(screen, "[3] Secrets") || strings.Contains(screen, "[1] Sources") || strings.Contains(screen, "Secret ─") {
|
||||
t.Errorf("full zoom should show only the Secrets pane:\n%s", screen)
|
||||
}
|
||||
if w := len([]rune(strings.Split(screen, "\n")[0])); w != 100 {
|
||||
t.Errorf("full zoom width = %d", w)
|
||||
}
|
||||
|
||||
d.keys("4") // the zoom follows focus
|
||||
if !strings.Contains(d.screen(), "╭─ Secret ") || strings.Contains(d.screen(), "[3] Secrets") {
|
||||
t.Errorf("full zoom on the detail pane:\n%s", d.screen())
|
||||
}
|
||||
|
||||
d.keys("+") // wraps to normal
|
||||
if d.m.zoom != zoomNormal {
|
||||
t.Errorf("zoom = %d after three +", d.m.zoom)
|
||||
}
|
||||
d.keys("_")
|
||||
if d.m.zoom != zoomFull {
|
||||
t.Errorf("_ from normal should go to full, got %d", d.m.zoom)
|
||||
}
|
||||
d.keys("=")
|
||||
if d.m.zoom != zoomNormal {
|
||||
t.Error("= didn't reset zoom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutIsSaved(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
load := func() *store.Store {
|
||||
s, err := store.Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
d := newDriverOpts(t, Options{Store: load()})
|
||||
d.keys(">", ">", "}")
|
||||
edge, heights := d.leftEdge(), d.paneHeights()
|
||||
|
||||
d2 := newDriverOpts(t, Options{Store: load()})
|
||||
if d2.leftEdge() != edge || d2.paneHeights() != heights {
|
||||
t.Errorf("restored %d %v, want %d %v", d2.leftEdge(), d2.paneHeights(), edge, heights)
|
||||
}
|
||||
d2.keys("=")
|
||||
if l := load().Layout(); l.Split != 0 || l.Heights != [3]int{} {
|
||||
t.Errorf("reset not saved: %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextHelpBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
|
||||
t.Errorf("Sources help bar: %q", bar)
|
||||
}
|
||||
d.keys("3")
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
|
||||
t.Errorf("Secrets help bar: %q", bar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeybindingsMenu(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "?")
|
||||
if d.m.prompt == nil || d.m.prompt.title != "Keybindings" {
|
||||
t.Fatalf("? didn't open the menu:\n%s", d.screen())
|
||||
}
|
||||
// Sections: the focused pane's keys, then global ones; the cursor
|
||||
// starts on the first key, and a counter shows where it is.
|
||||
p := d.m.prompt
|
||||
if !p.options[0].header || p.options[0].label != "Secrets pane" {
|
||||
t.Errorf("first section: %+v", p.options[0])
|
||||
}
|
||||
if sel := ansi.Strip(p.options[p.cursor].label); !strings.HasPrefix(sel, "space") {
|
||||
t.Errorf("cursor starts on %q", sel)
|
||||
}
|
||||
screen := d.screen()
|
||||
if !strings.Contains(screen, "Global") || !strings.Contains(screen, "1 of ") {
|
||||
t.Errorf("no Global section or counter:\n%s", screen)
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp}) // can't go up onto the header
|
||||
if p.options[p.cursor].header {
|
||||
t.Error("cursor landed on a header")
|
||||
}
|
||||
if !strings.Contains(d.screen(), "Show or hide the selected value") {
|
||||
t.Errorf("no tip for the selected row:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// The filter matches descriptions and groups too; matches stay under
|
||||
// their section headers, and empty sections drop out.
|
||||
for _, r := range "copy" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
var headers []string
|
||||
for _, o := range d.m.prompt.options {
|
||||
if o.header {
|
||||
headers = append(headers, o.label)
|
||||
}
|
||||
}
|
||||
if strings.Join(headers, ",") != "Secrets pane,Global" {
|
||||
t.Errorf("sections while filtering: %v", headers)
|
||||
}
|
||||
if d.m.prompt.options[d.m.prompt.cursor].header {
|
||||
t.Error("cursor on a header after filtering")
|
||||
}
|
||||
var keys []string
|
||||
for _, o := range d.m.prompt.options {
|
||||
keys = append(keys, strings.Fields(ansi.Strip(o.label))[0])
|
||||
}
|
||||
for _, want := range []string{"y", "Y", "s", "ctrl+y"} {
|
||||
if !slices.Contains(keys, want) {
|
||||
t.Errorf("filter copy: %v lacks %s", keys, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter runs the selected binding.
|
||||
d.keys("esc", "?")
|
||||
for _, r := range "space" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
if sel := ansi.Strip(d.m.prompt.options[d.m.prompt.cursor].label); !strings.HasPrefix(sel, "space") {
|
||||
t.Fatalf("filter space: selected %q", sel)
|
||||
}
|
||||
d.keys("enter")
|
||||
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
|
||||
t.Errorf("running reveal from the menu didn't reveal:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPress(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, want := range map[string]string{
|
||||
"space": "space", "ctrl+y": "ctrl+y", "shift+tab": "shift+tab", "S": "S", "`": "`", "ctrl+left": "ctrl+left",
|
||||
} {
|
||||
if got := keyPress(name).String(); got != want {
|
||||
t.Errorf("keyPress(%q).String() = %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lastLine(s string) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
return lines[len(lines)-1]
|
||||
}
|
||||
|
||||
func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
green := "\x1b[32m╭" // a focused border; "current" versions are green too
|
||||
if !strings.Contains(d.m.View().Content, green) {
|
||||
t.Fatal("focused pane not highlighted")
|
||||
}
|
||||
d.send(tea.BlurMsg{})
|
||||
if strings.Contains(d.m.View().Content, green) {
|
||||
t.Error("still highlighted while the terminal is blurred")
|
||||
}
|
||||
d.send(tea.FocusMsg{})
|
||||
if !strings.Contains(d.m.View().Content, green) {
|
||||
t.Error("highlight not back after focus")
|
||||
}
|
||||
|
||||
// Popups too.
|
||||
d.keys("?")
|
||||
d.send(tea.BlurMsg{})
|
||||
if strings.Contains(d.m.modalView(), "\x1b[32m") || strings.Contains(d.m.modalView(), "\x1b[7m") {
|
||||
t.Error("popup still highlighted while the terminal is blurred")
|
||||
}
|
||||
d.send(tea.FocusMsg{})
|
||||
if !strings.Contains(d.m.modalView(), "\x1b[32m╭") {
|
||||
t.Error("popup highlight not back after focus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionInStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{Version: "0.1.0"})
|
||||
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
|
||||
t.Errorf("no version in the status pane:\n%s", d.screen())
|
||||
}
|
||||
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
|
||||
t.Errorf("help line %q", bar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,892 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// Opening references: a reference (lazysecrets:// link, Key Vault URL, …)
|
||||
// is first resolved to a source, then the panes are stepped through to it
|
||||
// as each list loads. Copy and reveal actions ask before reading the value.
|
||||
|
||||
// linksRowID is the Sources row listing vaults opened from links that no
|
||||
// account could locate (e.g. no read access to the vault resource).
|
||||
const linksRowID = "links"
|
||||
|
||||
type navStage int
|
||||
|
||||
const (
|
||||
navSource navStage = iota
|
||||
navContainer
|
||||
navObject
|
||||
navVersion
|
||||
navAction
|
||||
)
|
||||
|
||||
// nav is an open in progress.
|
||||
type nav struct {
|
||||
ref ref.Ref
|
||||
act linkAction
|
||||
// sourceOnly stops at the source (jumping to a starred subscription).
|
||||
sourceOnly bool
|
||||
|
||||
sourceRow string // Sources row to select
|
||||
stage navStage
|
||||
// quiet gives up without a word: going back to where lazysecrets
|
||||
// was left, which may be gone.
|
||||
quiet bool
|
||||
}
|
||||
|
||||
type resolvedMsg struct {
|
||||
ref ref.Ref
|
||||
act linkAction
|
||||
sourceRow string
|
||||
others []string // other accounts' rows that also found the vault
|
||||
choices []choice // several candidates: ask
|
||||
linked *provider.Container
|
||||
err error
|
||||
}
|
||||
|
||||
type choice struct {
|
||||
label string
|
||||
sourceRow string
|
||||
}
|
||||
|
||||
// linkAction is what to do with an object once it's opened. Links don't
|
||||
// carry it: whoever opens one chooses.
|
||||
type linkAction int
|
||||
|
||||
const (
|
||||
actGo linkAction = iota // just go to it
|
||||
actCopy // and copy its value
|
||||
actShow // and show its value
|
||||
)
|
||||
|
||||
// offerRef asks what to do with a reference: for an object, go to it, or
|
||||
// go to it and copy or show its value; for a subscription, vault or scope,
|
||||
// just open it.
|
||||
// With ask false, vault and scope links open straight away.
|
||||
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
|
||||
if r.IsContainer() || r.IsSource() {
|
||||
if !ask {
|
||||
return m.openRef(r, actGo)
|
||||
}
|
||||
m.showMenu(title, nil, []option{
|
||||
{key: "o", label: "Open it", run: func(m *Model) tea.Cmd { return m.openRef(r, actGo) }},
|
||||
})
|
||||
m.prompt.about = &r
|
||||
return nil
|
||||
}
|
||||
do := func(a linkAction) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd { return m.openRef(r, a) }
|
||||
}
|
||||
opts := []option{
|
||||
{key: "o", label: "Go to it", run: do(actGo)},
|
||||
{key: "c", label: "Go to it and copy its value", run: do(actCopy)},
|
||||
{key: "r", label: "Go to it and show its value", run: do(actShow)},
|
||||
}
|
||||
if r.Kind == provider.KindAzure || r.Host != "" { // {{secrets/s/k}} names no workspace
|
||||
opts = append(opts, option{key: "y", label: "Copy its value, staying here", run: func(m *Model) tea.Cmd { return m.copyFromRef(r) }})
|
||||
}
|
||||
m.showMenu(title, nil, opts)
|
||||
m.prompt.about = &r
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFromRef copies the value a link points at without going there. The
|
||||
// link says enough to read it: the vault and name (Azure, tried with each
|
||||
// account, the one that last read it first) or the workspace, scope and
|
||||
// key (Databricks).
|
||||
func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
|
||||
type try struct {
|
||||
p provider.Provider
|
||||
o provider.Object
|
||||
}
|
||||
var tries []try
|
||||
key := r
|
||||
key.Version = ""
|
||||
node := m.store.Node(key.String())
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
|
||||
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: node.Source}
|
||||
if len(node.Path) > 0 {
|
||||
src.Name = node.Path[0]
|
||||
}
|
||||
if r.Kind != provider.KindAzure { // by host: Databricks workspaces, repos
|
||||
o.Type = provider.TypeSecret
|
||||
src.ID = ""
|
||||
for _, s := range m.sourceResults[p.ID()].sources {
|
||||
if sourceMatches(r, s) {
|
||||
src = s
|
||||
}
|
||||
}
|
||||
if src.ID == "" {
|
||||
continue
|
||||
}
|
||||
o.Container = provider.Container{Source: src, Name: r.Scope}
|
||||
} else {
|
||||
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
|
||||
}
|
||||
t := try{p, o}
|
||||
if p.ID() == node.Account {
|
||||
tries = append([]try{t}, tries...)
|
||||
} else {
|
||||
tries = append(tries, t)
|
||||
}
|
||||
}
|
||||
if len(tries) == 0 {
|
||||
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
|
||||
return nil
|
||||
}
|
||||
ctx, req := m.begin(slotCopy)
|
||||
m.flash = flash{text: "copying " + r.Name + "…"}
|
||||
return func() tea.Msg {
|
||||
var errs []error
|
||||
for _, t := range tries {
|
||||
v, err := t.p.Value(ctx, t.o)
|
||||
if err == nil {
|
||||
msg := copyMsg{req: req, name: r.Name, value: v}
|
||||
if t.o.Container.Source.ID != "" {
|
||||
msg.obj = t.o // where it is is known: count the use
|
||||
}
|
||||
return msg
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return copyMsg{req: req, name: r.Name, err: err}
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
|
||||
}
|
||||
if len(errs) == 1 {
|
||||
return copyMsg{req: req, name: r.Name, err: errors.Unwrap(errs[0])} // one account: no need to name it
|
||||
}
|
||||
return copyMsg{req: req, name: r.Name, err: errors.Join(errs...)}
|
||||
}
|
||||
}
|
||||
|
||||
// openRef starts opening a reference, doing act once there.
|
||||
func (m *Model) openRef(r ref.Ref, act linkAction) tea.Cmd {
|
||||
m.nav = nil
|
||||
if r.IsSource() {
|
||||
m.openSource(r)
|
||||
return nil
|
||||
}
|
||||
m.flash = flash{text: "opening " + describeRef(r) + "…"}
|
||||
cands := m.providers
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultResolveTimeout)
|
||||
defer cancel()
|
||||
msg := resolve(ctx, cands, r)
|
||||
msg.act = act
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
const defaultResolveTimeout = 30 * time.Second
|
||||
|
||||
// openSource goes to a subscription or workspace, asking which account
|
||||
// when several list it. Sources are loaded already, so it needs no lookup.
|
||||
func (m *Model) openSource(r ref.Ref) {
|
||||
var choices []choice
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
if sourceMatches(r, src) {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
}
|
||||
}
|
||||
switch len(choices) {
|
||||
case 0:
|
||||
m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true}
|
||||
case 1:
|
||||
m.nav = &nav{ref: r, sourceRow: choices[0].sourceRow, sourceOnly: true}
|
||||
default:
|
||||
var opts []option
|
||||
for i, c := range choices {
|
||||
row := c.sourceRow
|
||||
opts = append(opts, option{key: fmt.Sprint(i + 1), label: c.label, run: func(m *Model) tea.Cmd {
|
||||
m.nav = &nav{ref: r, sourceRow: row, sourceOnly: true}
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
m.showMenu("Several accounts can see "+describeRef(r), nil, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// refLabel draws what a link points at like the favourites list: the
|
||||
// provider's icon and the path in its colour, " platform-prod /
|
||||
// kv-app-prod / api-key", with the type and version after it. The
|
||||
// subscription or workspace isn't in the link: it comes from what's
|
||||
// remembered about the item, or from lists already loaded.
|
||||
func (m Model) refLabel(r ref.Ref) string {
|
||||
var path []string
|
||||
if src := m.refSource(r); src != "" {
|
||||
path = append(path, src)
|
||||
}
|
||||
switch {
|
||||
case r.IsSource():
|
||||
if len(path) == 0 {
|
||||
path = append(path, cmp.Or(r.Subscription, r.Host))
|
||||
}
|
||||
case r.IsContainer():
|
||||
path = append(path, r.ContainerName())
|
||||
default:
|
||||
path = append(path, r.ContainerName(), r.Name)
|
||||
}
|
||||
label := m.pathLabel(r.Kind, path, "")
|
||||
var extra []string
|
||||
if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret {
|
||||
if r.IsContainer() {
|
||||
extra = append(extra, t+" tab")
|
||||
} else {
|
||||
extra = append(extra, strings.TrimSuffix(t, "s")) // "key", "certificate"
|
||||
}
|
||||
}
|
||||
if r.Version != "" {
|
||||
extra = append(extra, "version "+r.Version)
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
label += m.st.Muted.Render(" · " + strings.Join(extra, " · "))
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// refSource names the subscription or workspace r is in, if known.
|
||||
func (m Model) refSource(r ref.Ref) string {
|
||||
key := r
|
||||
key.Version = ""
|
||||
if n := m.store.Node(key.String()); len(n.Path) > 0 {
|
||||
return n.Path[0]
|
||||
}
|
||||
for _, res := range m.sourceResults {
|
||||
for _, src := range res.sources {
|
||||
if (r.Kind != provider.KindAzure || r.IsSource()) && src.Kind == r.Kind && sourceMatches(r, src) {
|
||||
return src.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.Kind == provider.KindAzure && !r.IsSource() {
|
||||
for _, e := range m.cache.containers {
|
||||
for _, c := range e.value {
|
||||
if matchesContainer(c, r) {
|
||||
return c.Source.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func describeRef(r ref.Ref) string {
|
||||
if r.IsSource() {
|
||||
switch r.Kind {
|
||||
case provider.KindDatabricks:
|
||||
return "workspace " + r.Host
|
||||
case provider.KindRepo:
|
||||
return "repo " + r.Host
|
||||
}
|
||||
return "subscription " + r.Subscription
|
||||
}
|
||||
if r.IsContainer() {
|
||||
return r.ContainerName()
|
||||
}
|
||||
return r.Name + " in " + r.ContainerName()
|
||||
}
|
||||
|
||||
// resolve finds the source holding the reference's container.
|
||||
func resolve(ctx context.Context, providers []provider.Provider, r ref.Ref) resolvedMsg {
|
||||
msg := resolvedMsg{ref: r}
|
||||
var errs []error
|
||||
switch {
|
||||
case r.Kind == provider.KindDatabricks && r.Host == "":
|
||||
// {{secrets/scope/key}}: search every workspace for the scope.
|
||||
for _, p := range providers {
|
||||
if p.Kind() != provider.KindDatabricks {
|
||||
continue
|
||||
}
|
||||
srcs, err := p.Sources(ctx)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
for _, src := range srcs {
|
||||
cs, err := p.Containers(ctx, src)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", src.Name, err))
|
||||
continue
|
||||
}
|
||||
for _, c := range cs {
|
||||
if c.Name == r.Scope {
|
||||
msg.choices = append(msg.choices, choice{label: src.Name, sourceRow: sourceRowKey(p.ID(), src.ID)})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch len(msg.choices) {
|
||||
case 0:
|
||||
msg.err = errors.Join(append([]error{fmt.Errorf("scope %q isn't in any workspace", r.Scope)}, errs...)...)
|
||||
case 1:
|
||||
msg.sourceRow, msg.choices = msg.choices[0].sourceRow, nil
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
for _, p := range providers {
|
||||
loc, ok := p.(provider.Locator)
|
||||
if !ok || p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
id, found, err := loc.Locate(ctx, r.Host)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", p.Label(), err))
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
// Several accounts may see it, with different access: the
|
||||
// user picks.
|
||||
row := sourceRowKey(p.ID(), id)
|
||||
if msg.sourceRow == "" {
|
||||
msg.sourceRow = row
|
||||
} else {
|
||||
msg.others = append(msg.others, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
if msg.sourceRow != "" {
|
||||
return msg
|
||||
}
|
||||
if r.Kind == provider.KindAzure {
|
||||
// No account can find the vault resource, but one may still read
|
||||
// it: open it under "Opened links", where each account is tried.
|
||||
msg.linked = &provider.Container{
|
||||
Source: provider.Source{Kind: provider.KindAzure, Name: r.Host},
|
||||
ID: r.VaultURL(), Name: r.ContainerName(), Detail: r.VaultURL(),
|
||||
Types: []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate},
|
||||
}
|
||||
return msg
|
||||
}
|
||||
missing := fmt.Errorf("no Databricks profile for workspace %s", r.Host)
|
||||
if r.Kind == provider.KindRepo {
|
||||
missing = fmt.Errorf("repo %s isn't here: start lazysecrets in it, or list it under providers.repos.paths", r.Host)
|
||||
}
|
||||
msg.err = errors.Join(append([]error{missing}, errs...)...)
|
||||
return msg
|
||||
}
|
||||
|
||||
func (m *Model) handleResolved(msg resolvedMsg) {
|
||||
switch {
|
||||
case msg.err != nil:
|
||||
m.flash = flash{text: "open: " + msg.err.Error(), isErr: true}
|
||||
case len(msg.choices) > 1:
|
||||
for i := range msg.choices {
|
||||
msg.choices[i].label = "Open in " + msg.choices[i].label
|
||||
}
|
||||
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices)
|
||||
case len(msg.others) > 0:
|
||||
var choices []choice
|
||||
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices)
|
||||
case msg.linked != nil:
|
||||
m.addLinked(*msg.linked)
|
||||
m.startNav(msg.ref, msg.act, linksRowID)
|
||||
default:
|
||||
m.startNav(msg.ref, msg.act, msg.sourceRow)
|
||||
}
|
||||
}
|
||||
|
||||
// chooseSource asks where to open r.
|
||||
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice) {
|
||||
var opts []option
|
||||
for i, c := range choices {
|
||||
key := ""
|
||||
if i < 9 {
|
||||
key = fmt.Sprint(i + 1)
|
||||
}
|
||||
row := c.sourceRow
|
||||
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
|
||||
m.startNav(r, act, row)
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
m.showMenu(title, nil, opts)
|
||||
}
|
||||
|
||||
// sourceLabel names a Sources row as "Azure · admin / app-dev".
|
||||
func (m Model) sourceLabel(row string) string {
|
||||
providerID, sourceID := splitSourceRow(row)
|
||||
label := providerID
|
||||
if p, ok := m.byID[providerID]; ok {
|
||||
label = p.Label()
|
||||
}
|
||||
for _, s := range m.sourceResults[providerID].sources {
|
||||
if s.ID == sourceID {
|
||||
return label + " / " + s.Name
|
||||
}
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func (m *Model) startNav(r ref.Ref, act linkAction, sourceRow string) {
|
||||
m.nav = &nav{ref: r, act: act, sourceRow: sourceRow}
|
||||
m.versionsOf = nil
|
||||
m.flash = flash{}
|
||||
}
|
||||
|
||||
// addLinked lists a vault under "Opened links" for this session.
|
||||
func (m *Model) addLinked(c provider.Container) {
|
||||
for _, l := range m.linked {
|
||||
if l.ID == c.ID {
|
||||
return
|
||||
}
|
||||
}
|
||||
m.linked = append(m.linked, c)
|
||||
m.rebuildSources()
|
||||
}
|
||||
|
||||
func (m *Model) navFail(format string, a ...any) {
|
||||
if !m.nav.quiet {
|
||||
m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true}
|
||||
}
|
||||
m.nav = nil
|
||||
}
|
||||
|
||||
// advanceNav moves an open along as far as the loaded lists allow. It
|
||||
// reports whether it got further, so the caller can sync and try again.
|
||||
func (m *Model) advanceNav() bool {
|
||||
n := m.nav
|
||||
if n == nil {
|
||||
return false
|
||||
}
|
||||
r := n.ref
|
||||
switch n.stage {
|
||||
case navSource:
|
||||
if !m.sources.Select(n.sourceRow) {
|
||||
group, _ := splitSourceRow(n.sourceRow)
|
||||
if m.collapsed[group] {
|
||||
m.collapsed[group] = false
|
||||
m.rebuildSources()
|
||||
return true
|
||||
}
|
||||
if m.sourcesPending > 0 {
|
||||
return false // still loading
|
||||
}
|
||||
m.navFail("%s isn't in the Sources list (hidden in the config?)", describeRef(r))
|
||||
return false
|
||||
}
|
||||
m.sourcesTouched = true
|
||||
m.focus = paneContainers
|
||||
if n.sourceOnly {
|
||||
m.nav = nil
|
||||
return true
|
||||
}
|
||||
n.stage = navContainer
|
||||
return true
|
||||
|
||||
case navContainer:
|
||||
if m.selSource != n.sourceRow || m.containers.Loading {
|
||||
return false
|
||||
}
|
||||
if !m.containers.SelectWhere(func(c provider.Container) bool { return matchesContainer(c, r) }) {
|
||||
m.navFail("%s not found", r.ContainerName())
|
||||
return false
|
||||
}
|
||||
if r.Type != "" {
|
||||
m.wantType = r.Type
|
||||
}
|
||||
if !r.IsContainer() {
|
||||
m.focus = paneSecrets
|
||||
}
|
||||
n.stage = navObject
|
||||
return true
|
||||
|
||||
case navObject:
|
||||
c, _ := m.containers.Selected()
|
||||
if (r.Type != "" && m.objType != r.Type) || m.secrets.Loading ||
|
||||
!strings.HasPrefix(m.selContainer, containerKey(c)+"|") {
|
||||
return false
|
||||
}
|
||||
if m.secrets.Err != nil {
|
||||
m.nav = nil // the pane shows the error
|
||||
return false
|
||||
}
|
||||
// Reached it, and its account can read it: remember that.
|
||||
m.refreshContext(storeKey(c), c.Name, []string{c.Source.Name}, c.Source)
|
||||
if r.IsContainer() {
|
||||
m.nav = nil
|
||||
return true
|
||||
}
|
||||
if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Name == r.Name }) {
|
||||
m.navFail("%s not found in %s", r.Name, r.ContainerName())
|
||||
return false
|
||||
}
|
||||
if o, ok := m.secrets.Selected(); ok {
|
||||
m.refreshContext(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source)
|
||||
}
|
||||
if r.Version != "" {
|
||||
o, _ := m.secrets.Selected()
|
||||
m.versionsOf = &o
|
||||
n.stage = navVersion
|
||||
return true
|
||||
}
|
||||
n.stage = navAction
|
||||
return true
|
||||
|
||||
case navVersion:
|
||||
if m.versionsOf == nil || m.secrets.Loading || m.secrets.Err != nil {
|
||||
if m.secrets.Err != nil {
|
||||
m.nav = nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Version == r.Version }) {
|
||||
m.flash = flash{text: "version " + r.Version + " not found; showing all versions", isErr: true}
|
||||
}
|
||||
n.stage = navAction
|
||||
return true
|
||||
|
||||
case navAction:
|
||||
// The user chose this when opening the link; no need to ask again.
|
||||
m.nav = nil
|
||||
switch n.act {
|
||||
case actCopy:
|
||||
m.pendingCmd = m.copyValue()
|
||||
case actShow:
|
||||
if m.detail.State() != panes.ValueShown {
|
||||
m.pendingCmd = m.toggleReveal()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesContainer compares by vault URL (Azure), or scope or file name.
|
||||
func matchesContainer(c provider.Container, r ref.Ref) bool {
|
||||
if r.Kind != provider.KindAzure {
|
||||
return c.Name == r.Scope
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSuffix(c.Detail, "/")+"/", r.VaultURL())
|
||||
}
|
||||
|
||||
// share copies a link to the focused item. With menu, it offers the
|
||||
// other things worth sharing about it.
|
||||
func (m *Model) share(menu bool) tea.Cmd {
|
||||
switch m.focus {
|
||||
case paneSources:
|
||||
s, ok := m.selectedSource()
|
||||
if !ok {
|
||||
m.flash = warning("select a subscription, workspace, vault or secret to share")
|
||||
return nil
|
||||
}
|
||||
if !menu {
|
||||
return m.shareSource(s, "o")
|
||||
}
|
||||
share := func(k string) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd { return m.shareSource(s, k) }
|
||||
}
|
||||
opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
|
||||
if s.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
|
||||
}
|
||||
m.showMenu("Share "+s.Name, nil, opts)
|
||||
case paneContainers:
|
||||
c, ok := m.containers.Selected()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !menu {
|
||||
return m.shareContainer(c, "o")
|
||||
}
|
||||
share := func(k string) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
|
||||
}
|
||||
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}}
|
||||
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
|
||||
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
|
||||
}
|
||||
if c.Source.Kind == provider.KindAzure {
|
||||
opts = append(opts, option{key: "u", label: "Vault URL", run: share("u")})
|
||||
}
|
||||
if c.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
|
||||
}
|
||||
m.showMenu("Share "+c.Name, nil, opts)
|
||||
default:
|
||||
o, ok := m.detail.Secret()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !menu {
|
||||
return m.shareObject(o, "o")
|
||||
}
|
||||
share := func(k string) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
|
||||
}
|
||||
opts := []option{
|
||||
option{key: "o", label: "Lazysecrets link", run: share("o")},
|
||||
}
|
||||
if o.Container.Source.Kind == provider.KindAzure {
|
||||
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
|
||||
}
|
||||
if o.Reference != "" {
|
||||
opts = append(opts, option{key: "y", label: "Config reference", run: share("y")})
|
||||
}
|
||||
if o.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
|
||||
}
|
||||
m.showMenu("Share "+o.Name, nil, opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// webLabel names the web page o (open in browser) opens.
|
||||
func webLabel(kind provider.Kind) string {
|
||||
switch kind {
|
||||
case provider.KindDatabricks:
|
||||
return "Web page (Databricks workspace)"
|
||||
case provider.KindRepo:
|
||||
return "Web page (repository)"
|
||||
}
|
||||
return "Web page (Azure portal)"
|
||||
}
|
||||
|
||||
// shareSource copies what the share menu's key k stands for, for a
|
||||
// subscription or workspace.
|
||||
func (m *Model) shareSource(s provider.Source, k string) tea.Cmd {
|
||||
m.usedSource(s)
|
||||
if k == "b" {
|
||||
if s.URL == "" {
|
||||
return nil
|
||||
}
|
||||
return m.copyText(s.Name+" web page", s.URL, false)
|
||||
}
|
||||
r, err := ref.ForSource(s)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
return m.copyText("link to "+s.Name, r.String(), false)
|
||||
}
|
||||
|
||||
// shareContainer copies what the share menu's key k stands for, for a
|
||||
// vault or scope.
|
||||
func (m *Model) shareContainer(c provider.Container, k string) tea.Cmd {
|
||||
m.usedContainer(c)
|
||||
tab := provider.TypeSecret
|
||||
switch k {
|
||||
case "b":
|
||||
if c.URL == "" {
|
||||
return nil
|
||||
}
|
||||
return m.copyText(c.Name+" web page", c.URL, false)
|
||||
case "t":
|
||||
tab = m.objType
|
||||
case "o", "u":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
r, err := ref.ForContainer(c, tab)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
if k == "u" {
|
||||
if u := r.IdentifierURL(); u != "" {
|
||||
return m.copyText(c.Name+" URL", u, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return m.copyText("link to "+c.Name, r.String(), false)
|
||||
}
|
||||
|
||||
// shareObject copies what the share menu's key k stands for.
|
||||
func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
|
||||
m.usedObject(o)
|
||||
if k == "b" {
|
||||
if o.URL == "" {
|
||||
return nil
|
||||
}
|
||||
return m.copyText(o.Name+" web page", o.URL, false)
|
||||
}
|
||||
if k == "y" {
|
||||
if o.Reference == "" {
|
||||
return nil
|
||||
}
|
||||
return m.copyText(o.Name+" reference", o.Reference, false)
|
||||
}
|
||||
version := ""
|
||||
if m.versionsOf != nil {
|
||||
version = o.Version // shared from the versions view: that version
|
||||
}
|
||||
r, err := ref.ForObject(o, version)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
switch k {
|
||||
case "u":
|
||||
if u := r.IdentifierURL(); u != "" {
|
||||
return m.copyText(o.Name+" URL", u, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return m.copyText("link to "+o.Name, r.String(), false)
|
||||
}
|
||||
|
||||
func (m *Model) startOpenPrompt() tea.Cmd {
|
||||
in := textinput.New()
|
||||
in.Prompt = "open: "
|
||||
in.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
|
||||
in.SetWidth(max(20, min(m.w-12, 64)))
|
||||
m.prompt = &modal{
|
||||
title: "Open a link", input: &in,
|
||||
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
|
||||
submit: func(m *Model, text string) tea.Cmd { return m.openText(text) },
|
||||
}
|
||||
// Pre-fill from the clipboard, read now: it may hold any reference,
|
||||
// including ones not offered in the footer (our own, dismissed ones).
|
||||
clip := m.clip
|
||||
readClip := func() tea.Msg {
|
||||
text, err := clip.Read()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if _, err := ref.Parse(text); err != nil || strings.ContainsAny(text, "\n") {
|
||||
return nil // not a reference: don't keep it
|
||||
}
|
||||
return openPromptFillMsg(text)
|
||||
}
|
||||
return tea.Batch(m.prompt.input.Focus(), readClip)
|
||||
}
|
||||
|
||||
type openPromptFillMsg string
|
||||
|
||||
// fillOpenPrompt puts a reference from the clipboard in the open prompt,
|
||||
// unless the user has started typing.
|
||||
func (m *Model) fillOpenPrompt(text string) {
|
||||
if p := m.prompt; p != nil && p.input != nil && p.input.Value() == "" {
|
||||
p.input.SetValue(text)
|
||||
p.input.CursorEnd()
|
||||
}
|
||||
}
|
||||
|
||||
// openText parses a pasted or typed reference and asks what to do with it.
|
||||
func (m *Model) openText(s string) tea.Cmd {
|
||||
r, err := ref.Parse(s)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
return m.offerRef("Open link", r, false)
|
||||
}
|
||||
|
||||
// handlePaste opens a pasted reference, unless a text input has focus.
|
||||
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
|
||||
if p := m.prompt; p != nil && p.input != nil {
|
||||
in, cmd := p.input.Update(msg)
|
||||
p.input = &in
|
||||
return cmd
|
||||
}
|
||||
if l := m.focusedFilter(); l != nil && l.Filtering() {
|
||||
return l.UpdateFilter(msg)
|
||||
}
|
||||
if m.prompt != nil {
|
||||
return nil
|
||||
}
|
||||
return m.openText(msg.Content)
|
||||
}
|
||||
|
||||
type clipboardTextMsg string
|
||||
|
||||
// clipCheckEvery throttles the clipboard check on key presses, which
|
||||
// covers terminals that don't report focus.
|
||||
const clipCheckEvery = 3 * time.Second
|
||||
|
||||
// maybeCheckClipboard checks the clipboard if it hasn't been for a while.
|
||||
func (m *Model) maybeCheckClipboard() tea.Cmd {
|
||||
if now := m.now(); now.Sub(m.clipChecked) >= clipCheckEvery {
|
||||
m.clipChecked = now
|
||||
return m.checkClipboard()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkClipboard reads the clipboard in the background to look for a
|
||||
// lazysecrets:// link. Only such links are kept; anything else (it may well
|
||||
// be a password) is dropped as soon as it has been looked at.
|
||||
func (m Model) checkClipboard() tea.Cmd {
|
||||
clip := m.clip
|
||||
return func() tea.Msg {
|
||||
text, err := clip.Read()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if !isLink(text) {
|
||||
return clipboardTextMsg("")
|
||||
}
|
||||
return clipboardTextMsg(text)
|
||||
}
|
||||
}
|
||||
|
||||
// isLink reports whether s looks like a lazysecrets:// link, valid or not.
|
||||
func isLink(s string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(s), ref.Scheme+"://")
|
||||
}
|
||||
|
||||
// offerClipboardLink pops up a link found on the clipboard, once: whatever
|
||||
// the user picks, that link isn't offered again. It isn't offered while
|
||||
// another popup is open, or if it points at what's selected already (e.g.
|
||||
// just shared with s). A lazysecrets:// link that doesn't parse is
|
||||
// reported, so a typo doesn't fail silently.
|
||||
func (m *Model) offerClipboardLink(text string) {
|
||||
if text == "" || text == m.dismissedLink || m.prompt != nil || m.search != nil {
|
||||
return
|
||||
}
|
||||
r, err := ref.Parse(text)
|
||||
if err != nil {
|
||||
m.dismissedLink = text
|
||||
m.showMenu("Link on clipboard can't be read", []string{m.st.Error.Render(err.Error())}, nil)
|
||||
return
|
||||
}
|
||||
if m.isSelected(r) {
|
||||
return
|
||||
}
|
||||
m.dismissedLink = text
|
||||
m.offerRef("Link on clipboard", r, true)
|
||||
}
|
||||
|
||||
// isSelected reports whether r points at the selected container, or the
|
||||
// selected object in it.
|
||||
func (m Model) isSelected(r ref.Ref) bool {
|
||||
c, ok := m.containers.Selected()
|
||||
if !ok || c.Source.Kind != r.Kind || !matchesContainer(c, r) {
|
||||
return false
|
||||
}
|
||||
if r.IsContainer() {
|
||||
return true
|
||||
}
|
||||
o, ok := m.detail.Secret()
|
||||
return ok && o.Name == r.Name && (r.Type == "" || o.Type == r.Type) &&
|
||||
(r.Version == "" || o.Version == r.Version)
|
||||
}
|
||||
@@ -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 main prod"} {
|
||||
if !strings.Contains(screen, want) {
|
||||
t.Errorf("missing %q:\n%s", want, screen)
|
||||
}
|
||||
}
|
||||
|
||||
// The filter matches notes.
|
||||
d.keys("3", "/")
|
||||
d.typeText("stripe")
|
||||
if _, _, s := d.selected(); s != "api-key" || d.m.secrets.Len() != 1 {
|
||||
t.Errorf("filter by note: %q (%d)", s, d.m.secrets.Len())
|
||||
}
|
||||
d.keys("esc")
|
||||
|
||||
// Saved, and an empty note removes it.
|
||||
again, _ := store.Load(path)
|
||||
notes := 0
|
||||
for _, n := range again.Nodes() {
|
||||
if n.Note != "" {
|
||||
notes++
|
||||
}
|
||||
}
|
||||
if notes != 3 {
|
||||
t.Errorf("saved notes = %v", again.Nodes())
|
||||
}
|
||||
d.keys("3")
|
||||
d.note("")
|
||||
if strings.Contains(d.screen(), "stripe live") {
|
||||
t.Error("empty note didn't remove it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNoteGivesWay(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := styles.Default()
|
||||
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 ||
|
||||
!strings.HasSuffix(stripANSI(got), "prod SQL admin") {
|
||||
t.Errorf("fits: %q", stripANSI(got))
|
||||
}
|
||||
if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
|
||||
!strings.HasSuffix(got, "…") {
|
||||
t.Errorf("tight: %q", got)
|
||||
}
|
||||
if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
|
||||
t.Errorf("no room: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func stripANSI(s string) string { return ansi.Strip(s) }
|
||||
@@ -0,0 +1,248 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// What was loaded is kept between runs in a cache file (Options.CacheFile):
|
||||
// the Sources lists, vaults, secrets' metadata and where the cursor was in
|
||||
// each. Starting again shows it at once, and lists older than cacheTTL
|
||||
// reload in the background as usual. Values are never in it; neither are
|
||||
// repos, whose files are read from disk anyway.
|
||||
//
|
||||
// And where lazysecrets was left, per folder it ran in, goes in the store,
|
||||
// to go back there on the next start.
|
||||
|
||||
// cacheFileVersion is the file's format; a file of another version is
|
||||
// ignored, and replaced on the next save.
|
||||
const cacheFileVersion = 1
|
||||
|
||||
// cacheMaxAge drops what hasn't been loaded again for this long.
|
||||
const cacheMaxAge = 30 * 24 * time.Hour
|
||||
|
||||
// persistEvery is how often a changed cache is saved while running, so
|
||||
// little is lost if lazysecrets is killed rather than quit.
|
||||
const persistEvery = 30 * time.Second
|
||||
|
||||
type cacheFile struct {
|
||||
Version int `json:"version"`
|
||||
Sources map[string]stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
|
||||
Containers map[string]stamped[[]provider.Container] `json:"containers,omitempty"`
|
||||
Objects map[string]stamped[[]provider.Object] `json:"objects,omitempty"`
|
||||
Details map[string]stamped[provider.Object] `json:"details,omitempty"`
|
||||
Cursor map[string]string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
type stamped[T any] struct {
|
||||
At time.Time `json:"at"`
|
||||
Value T `json:"value"`
|
||||
}
|
||||
|
||||
type persistTickMsg struct{}
|
||||
|
||||
type persistedMsg struct{ err error }
|
||||
|
||||
// keepCached reports whether a cache key's account still exists and
|
||||
// isn't a repo: every key starts with the provider ID.
|
||||
func (m *Model) keepCached(key string) bool {
|
||||
id, _ := splitSourceRow(key)
|
||||
p, ok := m.byID[id]
|
||||
return ok && p.Kind() != provider.KindRepo
|
||||
}
|
||||
|
||||
// loadCacheFile fills the cache from the file, leaving out what's too
|
||||
// old or belongs to accounts that aren't signed in any more.
|
||||
func (m *Model) loadCacheFile() {
|
||||
b, err := os.ReadFile(m.cachePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var f cacheFile
|
||||
if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion {
|
||||
return
|
||||
}
|
||||
now := m.now()
|
||||
recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge }
|
||||
for id, e := range f.Sources {
|
||||
if m.keepCached(id+"|") && recent(e.At) {
|
||||
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
|
||||
m.sourcesAt[id] = e.At
|
||||
}
|
||||
}
|
||||
for k, e := range f.Containers {
|
||||
if m.keepCached(k) && recent(e.At) {
|
||||
m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
|
||||
}
|
||||
}
|
||||
for k, e := range f.Objects {
|
||||
if m.keepCached(k) && recent(e.At) {
|
||||
m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At}
|
||||
}
|
||||
}
|
||||
for k, e := range f.Details {
|
||||
if m.keepCached(k) && recent(e.At) {
|
||||
m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
|
||||
}
|
||||
}
|
||||
for k, v := range f.Cursor {
|
||||
if m.keepCached(k) {
|
||||
m.cache.cursor[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cacheSnapshot is the cache as the file holds it.
|
||||
func (m *Model) cacheSnapshot() cacheFile {
|
||||
f := cacheFile{
|
||||
Version: cacheFileVersion,
|
||||
Sources: map[string]stamped[[]provider.Source]{},
|
||||
Containers: map[string]stamped[[]provider.Container]{},
|
||||
Objects: map[string]stamped[[]provider.Object]{},
|
||||
Details: map[string]stamped[provider.Object]{},
|
||||
Cursor: map[string]string{},
|
||||
}
|
||||
for id, r := range m.sourceResults {
|
||||
if r.err == nil && m.keepCached(id+"|") {
|
||||
f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.containers {
|
||||
if m.keepCached(k) {
|
||||
f.Containers[k] = stamped[[]provider.Container]{e.at, e.value}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.objects {
|
||||
if m.keepCached(k) {
|
||||
f.Objects[k] = stamped[[]provider.Object]{e.at, e.value}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.details {
|
||||
if m.keepCached(k) {
|
||||
f.Details[k] = stamped[provider.Object]{e.at, e.value}
|
||||
}
|
||||
}
|
||||
for k, v := range m.cache.cursor {
|
||||
if m.keepCached(k) {
|
||||
f.Cursor[k] = v
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// writeCacheFile writes f atomically, readable by the user only.
|
||||
func writeCacheFile(path string, f cacheFile) error {
|
||||
b, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
// persistTick saves the cache, and where lazysecrets is, in the
|
||||
// background when they changed.
|
||||
func (m *Model) persistTick() tea.Cmd {
|
||||
cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })}
|
||||
if m.cacheDirty && m.cachePath != "" {
|
||||
m.cacheDirty = false
|
||||
f, path := m.cacheSnapshot(), m.cachePath
|
||||
cmds = append(cmds, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} })
|
||||
}
|
||||
if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace {
|
||||
m.savedPlace = p.Link
|
||||
st, dir := m.store, m.placeDir
|
||||
cmds = append(cmds, func() tea.Msg { return persistedMsg{st.SetPlace(dir, p)} })
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// Persist saves the cache and where lazysecrets was left; run it after
|
||||
// the program ends.
|
||||
func (m Model) Persist() error {
|
||||
var errs []error
|
||||
if m.cachePath != "" {
|
||||
errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot()))
|
||||
}
|
||||
if m.placeDir != "" {
|
||||
if p, ok := m.place(); ok {
|
||||
errs = append(errs, m.store.SetPlace(m.placeDir, p))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// place is the selected item, as deep as the focus.
|
||||
func (m *Model) place() (store.Place, bool) {
|
||||
src, ok := m.selectedSource()
|
||||
if !ok || m.nav != nil {
|
||||
return store.Place{}, false
|
||||
}
|
||||
var r ref.Ref
|
||||
var err error
|
||||
switch c, cok := m.containers.Selected(); {
|
||||
case m.focus == paneSources || !cok:
|
||||
r, err = ref.ForSource(src)
|
||||
case m.focus == paneContainers:
|
||||
r, err = ref.ForContainer(c, m.objType)
|
||||
default:
|
||||
o, ook := m.detail.Secret()
|
||||
if !ook {
|
||||
r, err = ref.ForContainer(c, m.objType)
|
||||
} else {
|
||||
r, err = ref.ForObject(o, "")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return store.Place{}, false
|
||||
}
|
||||
return store.Place{Link: r.String(), Account: src.Provider, Source: src.ID, At: m.now()}, true
|
||||
}
|
||||
|
||||
// restorePlace goes back to where lazysecrets was left in dir, if that
|
||||
// account is still signed in.
|
||||
func (m *Model) restorePlace() {
|
||||
p, ok := m.store.Place(m.placeDir)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := m.byID[p.Account]; !ok {
|
||||
return
|
||||
}
|
||||
r, err := ref.Parse(p.Link)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.startNav(r, actGo, sourceRowKey(p.Account, p.Source))
|
||||
m.nav.sourceOnly = r.IsSource()
|
||||
m.nav.quiet = true
|
||||
}
|
||||
|
||||
func (m *Model) handlePersisted(msg persistedMsg) {
|
||||
if msg.err != nil {
|
||||
m.logErrOf("saving the cache or place", msg.err)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user