22 Commits
Author SHA1 Message Date
keb 87141cb53b chore(release): v0.1.1
Release / Build and publish (push) Successful in 1m13s
2026-10-13 14:57:31 +02:00
keb e9366faa9b chore: the changelog shows #55's key as a key (#56)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 14:57:24 +02:00
Karl-Einar Bergstrøm 4377eb98bf feat: share env files as encrypted links (#54)
Share `.env` files as an encrypted `lazysecrets://share/…` link. The files are encrypted with a secret's value, so anyone who can read that secret can open the link.

**Sharing:** `S` → `f` on a repo (all its plain env files), an env file, or a key in one (its file).
- **The key** is `share.key` from the repo's `.lazysecrets.yaml`, else from the config. It can be a `lazysecrets://` link, a Key Vault URL or an `@Microsoft.KeyVault(...)` reference.
- **Without `share.key`,** a picker built on Go to opens, titled "Share .env", with "Pick a secret to encrypt with" in its own border. It lists only secrets, leaving out values from plain `.env` files. Pasting a reference picks it. Picking shares at once, and the picker stays open until the link is copied. If the secret can't be used, the reason shows in red in the picker: it can't be read, or it doesn't meet `share.keyRules` (which rule failed is named). `esc` cancels.
- **`share.keyRules`** (defaults `minLength: 32`, `minClasses: 2`; never under 16) applies when sharing, not when opening.
- **On Azure,** the link pins the key's version, so rotating the key doesn't break old links, and disabling a version revokes them.
- **The command line:** `lazysecrets share [--key <secret>] <env files>`.

**Opening** works by paste, `ctrl+o`, the clipboard offer, the link handler or `lazysecrets <link>`.
1. It first says which repo the link is from, how many files it holds and which key it uses, and decrypts only after `o`.
2. It saves into the repo's clone, found by its remote, showing which files are new, which would replace one there, and which git doesn't ignore.
3. Without a clone, it asks for a folder and saves straight away. The prompt stays open, with the reason in red, until the files are written.
4. If decryption fails, a popup says why: the first line of the reason, `r` for the whole message, `g` to go to the key's secret. A key that has changed is named by where it lives: a Databricks or SOPS value changed in place, an Azure current version that isn't the one the link was made with, or a pinned version, meaning the link may be damaged.

**Format** (`internal/share`): a version byte, then a JSON header with the key, the repo and the file count, then an age file (scrypt 2^15, with the key's value as passphrase) holding the files as deflated JSON, all as unpadded base64url. Links are capped at 16 KB. A future format gets a new version, and old ones stay readable.

**Safety:**
- A link can only write env files, inside the folder: never in `.git`, never through a symlink.
- New files are created with mode 0600.
- Opening accepts an scrypt work factor of at most 2^20, and unpacks at most 1 MB.

**Along the way:**
- Opening a link to a secret ends on its details, as `enter` would.
- `o` on an Azure secret opens its portal page, and in the versions view, that version's page.
- The details pane follows its secret's list when it reloads in the background. Before, it kept what it showed first, including an old portal address.
- `ref.Read` is shared by `get` and sharing, with Azure `VersionedValue` for pinning.
- `provider.Contents` lets the demo repos share their env files. `--demo` has a share key (`kv-shared-prod / env-share`), so the whole flow works there.
- New dependency: `filippo.io/age`.

Tested end to end in the TUI and the demo, the CLI, and the `share` package. In a real vault: version pinning, opening into a clone, and revocation by disabling the version.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #54
2026-10-13 14:55:22 +02:00
keb 2044341b20 feat: e on the status pane edits the config (#55)
`e` on the status pane (`0`) opens your config file, as in lazygit's status panel. The status footer shows it as `e edit config`. There's no global key. It opens the config the same way `e` opens a local file:
- inside nvim, in that nvim (lazysecrets quits);
- otherwise in `$VISUAL` or `$EDITOR`, coming back after;
- otherwise in the system's default app.

It opens the first config file: your own, also when `--config` layers several. A config that doesn't exist yet is created with a comment linking to its docs. Closing the editor says "restart lazysecrets to use it", since the config is read at startup.

Docs: keys.md (status pane) and config.md. Tested: the default app with a new config, nvim with an existing one left unchanged, the restart message, and the footer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #55
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 13:03:43 +02:00
renovate-bot Bot 1a5e40a012 chore(deps): update go module directive to v1.27.2 (#51)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-13 10:55:33 +02:00
keb 394dd85105 ci: staticcheck v0.8.2, which reads Go 1.27 (#53)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 10:53:01 +02:00
renovate-bot Bot 199fa039e4 chore(deps): update non-major (#50)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 23:18:55 +02:00
renovate-bot Bot a2ece5bf4f chore(deps): update azure-sdk-for-go monorepo to v2 (#52)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault](https://github.com/Azure/azure-sdk-for-go) | `v1.5.0` → `v2.1.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fkeyvault%2farmkeyvault/v2.1.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fkeyvault%2farmkeyvault/v1.5.0/v2.1.0?slim=true) |
| [github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions](https://github.com/Azure/azure-sdk-for-go) | `v1.3.0` → `v2.0.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fresources%2farmsubscriptions/v2.0.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fresources%2farmsubscriptions/v1.3.0/v2.0.0?slim=true) |

---

### Release Notes

<details>
<summary>Azure/azure-sdk-for-go (github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault)</summary>

### [`v2.1.0`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/keyvault/armkeyvault/v2.1.0)

#### 2.1.0 (2026-10-06)

##### Features Added

- New value `JSONWebKeyTypeOctHSM` added to enum type `JSONWebKeyType`
- New value `ManagedHsmSKUNameStandardB10V2`, `ManagedHsmSKUNameStandardB15V2`, `ManagedHsmSKUNameStandardB1V2`, `ManagedHsmSKUNameStandardB20V2`, `ManagedHsmSKUNameStandardB5V2` added to enum type `ManagedHsmSKUName`

### [`v2.0.2`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/keyvault/armkeyvault/v2.0.2)

#### 2.0.2 (2026-03-17)

##### Other Changes

- Upgrade to service API version 2026-02-01

### [`v2.0.1`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/keyvault/armkeyvault/v2.0.1)

#### 2.0.1 (2025-11-05)

##### Breaking Changes

- Field `RetryAfter` of struct `SecretsClientCreateOrUpdateResponse` has been removed

### [`v2.0.0`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/resources/armsubscriptions/v2.0.0)

#### 2.0.0 (2026-06-24)

##### Breaking Changes

- Struct `ErrorAdditionalInfo` has been removed
- Struct `ErrorDetail` has been removed
- Struct `ErrorResponse` has been removed
- Struct `ErrorResponseAutoGenerated` has been removed
- Struct `OperationAutoGenerated` has been removed
- Struct `OperationDisplayAutoGenerated` has been removed
- Struct `OperationListResultAutoGenerated` has been removed

##### Features Added

- New field `NextLink` in struct `LocationListResult`

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNTQuMSIsInVwZGF0ZWRJblZlciI6IjQ0LjE1NC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: #52
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 22:29:31 +02:00
renovate-bot Bot 6d1c26639e chore(deps): update go module directive to v1.26.9 (#49)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 22:10:21 +02:00
renovate-bot BotandKarl-Einar Bergstrøm d32bb63a9e chore(deps): update azure-sdk-for-go monorepo to v2 (#40)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault](https://github.com/Azure/azure-sdk-for-go) | `v1.5.0` → `v2.1.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fkeyvault%2farmkeyvault/v2.1.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fkeyvault%2farmkeyvault/v1.5.0/v2.1.0?slim=true) |
| [github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions](https://github.com/Azure/azure-sdk-for-go) | `v1.3.0` → `v2.0.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fresources%2farmsubscriptions/v2.0.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fresources%2farmsubscriptions/v1.3.0/v2.0.0?slim=true) |

---

### Release Notes

<details>
<summary>Azure/azure-sdk-for-go (github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault)</summary>

### [`v2.1.0`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/keyvault/armkeyvault/v2.1.0)

#### 2.1.0 (2026-10-06)

##### Features Added

- New value `JSONWebKeyTypeOctHSM` added to enum type `JSONWebKeyType`
- New value `ManagedHsmSKUNameStandardB10V2`, `ManagedHsmSKUNameStandardB15V2`, `ManagedHsmSKUNameStandardB1V2`, `ManagedHsmSKUNameStandardB20V2`, `ManagedHsmSKUNameStandardB5V2` added to enum type `ManagedHsmSKUName`

### [`v2.0.2`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/keyvault/armkeyvault/v2.0.2)

#### 2.0.2 (2026-03-17)

##### Other Changes

- Upgrade to service API version 2026-02-01

### [`v2.0.1`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/keyvault/armkeyvault/v2.0.1)

#### 2.0.1 (2025-11-05)

##### Breaking Changes

- Field `RetryAfter` of struct `SecretsClientCreateOrUpdateResponse` has been removed

### [`v2.0.0`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/resources/armsubscriptions/v2.0.0)

#### 2.0.0 (2026-06-24)

##### Breaking Changes

- Struct `ErrorAdditionalInfo` has been removed
- Struct `ErrorDetail` has been removed
- Struct `ErrorResponse` has been removed
- Struct `ErrorResponseAutoGenerated` has been removed
- Struct `OperationAutoGenerated` has been removed
- Struct `OperationDisplayAutoGenerated` has been removed
- Struct `OperationListResultAutoGenerated` has been removed

##### Features Added

- New field `NextLink` in struct `LocationListResult`

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - "before 6am on monday"
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNTQuMSIsInVwZGF0ZWRJblZlciI6IjQ0LjE1NC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Karl-Einar Bergstrøm <1+keb@noreply.localhost>
Reviewed-on: #40
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 21:15:45 +02:00
renovate-bot BotandKarl-Einar Bergstrøm 9de519d515 chore(deps): update actions/setup-go action to v7 (#39)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/setup-go](https://github.com/actions/setup-go) | action | major | `v5` → `v7` |

---

### Release Notes

<details>
<summary>actions/setup-go</summary>

### [`v7.0.0`](https://github.com/actions/setup-go/releases/tag/v7.0.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.5.0...v7.0.0)

##### What's Changed

- Migrate to ESM and upgrade dependencies by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;763](https://github.com/actions/setup-go/pull/763)
- chore(deps): bump [@&#8203;actions/cache](https://github.com/actions/cache) to 6.2.0 by [@&#8203;philip-gai](https://github.com/philip-gai) in [#&#8203;771](https://github.com/actions/setup-go/pull/771)

##### New Contributors

- [@&#8203;philip-gai](https://github.com/philip-gai) made their first contribution in [#&#8203;771](https://github.com/actions/setup-go/pull/771)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v7.0.0>

### [`v6.5.0`](https://github.com/actions/setup-go/releases/tag/v6.5.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.4.0...v6.5.0)

##### What's Changed

##### Dependency update

- Upgrade actions dependencies by [@&#8203;priyagupta108](https://github.com/priyagupta108) with [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;744](https://github.com/actions/setup-go/pull/744)
- Upgrade [@&#8203;types/node](https://github.com/types/node) and typescript-eslint dependencies to resolve npm audit findings by [@&#8203;HarithaVattikuti](https://github.com/HarithaVattikuti) in [#&#8203;755](https://github.com/actions/setup-go/pull/755)
- Upgrade [@&#8203;actions/cache](https://github.com/actions/cache) to 5.1.0, log cache write denied by [@&#8203;jasongin](https://github.com/jasongin) in [#&#8203;758](https://github.com/actions/setup-go/pull/758)
- Upgrade version to 6.5.0 in package.json and package-lock.json by [@&#8203;HarithaVattikuti](https://github.com/HarithaVattikuti) in [#&#8203;762](https://github.com/actions/setup-go/pull/762)

##### New Contributors

- [@&#8203;priyagupta108](https://github.com/priyagupta108) with [@&#8203;Copilot](https://github.com/Copilot) made their first contribution in [#&#8203;744](https://github.com/actions/setup-go/pull/744)
- [@&#8203;jasongin](https://github.com/jasongin) made their first contribution in [#&#8203;758](https://github.com/actions/setup-go/pull/758)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.5.0>

### [`v6.4.0`](https://github.com/actions/setup-go/releases/tag/v6.4.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.3.0...v6.4.0)

##### What's Changed

##### Enhancement

- Add go-download-base-url input for custom Go distributions by [@&#8203;gdams](https://github.com/gdams) in [#&#8203;721](https://github.com/actions/setup-go/pull/721)

##### Dependency update

- Upgrade minimatch from 3.1.2 to 3.1.5 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;727](https://github.com/actions/setup-go/pull/727)

##### Documentation update

- Rearrange README.md, add advanced-usage.md by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;724](https://github.com/actions/setup-go/pull/724)
- Fix Microsoft build of Go link by [@&#8203;gdams](https://github.com/gdams) in [#&#8203;734](https://github.com/actions/setup-go/pull/734)

##### New Contributors

- [@&#8203;gdams](https://github.com/gdams) made their first contribution in [#&#8203;721](https://github.com/actions/setup-go/pull/721)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.4.0>

### [`v6.3.0`](https://github.com/actions/setup-go/releases/tag/v6.3.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.2.0...v6.3.0)

##### What's Changed

- Update default Go module caching to use go.mod by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;705](https://github.com/actions/setup-go/pull/705)
- Fix golang download url to go.dev by [@&#8203;178inaba](https://github.com/178inaba) in [#&#8203;469](https://github.com/actions/setup-go/pull/469)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.3.0>

### [`v6.2.0`](https://github.com/actions/setup-go/releases/tag/v6.2.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.1.0...v6.2.0)

##### What's Changed

##### Enhancements

- Example for restore-only cache in documentation  by [@&#8203;aparnajyothi-y](https://github.com/aparnajyothi-y) in [#&#8203;696](https://github.com/actions/setup-go/pull/696)
- Update Node.js version in action.yml by [@&#8203;ccoVeille](https://github.com/ccoVeille) in [#&#8203;691](https://github.com/actions/setup-go/pull/691)
- Documentation update of actions/checkout by [@&#8203;deining](https://github.com/deining) in [#&#8203;683](https://github.com/actions/setup-go/pull/683)

##### Dependency updates

- Upgrade js-yaml from 3.14.1 to 3.14.2 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;682](https://github.com/actions/setup-go/pull/682)
- Upgrade [@&#8203;actions/cache](https://github.com/actions/cache) to v5 by [@&#8203;salmanmkc](https://github.com/salmanmkc) in [#&#8203;695](https://github.com/actions/setup-go/pull/695)
- Upgrade actions/checkout from 5 to 6 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;686](https://github.com/actions/setup-go/pull/686)
- Upgrade qs from 6.14.0 to 6.14.1 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;703](https://github.com/actions/setup-go/pull/703)

##### New Contributors

- [@&#8203;ccoVeille](https://github.com/ccoVeille) made their first contribution in [#&#8203;691](https://github.com/actions/setup-go/pull/691)
- [@&#8203;deining](https://github.com/deining) made their first contribution in [#&#8203;683](https://github.com/actions/setup-go/pull/683)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.2.0>

### [`v6.1.0`](https://github.com/actions/setup-go/releases/tag/v6.1.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.0.0...v6.1.0)

##### What's Changed

##### Enhancements

- Fall back to downloading from go.dev/dl instead of storage.googleapis.com/golang by [@&#8203;nicholasngai](https://github.com/nicholasngai) in [#&#8203;665](https://github.com/actions/setup-go/pull/665)
- Add support for .tool-versions file and update workflow by [@&#8203;priya-kinthali](https://github.com/priya-kinthali) in [#&#8203;673](https://github.com/actions/setup-go/pull/673)
- Add comprehensive breaking changes documentation for v6 by [@&#8203;mahabaleshwars](https://github.com/mahabaleshwars) in [#&#8203;674](https://github.com/actions/setup-go/pull/674)

##### Dependency updates

- Upgrade eslint-config-prettier from 10.0.1 to 10.1.8 and document breaking changes in v6 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;617](https://github.com/actions/setup-go/pull/617)
- Upgrade actions/publish-action from 0.3.0 to 0.4.0 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;641](https://github.com/actions/setup-go/pull/641)
- Upgrade semver and [@&#8203;types/semver](https://github.com/types/semver) by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;652](https://github.com/actions/setup-go/pull/652)

##### New Contributors

- [@&#8203;nicholasngai](https://github.com/nicholasngai) made their first contribution in [#&#8203;665](https://github.com/actions/setup-go/pull/665)
- [@&#8203;priya-kinthali](https://github.com/priya-kinthali) made their first contribution in [#&#8203;673](https://github.com/actions/setup-go/pull/673)
- [@&#8203;mahabaleshwars](https://github.com/mahabaleshwars) made their first contribution in [#&#8203;674](https://github.com/actions/setup-go/pull/674)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.1.0>

### [`v6.0.0`](https://github.com/actions/setup-go/releases/tag/v6.0.0)

[Compare Source](https://github.com/actions/setup-go/compare/v5.6.0...v6.0.0)

##### What's Changed

##### Breaking Changes

- Improve toolchain handling to ensure more reliable and consistent toolchain selection and management by [@&#8203;matthewhughes934](https://github.com/matthewhughes934) in [#&#8203;460](https://github.com/actions/setup-go/pull/460)
- Upgrade Nodejs runtime from node20 to node 24 by [@&#8203;salmanmkc](https://github.com/salmanmkc) in [#&#8203;624](https://github.com/actions/setup-go/pull/624)

Make sure your runner is on version v2.327.1 or later to ensure compatibility with this release. [See Release Notes](https://github.com/actions/runner/releases/tag/v2.327.1)

##### Dependency Upgrades

- Upgrade [@&#8203;types/jest](https://github.com/types/jest) from 29.5.12 to 29.5.14 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;589](https://github.com/actions/setup-go/pull/589)
- Upgrade [@&#8203;actions/tool-cache](https://github.com/actions/tool-cache) from 2.0.1 to 2.0.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;591](https://github.com/actions/setup-go/pull/591)
- Upgrade [@&#8203;typescript-eslint/parser](https://github.com/typescript-eslint/parser) from 8.31.1 to 8.35.1 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;590](https://github.com/actions/setup-go/pull/590)
- Upgrade undici from 5.28.5 to 5.29.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;594](https://github.com/actions/setup-go/pull/594)
- Upgrade typescript from 5.4.2 to 5.8.3 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;538](https://github.com/actions/setup-go/pull/538)
- Upgrade eslint-plugin-jest from 28.11.0 to 29.0.1 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;603](https://github.com/actions/setup-go/pull/603)
- Upgrade `form-data` to bring in fix for critical vulnerability by [@&#8203;matthewhughes934](https://github.com/matthewhughes934) in [#&#8203;618](https://github.com/actions/setup-go/pull/618)
- Upgrade actions/checkout from 4 to 5 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;631](https://github.com/actions/setup-go/pull/631)

##### New Contributors

- [@&#8203;matthewhughes934](https://github.com/matthewhughes934) made their first contribution in [#&#8203;618](https://github.com/actions/setup-go/pull/618)
- [@&#8203;salmanmkc](https://github.com/salmanmkc) made their first contribution in [#&#8203;624](https://github.com/actions/setup-go/pull/624)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v5...v6.0.0>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - "before 6am on monday"
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNTQuMSIsInVwZGF0ZWRJblZlciI6IjQ0LjE1NC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Karl-Einar Bergstrøm <1+keb@noreply.localhost>
Reviewed-on: #39
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 21:14:42 +02:00
renovate-bot BotandKarl-Einar Bergstrøm 548f19b292 chore(deps): update actions/checkout action to v7 (#38)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/checkout](https://github.com/actions/checkout) | action | major | `v4` → `v7` |

---

### Release Notes

<details>
<summary>actions/checkout</summary>

### [`v7.0.1`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v701)

[Compare Source](https://github.com/actions/checkout/compare/v7.0.0...v7.0.1)

- Skip running unsafe pr check if input is default by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2518](https://github.com/actions/checkout/pull/2518)
- Trim only ascii whitespace for branch by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2521](https://github.com/actions/checkout/pull/2521)
- Escape values passed to --unset by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2530](https://github.com/actions/checkout/pull/2530)
- Various dependency updates

### [`v7.0.0`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)

[Compare Source](https://github.com/actions/checkout/compare/v6.1.0...v7.0.0)

- Block checking out fork PR for pull_request_target and workflow_run by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2454](https://github.com/actions/checkout/pull/2454)
- Various dependency updates

### [`v6.1.0`](https://github.com/actions/checkout/releases/tag/v6.1.0)

[Compare Source](https://github.com/actions/checkout/compare/v6.0.3...v6.1.0)

##### What's Changed

- **\[BREAKING]** backport `allow-unsafe-pr-checkout` to v6 by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2500](https://github.com/actions/checkout/pull/2500)
- backport fixes to releases-v6 by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2527](https://github.com/actions/checkout/pull/2527)

<https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/> for more details about this breaking change

**Full Changelog**: <https://github.com/actions/checkout/compare/v6.0.3...v6.1.0>

### [`v6.0.3`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v603)

[Compare Source](https://github.com/actions/checkout/compare/v6.0.2...v6.0.3)

- Fix checkout init for SHA-256 repositories by [@&#8203;yaananth](https://github.com/yaananth) in [#&#8203;2439](https://github.com/actions/checkout/pull/2439)
- fix: expand merge commit SHA regex and add SHA-256 test cases by [@&#8203;yaananth](https://github.com/yaananth) in [#&#8203;2414](https://github.com/actions/checkout/pull/2414)

### [`v6.0.2`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v602)

[Compare Source](https://github.com/actions/checkout/compare/v6.0.1...v6.0.2)

- Fix tag handling: preserve annotations and explicit fetch-tags by [@&#8203;ericsciple](https://github.com/ericsciple) in [#&#8203;2356](https://github.com/actions/checkout/pull/2356)

### [`v6.0.1`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v601)

[Compare Source](https://github.com/actions/checkout/compare/v6.0.0...v6.0.1)

- Add worktree support for persist-credentials includeIf by [@&#8203;ericsciple](https://github.com/ericsciple) in [#&#8203;2327](https://github.com/actions/checkout/pull/2327)

### [`v6.0.0`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v600)

[Compare Source](https://github.com/actions/checkout/compare/v5.1.0...v6.0.0)

- Persist creds to a separate file by [@&#8203;ericsciple](https://github.com/ericsciple) in [#&#8203;2286](https://github.com/actions/checkout/pull/2286)
- Update README to include Node.js 24 support details and requirements by [@&#8203;salmanmkc](https://github.com/salmanmkc) in [#&#8203;2248](https://github.com/actions/checkout/pull/2248)

### [`v5.1.0`](https://github.com/actions/checkout/releases/tag/v5.1.0)

[Compare Source](https://github.com/actions/checkout/compare/v5.0.1...v5.1.0)

##### What's Changed

- **\[BREAKING]** backport `allow-unsafe-pr-checkout` to v5 by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2501](https://github.com/actions/checkout/pull/2501)
- backport fixes to releases-v5 by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2523](https://github.com/actions/checkout/pull/2523)

<https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/> for more details about this breaking change

**Full Changelog**: <https://github.com/actions/checkout/compare/v5.0.1...v5.1.0>

### [`v5.0.1`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v501)

[Compare Source](https://github.com/actions/checkout/compare/v5.0.0...v5.0.1)

- Port v6 cleanup to v5 by [@&#8203;ericsciple](https://github.com/ericsciple) in [#&#8203;2301](https://github.com/actions/checkout/pull/2301)

### [`v5.0.0`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v500)

[Compare Source](https://github.com/actions/checkout/compare/v4.4.0...v5.0.0)

- Update actions checkout to use node 24 by [@&#8203;salmanmkc](https://github.com/salmanmkc) in [#&#8203;2226](https://github.com/actions/checkout/pull/2226)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - "before 6am on monday"
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNTQuMSIsInVwZGF0ZWRJblZlciI6IjQ0LjE1NC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Karl-Einar Bergstrøm <1+keb@noreply.localhost>
Reviewed-on: #38
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 21:12:22 +02:00
renovate-bot BotandKarl-Einar Bergstrøm 51beea9d78 chore(deps): update goreleaser/goreleaser-action action to v7 (#41)
This PR contains the following updates:

| Package | Type | Update | Change | Pending |
|---|---|---|---|---|
| [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) | action | major | `v6` → `v7` | `v7.3.0` |

---

### Release Notes

<details>
<summary>goreleaser/goreleaser-action</summary>

### [`v7.2.3`](https://github.com/goreleaser/goreleaser-action/releases/tag/v7.2.3)

[Compare Source](https://github.com/goreleaser/goreleaser-action/compare/v7.2.2...v7.2.3)

#### What's Changed

- ci(deps): bump the actions group with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;563](https://github.com/goreleaser/goreleaser-action/pull/563)
- chore(deps): bump undici from 6.24.1 to 6.27.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;565](https://github.com/goreleaser/goreleaser-action/pull/565)
- build: regenerate dist after undici 6.27.0 bump by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;567](https://github.com/goreleaser/goreleaser-action/pull/567)
- ci: auto-rebuild dist on dependabot PRs by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;568](https://github.com/goreleaser/goreleaser-action/pull/568)
- ci: use a GitHub App token to rebuild dist on dependabot PRs by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;569](https://github.com/goreleaser/goreleaser-action/pull/569)

**Full Changelog**: <https://github.com/goreleaser/goreleaser-action/compare/v7.2.2...v7.2.3>

### [`v7.2.2`](https://github.com/goreleaser/goreleaser-action/releases/tag/v7.2.2)

[Compare Source](https://github.com/goreleaser/goreleaser-action/compare/v7.2.1...v7.2.2)

#### What's Changed

- ci(deps): bump the actions group with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;560](https://github.com/goreleaser/goreleaser-action/pull/560)
- fix: nightly resolution to select newest published release by [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;562](https://github.com/goreleaser/goreleaser-action/pull/562)

#### New Contributors

- [@&#8203;Copilot](https://github.com/Copilot) made their first contribution in [#&#8203;562](https://github.com/goreleaser/goreleaser-action/pull/562)

**Full Changelog**: <https://github.com/goreleaser/goreleaser-action/compare/v7...v7.2.2>

### [`v7.2.1`](https://github.com/goreleaser/goreleaser-action/releases/tag/v7.2.1)

[Compare Source](https://github.com/goreleaser/goreleaser-action/compare/v7.2.0...v7.2.1)

This fully removes the usage of the old `nightly` moving tag.

**Full Changelog**: <https://github.com/goreleaser/goreleaser-action/compare/v7.2.0...v7.2.1>

### [`v7.2.0`](https://github.com/goreleaser/goreleaser-action/releases/tag/v7.2.0)

[Compare Source](https://github.com/goreleaser/goreleaser-action/compare/v7.1.0...v7.2.0)

#### What's Changed

- test: cover install across release eras by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;555](https://github.com/goreleaser/goreleaser-action/pull/555)
- feat: add `version-file` input by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;556](https://github.com/goreleaser/goreleaser-action/pull/556)
- feat: resolve nightly to latest vX.Y.Z-<sha>-nightly release by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;558](https://github.com/goreleaser/goreleaser-action/pull/558)

**Full Changelog**: <https://github.com/goreleaser/goreleaser-action/compare/v7...v7.2.0>

### [`v7.1.0`](https://github.com/goreleaser/goreleaser-action/releases/tag/v7.1.0)

[Compare Source](https://github.com/goreleaser/goreleaser-action/compare/v7.0.0...v7.1.0)

#### What's Changed

- feat: verify release checksum and cosign signature by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;550](https://github.com/goreleaser/goreleaser-action/pull/550)
- docs: document cosign verification in README by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;553](https://github.com/goreleaser/goreleaser-action/pull/553)
- docs: Upgrade import GPG action version by [@&#8203;flecno](https://github.com/flecno) in [#&#8203;547](https://github.com/goreleaser/goreleaser-action/pull/547)
- ci: drop docker-bake in favor of plain npm by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;551](https://github.com/goreleaser/goreleaser-action/pull/551)
- ci: add release-major-tag workflow by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;552](https://github.com/goreleaser/goreleaser-action/pull/552)
- ci: drop pre-cosign-v3 goreleaser versions from tests by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;554](https://github.com/goreleaser/goreleaser-action/pull/554)
- ci(deps): bump the actions group with 2 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;543](https://github.com/goreleaser/goreleaser-action/pull/543)
- ci(deps): bump the actions group with 5 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;546](https://github.com/goreleaser/goreleaser-action/pull/546)
- chore(deps): bump undici from 6.23.0 to 6.24.1 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;545](https://github.com/goreleaser/goreleaser-action/pull/545)

#### New Contributors

- [@&#8203;flecno](https://github.com/flecno) made their first contribution in [#&#8203;547](https://github.com/goreleaser/goreleaser-action/pull/547)

**Full Changelog**: <https://github.com/goreleaser/goreleaser-action/compare/v7...v7.1.0>

### [`v7.0.0`](https://github.com/goreleaser/goreleaser-action/releases/tag/v7.0.0)

[Compare Source](https://github.com/goreleaser/goreleaser-action/compare/v6.4.0...v7.0.0)

#### What's Changed

- feat!: node 24, update deps, rm yarn, ESM by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;533](https://github.com/goreleaser/goreleaser-action/pull/533)
- sec: pin github action versions by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;514](https://github.com/goreleaser/goreleaser-action/pull/514)
- docs: Upgrade checkout GitHub Action in README.md by [@&#8203;dunglas](https://github.com/dunglas) in [#&#8203;507](https://github.com/goreleaser/goreleaser-action/pull/507)
- chore(deps): bump actions/checkout from 4 to 5 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;504](https://github.com/goreleaser/goreleaser-action/pull/504)
- ci(deps): bump the actions group with 2 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;517](https://github.com/goreleaser/goreleaser-action/pull/517)
- ci(deps): bump the actions group with 2 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;523](https://github.com/goreleaser/goreleaser-action/pull/523)
- ci(deps): bump docker/bake-action from 6.9.0 to 6.10.0 in the actions group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;526](https://github.com/goreleaser/goreleaser-action/pull/526)
- ci(deps): bump the actions group across 1 directory with 4 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;532](https://github.com/goreleaser/goreleaser-action/pull/532)
- ci(deps): bump actions/checkout from 6.0.1 to 6.0.2 in the actions group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;534](https://github.com/goreleaser/goreleaser-action/pull/534)
- chore(deps): bump the npm group across 1 directory with 4 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;536](https://github.com/goreleaser/goreleaser-action/pull/536)
- chore(deps): bump [@&#8203;actions/http-client](https://github.com/actions/http-client) from 3.0.2 to 4.0.0 in the npm group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;537](https://github.com/goreleaser/goreleaser-action/pull/537)
- ci(deps): bump docker/setup-buildx-action from 3.10.0 to 3.12.0 in the actions group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;538](https://github.com/goreleaser/goreleaser-action/pull/538)
- chore(deps): bump semver from 7.7.3 to 7.7.4 in the npm group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;539](https://github.com/goreleaser/goreleaser-action/pull/539)

**Full Changelog**: <https://github.com/goreleaser/goreleaser-action/compare/v6...v7.0.0>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - "before 6am on monday"
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNTQuMSIsInVwZGF0ZWRJblZlciI6IjQ0LjE1NC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Karl-Einar Bergstrøm <1+keb@noreply.localhost>
Reviewed-on: #41
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 21:10:27 +02:00
renovate-bot Bot 00924a4a1e chore(deps): update module google.golang.org/grpc to v1.83.2 [security] (#48)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 21:02:00 +02:00
renovate-bot Bot 3013d622e4 chore(deps): update security fixes [security] (#47)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 20:43:38 +02:00
renovate-bot Bot f19ac7845a chore(deps): update module go.opentelemetry.io/otel to v1.42.0 [security] (#46)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 20:40:01 +02:00
renovate-bot Bot 915d8bf3bd chore(deps): update module go.opentelemetry.io/otel to v1.41.0 [security] (#42)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 20:35:47 +02:00
renovate-bot Bot 392ea41865 chore(deps): update non-major (#37)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 20:23:44 +02:00
renovate-bot Bot da890a92cb chore(deps): update non-major (#36)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 20:15:59 +02:00
Karl-Einar Bergstrømandkeb 01a51da443 chore: renovate leaves the golden test helper alone (#34)
github.com/charmbracelet/x/exp/golden is pinned to a commit with no releases, so Renovate can't look it up and warned on every run.

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #34
2026-10-12 20:07:33 +02:00
renovate-bot Bot 595bdaad43 chore: Configure Renovate (#32)
Welcome to [Renovate](https://github.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin.

🚦 To activate Renovate, merge this Pull Request. To disable Renovate, simply close this Pull Request unmerged.

📚 See our [Reading List](https://docs.renovatebot.com/reading-list/) for relevant documentation you may be interested in reading.

🔡 Do you want to change how Renovate upgrades your dependencies? Add your custom config to `renovate.json` in this branch. Renovate will update the Pull Request description the next time it runs.

---
### Detected Package Files

 * `.gitea/workflows/ci.yml` (github-actions)
 * `.gitea/workflows/pr-title.yml` (github-actions)
 * `.gitea/workflows/release.yml` (github-actions)
 * `go.mod` (gomod)
 * `renovate.json` (renovate-config)
 * `.gitea/workflows/release.yml` (regex)
 * `.gitea/workflows/ci.yml` (regex)

### Configuration Summary

Based on the default config's presets, Renovate will:

  - Start dependency updates only once this onboarding PR is merged
  - Enable Renovate Dependency Dashboard creation.
  - Use semantic commit type `fix` for dependencies and `chore` for all others if semantic commits are in use.
  - Ignore `node_modules`, `bower_components`, `vendor` and various test/tests (except for nuget) directories.
  - Group known monorepo packages together.
  - Use curated list of recommended non-monorepo package groupings.
  - Show only the Age and Confidence Merge Confidence badges for pull requests.
  - Apply crowd-sourced package replacement rules.
  - Apply crowd-sourced workarounds for known problems with packages.
  - Ensure that every dependency pinned by digest and sourced from Forgejo contains a link to the commit-to-commit diff
  - Ensure that every dependency pinned by digest and sourced from Gitea contains a link to the commit-to-commit diff
  - Ensure that every dependency pinned by digest and sourced from GitHub.com and Github enterprise contains a link to the commit-to-commit diff
  - Ensure that every dependency pinned by digest and sourced from GitLab.com contains a link to the commit-to-commit diff
  - Correctly link to the source code for golang.org/x packages
  - Link to pkg.go.dev/... for golang.org/x packages' title
  - Provide a link to octochangelog's improved breakdown for Renovate's changelogs
  - Use semantic prefixes for commit messages and PR titles.
  - If Renovate detects semantic commits, it will use semantic commit type `chore` for all commits.
  - The shared Renovate policy: repos extend it with local>keb/renovate.
  - Run Renovate on following schedule: before 6am on monday

---

### What to Expect

With your current configuration, Renovate will create 3 Pull Requests:

<details>
<summary>chore(deps): update 0.x minor</summary>

  - Schedule: ["before 6am on monday"]
  - Branch name: `renovate/0.x-minor`
  - Merge into: `main`
  - Upgrade [golang.org/x/mod](https://go.googlesource.com/mod) to `v0.41.0`
  - Upgrade [golang.org/x/term](https://go.googlesource.com/term) to `v0.47.0`

</details>

<details>
<summary>chore(deps): update non-major</summary>

  - Schedule: ["before 6am on monday"]
  - Branch name: `renovate/non-major`
  - Merge into: `main`
  - Upgrade [charm.land/bubbletea/v2](https://github.com/charmbracelet/bubbletea) to `v2.1.0`
  - Upgrade [github.com/Azure/azure-sdk-for-go/sdk/azcore](https://github.com/Azure/azure-sdk-for-go) to `v1.23.3`
  - Upgrade [github.com/charmbracelet/x/ansi](https://github.com/charmbracelet/x) to `v0.11.9`
  - Upgrade [gopkg.in/ini.v1](https://gopkg.in/ini.v1) to `v1.67.3`

</details>

<details>
<summary>chore(deps): update azure-sdk-for-go monorepo to v2</summary>

  - Schedule: ["before 6am on monday"]
  - Branch name: `renovate/major-azure-sdk-for-go-monorepo`
  - Merge into: `main`
  - Upgrade [github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault](https://github.com/Azure/azure-sdk-for-go) to `v2.1.0`
  - Upgrade [github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions](https://github.com/Azure/azure-sdk-for-go) to `v2.0.0`

</details>

🚸 PR creation will be limited to maximum 2 per hour, so it doesn't swamp any CI resources or overwhelm the project. See [docs for `prHourlyLimit`](https://docs.renovatebot.com/configuration-options/#prhourlylimit) for details.

---
>
> ⚠️ **Warning**
>
> Please correct - or verify that you can safely ignore - these dependency lookup failures before you merge this PR.
>
> -   `Could not determine new digest for update (go package github.com/charmbracelet/x/exp/golden)`
>
> Files affected: `go.mod`

---

❓ Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section.
If you need any further assistance then you can also [request help here](https://github.com/renovatebot/renovate/discussions).

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).

<!--renovate-config-hash:42b8771e0e792cb00b73533dcfa07b6c34d2c882632e57c0db725713586c855e-->

Reviewed-on: #32
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 19:58:41 +02:00
keb edf6e79327 Hello world
Release / Build and publish (push) Successful in 38s
2026-10-12 15:40:46 +02:00
85 changed files with 4162 additions and 1122 deletions

No files matched your search

+3 -3
View File
@@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
# The runner mounts Go, the module cache and the build cache as # The runner mounts Go, the module cache and the build cache as
@@ -40,7 +40,7 @@ jobs:
- name: staticcheck - name: staticcheck
# Pinned, so a new release can't fail CI on its own. # Pinned, so a new release can't fail CI on its own.
run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./... run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 ./...
- name: go test - name: go test
run: go test ./... run: go test ./...
+17 -5
View File
@@ -4,8 +4,9 @@ name: Release
# as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml) # as a pre-release) tests lazysecrets, then GoReleaser (.goreleaser.yaml)
# builds it for macOS and Linux, arm64 and amd64, and publishes a Gitea # 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 # release with the archives, checksums and this release's section of the
# changelog (git-cliff, cliff.toml). Pushing the same tag again (after # changelog (git-cliff, cliff.toml), then copies it to the GitHub
# deleting it) replaces the release. # mirror. Pushing the same tag again (after deleting it) replaces the
# release in both places.
# #
# scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes # scripts/release.sh v0.2.0 # updates CHANGELOG.md, tags and pushes
@@ -22,12 +23,12 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout (full history, for the changelog) - name: Checkout (full history, for the changelog)
uses: actions/checkout@v4 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
# The runner mounts Go, the module cache and the build cache as # The runner mounts Go, the module cache and the build cache as
@@ -74,10 +75,21 @@ jobs:
cat "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md"
- name: GoReleaser - name: GoReleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v7
with: with:
distribution: goreleaser distribution: goreleaser
version: v2.18.3 version: v2.18.3
args: release --clean --release-notes ${{ runner.temp }}/notes.md args: release --clean --release-notes ${{ runner.temp }}/notes.md
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Copy the release to GitHub
# The same notes and files on the GitHub mirror, which lazysecrets
# updates from first (internal/update). GH_RELEASE_TOKEN is a
# fine-grained token that can write the mirror's contents; without
# it the step does nothing.
run: node scripts/github-release.mjs
env:
TAG: ${{ github.ref_name }}
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+46
View File
@@ -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
+5
View File
@@ -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.
+20
View File
@@ -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.
+8 -1
View File
@@ -2,7 +2,14 @@
Every release of lazysecrets, newest first. Every release of lazysecrets, newest first.
## v0.1.0 – 2026-10-10 ## v0.1.1 – 2026-10-13
### Added
- `e` on the status pane edits the config ([#55](https://git.kebcloud.no/keb/lazysecrets/pulls/55)) ([2044341](https://git.kebcloud.no/keb/lazysecrets/commit/2044341b20d795fa7cadcabb9514631990993e0a))
- Share env files as encrypted links ([#54](https://git.kebcloud.no/keb/lazysecrets/pulls/54)) ([4377eb9](https://git.kebcloud.no/keb/lazysecrets/commit/4377eb98bf0dd523d5c4371bf84668412053290e))
## v0.1.0 – 2026-10-12
Hello world 👋 Hello world 👋
+28 -17
View File
@@ -1,30 +1,29 @@
# lazysecrets # lazysecrets
A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic lazygit. A terminal UI for finding, reading and sharing secrets. Inspired by the fantastic [lazygit](https://github.com/jesseduffield/lazygit).
![lazysecrets](docs/assets/lazysecrets.png)
## Main features ## Main features
- **Read-only**: lazysecrets never creates, changes or deletes a secret, so - **Read-only**: lazysecrets never creates, changes or deletes a secret.
it's safe to point at production. That's by design and may stay that way. That's by design and may stay that way.
(`e` opens a repo's SOPS or `.env` file in your editor, or `sops edit`;
any change is yours.)
- **Zero setup**: uses the logins you already have (`az`, the Databricks - **Zero setup**: uses the logins you already have (`az`, the Databricks
CLI, `sops`, `git`). No config file needed. CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with - **Search everything**: `ctrl+s` searches every source at once, with
fzf-style matching (`'exact`, `^prefix`, `suffix$`, `!not`), and jumps fzf-style matching.
straight to the result. - **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
- **Share links**: `s` copies a `lazysecrets://` link. A colleague opens it
with their own access, from the terminal or with a click in the browser with their own access, from the terminal or with a click in the browser
or chat. or chat. `.env` files can be shared too, as a link encrypted with a
secret your team can read.
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
- **Careful with values**: values stay hidden until you reveal them, and - **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds. copied values are cleared from the clipboard after 30 seconds.
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and - **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
pipes. pipes.
- **Favourites**: star the secrets you use most and get back to them in one - **Favourites**: star the secrets you use most for easy access.
key.
- **Expiry warnings**: secrets, keys and certificates that expire soon are - **Expiry warnings**: secrets, keys and certificates that expire soon are
marked, listed together in the status pane, and `lazysecrets expiring` marked, listed together in the status pane.
checks every vault from a pipeline.
- **Your way**: rebind any key, pick a theme, and add custom commands to run - **Your way**: rebind any key, pick a theme, and add custom commands to run
on a secret. on a secret.
- **Try it first**: `lazysecrets --demo` runs on built-in sample data. - **Try it first**: `lazysecrets --demo` runs on built-in sample data.
@@ -40,8 +39,9 @@ On macOS or Linux (arm64 or x86_64):
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
``` ```
It installs the latest [release](https://git.kebcloud.no/keb/lazysecrets/releases) It installs the latest release, from GitHub or else
into `~/.local/bin` after checking it against the release's checksums. [git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into
`~/.local/bin` after checking it against the release's checksums.
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR` `sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
somewhere else. somewhere else.
@@ -99,9 +99,20 @@ 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 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 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 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 call of its own is a daily check for a new release, on GitHub or else
`updateCheck: false` turns off. git.kebcloud.no, which `updateCheck: false` turns off.
## Repositories
The main repo of this codebase is at [git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets).
The [GitHub mirror](https://github.com/karleinarb/lazysecrets) is for discoverability, issue-tracking and releases.
It also makes it possible to give a ⭐ if you like lazysecrets!
## Contributing
I don't take pull requests as I don't have time to review and test them.
But bug reports and ideas are very welcome!
Open an [issue on GitHub](https://github.com/karleinarb/lazysecrets/issues) and I'll have a look.
## AI disclosure ## AI disclosure
+2
View File
@@ -51,6 +51,8 @@ commit_preprocessors = [
{ pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" }, { pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" },
# #11 was squash-merged with #10 (the status pane) in it: name both. # #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)" }, { 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)" },
# #55's title starts with a key, which capitalised reads as another.
{ pattern = "^feat: e on the status pane edits the config", replace = "feat: `e` on the status pane edits the config" },
# Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see # Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see
# pr-title.yml); all become "feat: x". # 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}: " },
-163
View File
@@ -1,163 +0,0 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"slices"
"strings"
"sync"
"text/tabwriter"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
// lazysecrets expiring lists what has expired, or will within --days,
// in every vault of every account: for a pipeline or a cron job. Expiry
// dates come with the lists, so it's one request per vault and type,
// a few at a time. Only Key Vault has expiry dates.
// errExpiring makes the exit status 1 when anything was listed.
var errExpiring = errors.New("expiring")
// listLimit is how many lists load at once.
const listLimit = 8
type expiringOpts struct {
days int
timeout time.Duration
now time.Time
}
func parseExpiring(args []string, days int) (int, error) {
fs := flag.NewFlagSet("expiring", flag.ContinueOnError)
n := fs.Int("days", days, "list what expires within this many days (default: expiryWarningDays, else 30)")
if err := fs.Parse(args); err != nil {
return 0, err
}
if fs.NArg() > 0 || *n < 0 {
return 0, fmt.Errorf("usage: lazysecrets expiring [--days N]")
}
return *n, nil
}
func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer) error {
var (
mu sync.Mutex
found []provider.Object
seen = map[string]bool{}
failed int
vaults int
wg sync.WaitGroup
limit = make(chan struct{}, listLimit)
within = o.now.AddDate(0, 0, o.days)
warnErr = func(what string, err error) {
mu.Lock()
defer mu.Unlock()
failed++
fmt.Fprintf(errs, "lazysecrets: %s: %v\n", what, err)
}
)
run := func(f func(ctx context.Context)) {
wg.Add(1)
go func() {
defer wg.Done()
limit <- struct{}{}
defer func() { <-limit }()
ctx, cancel := context.WithTimeout(context.Background(), o.timeout)
defer cancel()
f(ctx)
}()
}
var vaultsDone sync.WaitGroup
for _, p := range providers {
if p.Kind() != provider.KindAzure {
continue
}
vaultsDone.Add(1)
run(func(ctx context.Context) {
defer vaultsDone.Done()
srcs, err := p.Sources(ctx)
if err != nil {
warnErr(p.Label(), err)
return
}
for _, src := range srcs {
vaultsDone.Add(1)
run(func(ctx context.Context) {
defer vaultsDone.Done()
cs, err := p.Containers(ctx, src)
if err != nil {
warnErr(src.Name, err)
return
}
for _, c := range cs {
mu.Lock()
fresh := !seen[c.ID]
seen[c.ID] = true
if fresh {
vaults++
}
mu.Unlock()
if !fresh { // the same vault through another account
continue
}
for _, t := range c.ObjectTypes() {
run(func(ctx context.Context) {
objs, err := p.Objects(ctx, c, t)
if err != nil {
warnErr(c.Name+" "+strings.ToLower(string(t))+"s", err)
return
}
mu.Lock()
defer mu.Unlock()
for _, o := range objs {
if !o.Expires.IsZero() && o.Expires.Before(within) {
found = append(found, o)
}
}
})
}
}
})
}
})
}
// Listing vaults queues the lists of their secrets, so wait for both.
vaultsDone.Wait()
wg.Wait()
slices.SortFunc(found, func(a, b provider.Object) int { return a.Expires.Compare(b.Expires) })
if len(found) == 0 {
fmt.Fprintf(errs, "nothing expires within %d days (%d vaults)\n", o.days, vaults)
if failed > 0 {
return fmt.Errorf("%d lists couldn't be read", failed)
}
return nil
}
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "LEFT\tEXPIRES\tTYPE\tVAULT\tNAME\tLINK")
for _, x := range found {
left := "expired"
if d := x.Expires.Sub(o.now); d > 0 {
left = fmt.Sprintf("%dd", int(d.Hours()/24))
}
link := ""
if r, err := ref.ForObject(x, ""); err == nil {
link = r.String()
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", left, x.Expires.Local().Format("2006-01-02"),
strings.ToLower(string(x.Type)), x.Container.Name, x.Name, link)
}
if err := tw.Flush(); err != nil {
return err
}
if failed > 0 {
fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: the list may be incomplete\n", failed)
}
return errExpiring
}
-41
View File
@@ -1,41 +0,0 @@
package main
import (
"bytes"
"errors"
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
func TestExpiring(t *testing.T) {
providers := []provider.Provider{fake.Azure(0), fake.Databricks(0)}
now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
var out, errs bytes.Buffer
err := expiring(providers, expiringOpts{days: 30, timeout: time.Minute, now: now}, &out, &errs)
if !errors.Is(err, errExpiring) {
t.Fatalf("err %v (want errExpiring: something listed)", err)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 2 || !strings.HasPrefix(lines[1], "expired 2026-03-01 certificate kv-app-prod old-tls") ||
!strings.Contains(lines[1], "lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls") {
t.Errorf("out:\n%s", out.String())
}
if !strings.Contains(errs.String(), "kv-restricted") {
t.Errorf("unreadable vaults not said:\n%s", errs.String())
}
// Further ahead, the soonest first.
out.Reset()
expiring(providers, expiringOpts{days: 400, timeout: time.Minute, now: now}, &out, &errs)
if got := strings.Count(out.String(), "\n"); got != 4 || !strings.Contains(out.String(), "app-tls") {
t.Errorf("400 days:\n%s", out.String())
}
if _, err := parseExpiring([]string{"--days", "-1"}, 30); err == nil {
t.Error("negative days accepted")
}
}
+9 -77
View File
@@ -1,10 +1,7 @@
package main package main
import ( import (
"cmp"
"context" "context"
"errors"
"fmt"
"os" "os"
"golang.org/x/term" "golang.org/x/term"
@@ -33,82 +30,17 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
return err return err
} }
// value reads what r points at without listing its vault: the vault and // value reads what r points at (ref.Read), with the account that last
// name are enough for Azure, tried with each account (the one that last // read it first.
// 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) { func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
if r.IsSource() || r.IsContainer() { v, _, err := readKey(ctx, providers, st, r)
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()]) return v, err
} }
if r.Kind != provider.KindAzure && r.Host == "" {
return provider.SecretValue{}, errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)") // readKey is value, also returning the version read.
} func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
key := r key := r
key.Version = "" key.Version = ""
node := st.Node(key.String()) node := st.Node(key.String())
return ref.Read(ctx, providers, r, node.Account, node.Source)
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...)
} }
+25 -24
View File
@@ -4,7 +4,6 @@ package main
import ( import (
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -34,9 +33,7 @@ import (
) )
func main() { func main() {
if err := run(); errors.Is(err, errExpiring) { if err := run(); err != nil {
os.Exit(1) // listed: a pipeline fails on it
} else if err != nil {
fmt.Fprintln(os.Stderr, "lazysecrets:", err) fmt.Fprintln(os.Stderr, "lazysecrets:", err)
os.Exit(1) os.Exit(1)
} }
@@ -117,13 +114,14 @@ func run() error {
fmt.Fprintf(flag.CommandLine.Output(), `Usage: fmt.Fprintf(flag.CommandLine.Output(), `Usage:
lazysecrets [flags] browse secrets lazysecrets [flags] browse secrets
lazysecrets [flags] [open] <ref> lazysecrets [flags] [open] <ref>
open a lazysecrets:// link, Key Vault URL, open a lazysecrets:// link (also a share link),
@Microsoft.KeyVault(...) or {{secrets/scope/key}} Key Vault URL, @Microsoft.KeyVault(...) or
{{secrets/scope/key}}
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at, lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
for scripts: VAR=$(lazysecrets get <ref>) for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] expiring [--days N] lazysecrets [flags] share [--key <secret>] <env file>...
list what has expired or will within N days print a link to env files of a repo, encrypted
(default 30) in every vault; exits 1 if any with a secret's value (default: share.key)
lazysecrets [flags] keybindings print every action and its keys (with your lazysecrets [flags] keybindings print every action and its keys (with your
config applied), as config to copy and edit config applied), as config to copy and edit
lazysecrets handler install [--terminal NAME] lazysecrets handler install [--terminal NAME]
@@ -146,9 +144,14 @@ Flags:
} }
var open, getRef *ref.Ref var open, getRef *ref.Ref
var expiringArgs []string var shareArgs []string
printKeys := false printKeys := false
switch args := flag.Args(); { switch args := flag.Args(); {
case len(args) > 0 && args[0] == "share":
shareArgs = args[1:]
if len(shareArgs) == 0 {
shareArgs = []string{} // still sharing: says how
}
case len(args) == 1 && args[0] == "keybindings": case len(args) == 1 && args[0] == "keybindings":
printKeys = true printKeys = true
case len(args) == 2 && args[0] == "get": case len(args) == 2 && args[0] == "get":
@@ -157,14 +160,12 @@ Flags:
return err return err
} }
getRef = &r getRef = &r
case len(args) > 0 && args[0] == "expiring":
expiringArgs = append([]string{}, args[1:]...)
case len(args) > 0 && args[0] == "handler": case len(args) > 0 && args[0] == "handler":
return handler(*configPath, args[1:]) return handler(*configPath, args[1:])
case len(args) > 0 && args[0] == "launch": case len(args) > 0 && args[0] == "launch":
return launch(*configPath, args[1:]) return launch(*configPath, args[1:])
case len(args) > 0 && args[0] == "update": case len(args) > 0 && args[0] == "update":
return updateCmd(args[1:]) return updateCmd(*configPath, args[1:])
case len(args) == 0: case len(args) == 0:
case args[0] == "open" && len(args) == 2: case args[0] == "open" && len(args) == 2:
r, err := ref.Parse(args[1]) r, err := ref.Parse(args[1])
@@ -252,9 +253,10 @@ Flags:
if *demo { if *demo {
// Two Azure accounts, like a personal and an admin login in az. // Two Azure accounts, like a personal and an admin login in az.
providers = []provider.Provider{ providers = []provider.Provider{
fake.Azure(400*time.Millisecond).As("Azure:dev@example.com/demo", "Azure · dev"), fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"),
fake.AzureOps(700*time.Millisecond).As("Azure:ops@example.com/demo", "Azure · ops"), fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"),
fake.Databricks(600 * time.Millisecond), fake.Databricks(600 * time.Millisecond),
fake.Repos(),
} }
} else { } else {
pc := cfg.Providers pc := cfg.Providers
@@ -307,19 +309,16 @@ Flags:
// the standard logger, which would draw over the TUI. // the standard logger, which would draw over the TUI.
log.SetOutput(io.Discard) log.SetOutput(io.Discard)
if expiringArgs != nil {
days, err := parseExpiring(expiringArgs, expiryDays)
if err != nil {
return err
}
return expiring(providers, expiringOpts{days: days, timeout: *timeout, now: time.Now()}, os.Stdout, os.Stderr)
}
if getRef != nil { if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), *timeout) ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel() defer cancel()
return get(ctx, providers, st, *getRef) return get(ctx, providers, st, *getRef)
} }
if shareArgs != nil {
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
return shareFiles(ctx, providers, st, cfg.Share.Key, shareRules(cfg), shareArgs, os.Stdout)
}
// Links opened elsewhere (lazysecrets launch) can come here. // Links opened elsewhere (lazysecrets launch) can come here.
var p *tea.Program var p *tea.Program
@@ -380,7 +379,7 @@ Flags:
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour, ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.HideOnBlur), HideOnBlur: config.On(cfg.HideOnBlur),
HandlerSetup: setup, HandlerSetup: setup,
Updater: updater(), Updater: updater(cfg.Releases),
UpdateCheck: config.On(cfg.UpdateCheck), UpdateCheck: config.On(cfg.UpdateCheck),
Keys: &keymap, Keys: &keymap,
Commands: cfg.CustomCommands, Commands: cfg.CustomCommands,
@@ -391,6 +390,8 @@ Flags:
StatePath: statePath, StatePath: statePath,
Changelog: lazysecrets.Changelog, Changelog: lazysecrets.Changelog,
Dir: placeDir, Dir: placeDir,
ShareKey: cfg.Share.Key,
ShareRules: shareRules(cfg),
Open: open, Open: open,
Styles: &theme, Styles: &theme,
DefaultFg: defaultFg, DefaultFg: defaultFg,
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// shareFiles prints a share link to env files of one repo, encrypted with
// the --key secret's value, else the repo's or the config's share.key:
//
// lazysecrets share .env apps/api/.env
func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, rules share.Rules, args []string, out io.Writer) error {
fs := flag.NewFlagSet("share", flag.ContinueOnError)
keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: share.key)")
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() == 0 {
return errors.New("usage: lazysecrets share [--key <secret>] <env files>")
}
var root, id string
var files []share.File
for _, name := range fs.Args() {
abs, err := filepath.Abs(name)
if err != nil {
return err
}
r, i, err := repo.Root(filepath.Dir(abs))
if err != nil {
return err
}
if root == "" {
root, id = r, i
} else if r != root {
return fmt.Errorf("%s isn't in %s: share files of one repo at a time", name, root)
}
real, err := filepath.EvalSymlinks(abs)
if err != nil {
return err
}
rel, err := filepath.Rel(root, real)
if err != nil {
return err
}
f := share.File{Path: filepath.ToSlash(rel)}
if err := share.CheckPath(f.Path); err != nil {
return err
}
if f.Data, err = os.ReadFile(real); err != nil {
return err
}
files = append(files, f)
}
key, ok, err := share.DefaultKey(root, general)
if *keyLink != "" {
key, err = share.ParseKey(*keyLink)
ok = err == nil
}
if err != nil {
return err
}
if !ok {
return errors.New("which secret to encrypt with? --key <secret>, or share.key in the repo's .lazysecrets.yaml or the config")
}
v, version, err := readKey(ctx, providers, st, key)
if err != nil {
return fmt.Errorf("reading the key %s: %w", key, err)
}
link, err := share.Seal(share.Header{Key: share.Pin(key, version), Repo: id}, []byte(v.Reveal()), files, rules)
if err != nil {
return err
}
_, err = fmt.Fprintln(out, link)
return err
}
// shareRules are the config's share.keyRules.
func shareRules(cfg config.Config) share.Rules {
return share.Rules{MinLength: cfg.Share.KeyRules.MinLength, MinClasses: cfg.Share.KeyRules.MinClasses}
}
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"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/share"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// demoRules take the demo key, made up to be remembered.
var demoRules = share.Rules{MinLength: 16, MinClasses: 1}
func TestShareFiles(t *testing.T) {
root, _ := filepath.EvalSymlinks(t.TempDir())
for _, args := range [][]string{{"init", "-q"}, {"remote", "add", "origin", "git@git.example.com:team/app.git"}} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Skip("git:", string(out))
}
}
os.WriteFile(filepath.Join(root, ".env"), []byte("A=1\n"), 0o600)
os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644)
providers := []provider.Provider{fake.Azure(0)}
st, _ := store.Load("")
ctx := context.Background()
key := "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password"
var out bytes.Buffer
if err := shareFiles(ctx, providers, st, key, demoRules, []string{filepath.Join(root, ".env")}, &out); err != nil {
t.Fatal(err)
}
r, err := ref.Parse(strings.TrimSpace(out.String()))
if err != nil {
t.Fatal(err)
}
s, err := share.Read(r)
if err != nil || s.Repo != "git.example.com/team/app" || s.Key.Name != "db-password" {
t.Fatalf("%+v, %v", s.Header, err)
}
files, err := s.Open([]byte("correct-horse-battery-staple"))
if err != nil || len(files) != 1 || files[0].Path != ".env" || string(files[0].Data) != "A=1\n" {
t.Errorf("%+v, %v", files, err)
}
for _, c := range []struct {
general string
args []string
want string
}{
{"", []string{filepath.Join(root, ".env")}, "which secret"},
{key, []string{filepath.Join(root, "main.go")}, "isn't an env file"},
{key, nil, "usage"},
{"", []string{"--key", "lazysecrets://azure/kv-app-prod.vault.azure.net", filepath.Join(root, ".env")}, "isn't a secret"},
} {
if err := shareFiles(ctx, providers, st, c.general, demoRules, c.args, &out); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%v: %v, want %q", c.args, err, c.want)
}
}
}
+11 -5
View File
@@ -7,14 +7,16 @@ import (
"strings" "strings"
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/ui/app" "git.kebcloud.no/keb/lazysecrets/internal/ui/app"
"git.kebcloud.no/keb/lazysecrets/internal/update" "git.kebcloud.no/keb/lazysecrets/internal/update"
) )
// updater is the app's way to find and install releases, replacing the // updater is the app's way to find and install releases, replacing the
// binary that's running. // binary that's running, from the servers (config releases) or the
func updater() *app.Updater { // default ones.
c := &update.Client{} func updater(servers []string) *app.Updater {
c := &update.Client{Servers: servers}
return &app.Updater{ return &app.Updater{
Latest: c.Latest, Latest: c.Latest,
Install: func(ctx context.Context, r update.Release) error { Install: func(ctx context.Context, r update.Release) error {
@@ -29,15 +31,19 @@ func updater() *app.Updater {
// updateCmd is lazysecrets update [--check]: the newer release's // updateCmd is lazysecrets update [--check]: the newer release's
// changelog, then (unless --check) install it. // changelog, then (unless --check) install it.
func updateCmd(args []string) error { func updateCmd(configPath string, args []string) error {
check := len(args) == 1 && args[0] == "--check" check := len(args) == 1 && args[0] == "--check"
if len(args) > 0 && !check { if len(args) > 0 && !check {
return errors.New("usage: lazysecrets update [--check]") return errors.New("usage: lazysecrets update [--check]")
} }
cfg, err := config.LoadAll(strings.Split(configPath, ","))
if err != nil {
return err
}
current := versionString() current := versionString()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
c := &update.Client{} c := &update.Client{Servers: cfg.Releases}
r, err := c.Latest(ctx) r, err := c.Latest(ctx)
if err != nil { if err != nil {
return fmt.Errorf("looking for a newer lazysecrets: %w", err) return fmt.Errorf("looking for a newer lazysecrets: %w", err)
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

+8 -22
View File
@@ -16,12 +16,10 @@ A service principal is used instead when `AZURE_CLIENT_ID`,
On an account's group row, the fourth pane shows who it signs in as: name, 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 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 (built-in ones by name) and its subscriptions, read from az's account list
and the token lazysecrets already has. Below them are the groups the and the token lazysecrets already has: no requests of their own. `y` on
account is in, nested ones too: those come from Microsoft Graph, once a the row copies the object ID, the one access policies and role
session, the first time the pane shows them. `y` on the row copies the assignments ask for; `l` goes into the pane, where `j`/`k` pick any
object ID, the one access policies and role assignments ask for; `tab` field, `/` filters them (`/tenant`) and `y` copies the selected one.
goes into the pane, where `j`/`k` pick any field or group, `/` filters
them (`/tenant`, `/platform`) and `y` copies the selected one.
If an account's az login has expired, the status line says so once, and the 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` Details pane on that account's rows says it until you sign in: press `L`
@@ -40,8 +38,10 @@ vault resources (e.g. *Reader*). Key Vault data-plane access is separate
| read secret values | *Key Vault Secrets User* | | read secret values | *Key Vault Secrets User* |
| read certificate details | *Key Vault Certificate User* | | read certificate details | *Key Vault Certificate User* |
A missing permission is shown in the pane, including the action that was A missing permission is shown in the pane, shortened to what was denied
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 ## Vaults you can't discover
@@ -71,20 +71,6 @@ 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 with the lists. It says how many vaults it covers; `ctrl+a` in search
loads the rest. loads the rest.
For a pipeline or a cron job, `lazysecrets expiring` lists them in every
vault of every account, loading each vault's lists (a few at a time), and
exits 1 when it lists anything:
```sh
$ lazysecrets expiring --days 30
LEFT EXPIRES TYPE VAULT NAME LINK
expired 2026-03-01 certificate kv-app-prod old-tls lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls
12d 2026-10-23 secret kv-app-prod api-key lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key
```
`--days` defaults to `expiryWarningDays`. Lists it can't read are named on
stderr.
On a vault, the right pane shows its settings: resource group, location, 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 SKU, whether access is by Azure roles or access policies, soft delete and
purge protection, which networks can reach it, private endpoints and tags. purge protection, which networks can reach it, private endpoints and tags.
+22 -8
View File
@@ -2,7 +2,9 @@
Optional, at `~/.config/lazysecrets/config.yaml` (or Optional, at `~/.config/lazysecrets/config.yaml` (or
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override `$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override
it. Unknown keys are an error, so typos don't go unnoticed. it. Unknown keys are an error, so typos don't go unnoticed. `e` on the
status pane (`0`) opens it in your editor, as in lazygit (making it if
it isn't there yet); changes apply when lazysecrets starts again.
```yaml ```yaml
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0 icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
@@ -15,6 +17,9 @@ loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s
sourcesView: grouped # or flat; ` toggles it sourcesView: grouped # or flat; ` toggles it
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one 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) updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way)
releases: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
- https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets
history: true # count uses of items for Recent (R) history: true # count uses of items for Recent (R)
cache: true # keep what was loaded (never values) for the next start cache: true # keep what was loaded (never values) for the next start
providers: providers:
@@ -32,9 +37,15 @@ providers:
paths: [~/work/infra] paths: [~/work/infra]
include: ["secrets/**"] include: ["secrets/**"]
exclude: ["**/testdata/**"] exclude: ["**/testdata/**"]
share: # sharing env files as encrypted links
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none
keyRules: # what a key's value must be
minLength: 32 # characters, 16 at least
minClasses: 2 # kinds: lowercase, uppercase, digits, symbols
``` ```
More, each on its own page: [repos](repos.md) (`providers.repos`), the More, each on its own page: [repos](repos.md) (`providers.repos`),
[sharing env files](links.md#sharing-env-files) (`share`), the
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`), [link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and [keybindings](keys.md#rebinding-keys) (`keybinding`) and
[custom commands](custom-commands.md) (`customCommands`). [custom commands](custom-commands.md) (`customCommands`).
@@ -56,7 +67,7 @@ theme:
activeBorderColor: ["#ff966c", bold] # focused pane activeBorderColor: ["#ff966c", bold] # focused pane
inactiveBorderColor: ["#589ed7"] inactiveBorderColor: ["#589ed7"]
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8) selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
inactiveViewSelectedLineBgColor: ["#2f334d"] inactiveViewSelectedLineBgColor: ["#2f334d"] # in the other panes (default: palette 8, not bold)
defaultFgColor: ["#c8d3f5"] defaultFgColor: ["#c8d3f5"]
optionsTextColor: ["#82aaff"] # keys and labels optionsTextColor: ["#82aaff"] # keys and labels
searchMatchColor: ["#ffc777", bold] searchMatchColor: ["#ffc777", bold]
@@ -125,14 +136,17 @@ are read from disk anyway. Deleting the file is always safe, and
## Updates ## Updates
Once a day (`updateCheck`), in the background, lazysecrets asks the Once a day (`updateCheck`), in the background, lazysecrets asks for the
release server for the latest release; within the day it reuses the latest release; within the day it reuses the answer from the state file.
answer from the state file. Offline, or with the server down, it says Releases are published on GitHub and on git.kebcloud.no, and it asks
nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or them in that order, the next when one doesn't answer within 5 seconds;
`releases` lists others, such as a mirror of your own. Offline, or with
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
`lazysecrets update --check`) shows its changelog. `lazysecrets update --check`) shows its changelog.
Updating (`u` there, or `lazysecrets update`) downloads the release for Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU, checks it against the release's `checksums.txt`, makes your OS and CPU from the first server that has it, checks it against
that server's `checksums.txt`, makes
sure it runs, and only then replaces the binary, atomically: a sure it runs, and only then replaces the binary, atomically: a
lazysecrets that's running keeps working, and the next start is the new lazysecrets that's running keeps working, and the next start is the new
version. A binary a package manager owns (Homebrew, Nix, a distro version. A binary a package manager owns (Homebrew, Nix, a distro
+3 -3
View File
@@ -13,9 +13,9 @@ Secrets in Azure Key Vault-backed scopes are read through Databricks; if
Databricks refuses, lazysecrets reads the backing vault directly with your Databricks refuses, lazysecrets reads the backing vault directly with your
az login (which then needs the [Key Vault roles](azure.md#permissions)). az login (which then needs the [Key Vault roles](azure.md#permissions)).
On a scope, the right pane shows who has access to it and how much When Databricks refuses, the pane says what to check: access to the
(manage, write, read). Databricks shows that only to the scope's workspace and READ on the scope, or, when the workspace's IP access list
managers; to others it says so. refused, the network you're on.
Hide profiles you don't want in the [config](config.md): Hide profiles you don't want in the [config](config.md):
+11 -2
View File
@@ -7,6 +7,11 @@ go test ./...
go test ./internal/ui/app -run TestGolden -update # after intentional UI changes go test ./internal/ui/app -run TestGolden -update # after intentional UI changes
``` ```
In `--demo`, sharing env files works end to end: `S` → `f` on a demo
repo, pick `kv-shared-prod / env-share` (the second account's) as the
key, then paste the link back. The demo repos aren't on disk, so opening
asks where to save, as for a repo you haven't cloned.
A stripped binary is about 21 MB instead of 30 MB: A stripped binary is about 21 MB instead of 30 MB:
```sh ```sh
@@ -56,8 +61,12 @@ from the pull request titles. The tag runs the release workflow
(`.gitea/workflows/release.yml`): it tests, then (`.gitea/workflows/release.yml`): it tests, then
[GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the [GoReleaser](https://goreleaser.com) (`.goreleaser.yaml`) builds the
archives and publishes a Gitea release with them and the release's archives and publishes a Gitea release with them and the release's
changelog section. `goreleaser release --snapshot --clean` builds the same changelog section. Then `scripts/github-release.mjs` copies that release,
archives locally, into `dist/`, without publishing. notes and files, to the GitHub mirror, with the `GH_RELEASE_TOKEN`
secret (a fine-grained token that can write the mirror's contents);
without the secret it skips that. `goreleaser release --snapshot
--clean` builds the same archives locally, into `dist/`, without
publishing.
A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md` A suffix (`v0.2.0-rc.1`) makes a pre-release, which leaves `CHANGELOG.md`
alone. alone.
+30 -17
View File
@@ -49,17 +49,22 @@ match case exactly.
| Key | Action | | Key | Action |
|---|---| |---|---|
| `space` | reveal / hide the selected value (public material for keys and certificates) | | `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 or group | | `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}}`) | | `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 | | `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) | | `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)) | | `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 | | `s` / `S` | copy a [link](links.md) to the selected item / share menu (also env files, [encrypted](links.md#sharing-env-files)) |
| `ctrl+o` | open a link or reference (or just paste one) | | `ctrl+o` | open a link or reference (or just paste one) |
| `x` | [custom commands](custom-commands.md) | | `x` | [custom commands](custom-commands.md) |
| `L` | sign in again (shown when a login has expired) | | `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 | | `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 A revealed value hides again after `revealTimeout` (60s) and when the
terminal loses focus (`hideOnBlur`); see [config](config.md). terminal loses focus (`hideOnBlur`); see [config](config.md).
@@ -79,20 +84,24 @@ under every account. Both are kept in the [state file](config.md#state).
starred) and `R` on Recent (what you used last). `enter` goes to the starred) and `R` on Recent (what you used last). `enter` goes to the
selected item. selected item.
Search has the scope on the left (All; all of Azure, Databricks or repos; Search has the scope on the left and what's in it on the right. The
or one source; with `○ ◐ ●` for how much of it is loaded) and what's in scopes are All, then grouped like the Sources pane: each account (or
it on the right. All repos searches every repo without asking Azure or Databricks, or Repos) with its sources under it, with `○ ◐ ●` for how
Databricks for anything. It opens on the much of each is loaded. Repos searches every repo without asking Azure
source you're in. Typing a search loads what it covers: everything on or Databricks for anything. Go to opens on All; `ctrl+s` again narrows
All, else the source picked; results appear as lists arrive. That's once it to the source or group selected in the Sources pane, and once more
each time Go to opens, and only what isn't loaded yet; `ctrl+a` loads goes back to All. Typing a search loads what it covers: everything on
again, lists that failed included. 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: Like a pane, Go to is either browsing or typing a filter:
| Browsing | Typing | Action | | Browsing | Typing | Action |
|---|---|---| |---|---|---|
| `j`/`k` | `ctrl+j`/`ctrl+k` | move | | `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 | | `h`/`l`, `tab` | `ctrl+h`/`ctrl+l`, `tab` | scope list / results |
| `[` / `]` | | previous / next tab | | `[` / `]` | | previous / next tab |
| `f` | `ctrl+f` | star | | `f` | `ctrl+f` | star |
@@ -113,13 +122,17 @@ Secrets. Going into it (`l`, or `4`) keeps it on that; there `j`/`k`,
it's hidden. it's hidden.
On top, the status pane (`0`) shows the version, a newer release and how On top, the status pane (`0`) shows the version, a newer release and how
your accounts are doing. Focused, the right pane lists more: the version your accounts are doing; `e` there opens your [config](config.md) in your
and updates (`U`), each account (`L` on one that needs it signs in), the editor, as in lazygit. Focused, the right pane lists more under the
config, state and cache files (`e` opens one in your editor), and links logo (when there's room): the version and updates (`U`), each account
to the docs, source, issues, releases and changelog (`o` opens one; on (`L` on one that needs it signs in), what's expiring (`enter` goes to
the version or update, its release), then the changelog of it), this session's errors, newest first (what a pane showed, or the
every release up to yours, built in so it needs no network: `/` filters footer flashed before you moved on; not kept after quitting), the config,
it (`/fixed`, `/search`). `y` copies any row. 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 | | Key | Action |
|---|---| |---|---|
+82 -8
View File
@@ -1,9 +1,10 @@
# Links # Links
Every subscription, workspace, vault, scope, repo, file and secret has a Subscriptions, workspaces, vaults, scopes, repos, files and secrets can
`lazysecrets://` link. Share one in chat or a ticket, and whoever opens it be shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
lands on that item, reading it with their own account and permissions: it lands on that item, reading it with their own account and
links carry names, never values. permissions: the link names the item, never its value. Env files can
also be [shared encrypted](#sharing-env-files), in the link itself.
``` ```
lazysecrets://azure/kv-app-prod.vault.azure.net vault lazysecrets://azure/kv-app-prod.vault.azure.net vault
@@ -20,10 +21,83 @@ lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
that version). `S` opens the share menu: that version). `S` opens the share menu:
- secrets: the link (`o`), the plain Key Vault URL (`u`), the config - secrets: the link (`o`), the plain Key Vault URL (`u`), the config
reference (`y`) or the web page (`b`) reference (`y`) or the web page (`b`); in an env file, also the file as
an [encrypted link](#sharing-env-files) (`f`)
- vaults and scopes: a link (`o`), a link that opens on the current tab - 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`) (`t`), the plain vault URL (`u`) or the web page (`b`)
- subscriptions and workspaces: a link (`o`) or the web page (`b`) - subscriptions and workspaces: a link (`o`) or the web page (`b`)
- repos and env files: also their env files as an
[encrypted link](#sharing-env-files) (`f`)
## Sharing env files
`.env` files usually live only on your machine. `S` → `f` shares them as
an encrypted link, on a repo (all its plain env files), an env file, or a
key in one (its file):
```
lazysecrets://share/AQ…
```
The files are encrypted with a secret's value, so whoever can read that
secret (in Key Vault, Databricks or a repo's SOPS file) can open the link,
and nobody else: who may open it is up to the vault's access, as for
everything else. Make a secret for it once, random:
```sh
az keyvault secret set --vault-name kv-team --name env-share --value "$(openssl rand -base64 32)"
```
and name it in the repo's `.lazysecrets.yaml` (committed: the whole team
uses it) or your config (for repos that don't name one), by its
`lazysecrets://` link, Key Vault URL or `@Microsoft.KeyVault(...)`
reference:
```yaml
share:
key: https://kv-team.vault.azure.net/secrets/env-share
```
With neither, sharing asks which secret, in [Go to](keys.md#go-to): it
lists only secrets, and `enter` picks one (pasting a reference picks it
too).
Anyone with a link can try to guess the key, so sharing refuses one that
isn't long and varied enough, saying how (never showing it). By default
that's 32 characters of two kinds; your config can ask for more, or less:
```yaml
share:
keyRules:
minLength: 32 # characters; 16 at least
minClasses: 2 # kinds of character: lowercase, uppercase, digits, symbols
```
Opening a link doesn't check them.
On the command line, `lazysecrets share [--key <secret>] <env file>...`
prints the link.
Opening a share link says which repo it's from, how many files and which
secret it's encrypted with, and decrypts only when asked: lazysecrets
reads the secret with your access, then shows where each file goes in
your clone of the repo (or asks for a folder, if it isn't here), which
ones would replace a file there and which ones git doesn't ignore. It
saves them all, only the new ones, or none. New files are readable by you
only.
A link holds the files' paths and contents encrypted ([age](https://age-encryption.org),
the secret's value as passphrase); the repo, the secret and the number of
files are in the clear. Links stay under 16 KB, which fits chat and
tickets.
A link stays readable as long as its secret is. On Azure it's pinned to
the secret's version, so adding a new version (rotating) doesn't break
old links, and disabling the old version, or taking away someone's
access, shuts them out; Databricks secrets have no versions, so changing
the value breaks every old link. Whoever has read the secret's value
before can decrypt links made with it without asking the vault, so rotate
it now and then: new links use the newest version.
## Opening ## Opening
@@ -44,9 +118,9 @@ read the clipboard over SSH.
lazysecrets finds the vault in your accounts (one Resource Graph query per 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 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 it, ending on a secret's details as `enter` on it would. Links say what,
to just go to it, also copy or show its value, or (`y`) copy its value not what to do: opening a link to a secret asks whether to just go to it,
without going there. also copy or show its value, or (`y`) copy its value without going there.
## In scripts ## In scripts
+10
View File
@@ -55,6 +55,14 @@ providers:
exclude: ["**/testdata/**"] exclude: ["**/testdata/**"]
``` ```
A repo's `.lazysecrets.yaml` can also name the secret its env files are
[shared](links.md#sharing-env-files) with:
```yaml
share:
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
```
## Decrypting ## Decrypting
Listing never decrypts: SOPS encrypts values only, so keys and metadata Listing never decrypts: SOPS encrypts values only, so keys and metadata
@@ -90,6 +98,8 @@ sops 3.8 or later.
## Links ## Links
`S` → `f` shares env files as an [encrypted link](links.md#sharing-env-files).
Links name the repo by its git remote, so they open in a colleague's clone Links name the repo by its git remote, so they open in a colleague's clone
wherever it is: wherever it is:
+35 -35
View File
@@ -1,41 +1,43 @@
module git.kebcloud.no/keb/lazysecrets module git.kebcloud.no/keb/lazysecrets
go 1.26.4 go 1.27.2
require ( require (
charm.land/bubbles/v2 v2.2.1 charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.0.10 charm.land/bubbletea/v2 v2.1.0
charm.land/lipgloss/v2 v2.0.6 charm.land/lipgloss/v2 v2.0.6
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 filippo.io/age v1.3.2
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.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/keyvault/armkeyvault/v2 v2.1.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.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/resourcemanager/resources/armsubscriptions/v2 v2.0.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/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/azkeys v1.5.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets 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/atotto/clipboard v0.1.4
github.com/charmbracelet/x/ansi v0.11.8 github.com/charmbracelet/x/ansi v0.11.9
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f
github.com/databricks/databricks-sdk-go v0.187.0 github.com/databricks/databricks-sdk-go v0.187.0
github.com/junegunn/fzf v0.74.4 github.com/junegunn/fzf v0.74.4
go.yaml.in/yaml/v3 v3.0.5 go.yaml.in/yaml/v3 v3.0.5
golang.org/x/mod v0.38.0 golang.org/x/mod v0.42.0
golang.org/x/term v0.45.0 golang.org/x/term v0.47.0
gopkg.in/ini.v1 v1.67.0 gopkg.in/ini.v1 v1.67.3
) )
require ( require (
cloud.google.com/go/auth v0.4.2 // indirect cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect filippo.io/hpke v0.4.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.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/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect
github.com/aymanbagabas/go-udiff v0.4.1 // indirect github.com/aymanbagabas/go-udiff v0.4.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -45,37 +47,35 @@ require (
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // 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/go-querystring v1.1.0 // indirect
github.com/google/s2a-go v0.1.7 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.27 // indirect github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v1.0.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // 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/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/crypto v0.55.0 // indirect golang.org/x/crypto v0.57.0 // indirect
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
golang.org/x/net v0.58.0 // indirect golang.org/x/net v0.60.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.182.0 // indirect google.golang.org/api v0.264.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.79.3 // indirect google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
+87 -160
View File
@@ -1,36 +1,41 @@
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= 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/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.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc= charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= 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.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.4.2 h1:sb0eyLkhRtpq5jA+a8KWw0W70YcdVca7KJ8TM0AFYDg= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/auth v0.4.2/go.mod h1:Kqvlz1cf1sNA0D+sYJnkPQOP+JMHkuHeIgVmCRtZOLc= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
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 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= 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= filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU= filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 h1:qgnWTiWs1G7AqcKZegFvkJD1bmXJcaDU3uC0ELSgZ5U=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3/go.mod h1:XtxD48QPInqoj+GMlcRlXHEYsoOxFnP6w2JgIZ9rDS4=
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 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 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 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/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.13.0 h1:Terj1CMW4+NY5CpWjSRkqLoxh2DkYDdv/FH2z0897Uc=
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/internal v1.13.0/go.mod h1:7Ut2Qw9vcwpLW5Q4MBy43uSxXS7tK2yjs94OJ6j5thE=
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/v3 v3.2.0 h1:+lnLQhKh3cgSOIOVH61UZ3s/l9d+bAZp5d/spt1+7UI=
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.2.0/go.mod h1:tStOHrivWUrcBolspvKV70Us1ckESYGYSHdG4LX8zyY=
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/keyvault/armkeyvault/v2 v2.1.0 h1:4dT2wTR+ViaGvwcAoMjqt0BvgwBkT6nXxg8flJhnLfw=
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/v2 v2.1.0/go.mod h1:7fgu6AtY2gkWhtZt9SEHK/MkTVUn2HPvAZjv6XsrjuI=
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 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/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/armdeployments v1.0.0 h1:67nFqWXpo0x5Nz0XEb1yI7s8D+EHy8NsTinYw9sZnLk=
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/armdeployments v1.0.0/go.mod h1:fewgRjNVE84QVVh798sIMFb7gPXPp7NmnekGnboSnXk=
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/armresources/v3 v3.0.1 h1:guyQA4b8XB2sbJZXzUnOF9mn0WDBv/ZT7me9wTipKtE=
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/resourcemanager/resources/armresources/v3 v3.0.1/go.mod h1:8h8yhzh9o+0HeSIhUxYny+rEQajScrfIpNktvgYG3Q8=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2 v2.0.0 h1:TPaLKUUZw3XUaQy5aU+ApHXFKB2VnTWUA0xufkvFDE8=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2 v2.0.0/go.mod h1:Rn/a97OKDAoOGvk5/OFjqObHHqWyoMMVSGtikqbKcAg=
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 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/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 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM=
@@ -43,20 +48,18 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= 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 h1:Nljr4q1GRA/5vCrMONS+g4u4LRHNgOXVSh3O43J2CnI=
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0/go.mod h1:Y33QHnf0FfdVewFFISOGe20mkZbxX4H839o955/PoeI= 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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= 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-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
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 h1:8CnFOYzrMArVN42jYaGvnBo3mxdONgt09fly+9B96GY=
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw= 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 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -65,20 +68,14 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 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 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= 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 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= 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 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= 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 h1:V1rGbEUaEdGS7tTZSyplDLnE2Vp5N1aIPiIh/LzjOC8=
github.com/databricks/databricks-sdk-go v0.187.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8= 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.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/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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -88,42 +85,21 @@ 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/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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 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.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg= github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/junegunn/fzf v0.74.4 h1:DSqIW3ZxtkuuPzeLKrVnHdoWqT9VCIGRbjc/lXiGCso= github.com/junegunn/fzf v0.74.4 h1:DSqIW3ZxtkuuPzeLKrVnHdoWqT9VCIGRbjc/lXiGCso=
github.com/junegunn/fzf v0.74.4/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM= 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 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
@@ -136,128 +112,79 @@ github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBql
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= 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.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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 h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 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/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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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.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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= 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 v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v1.0.0/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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 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.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
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.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= 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= 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.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
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 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= 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/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
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= 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.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.182.0 h1:if5fPvudRQ78GeRx3RayIoiuV7modtErPIZC/T2bIvE= google.golang.org/api v0.264.0 h1:+Fo3DQXBK8gLdf8rFZ3uLu39JpOnhvzJrLMQSoSYZJM=
google.golang.org/api v0.182.0/go.mod h1:cGhjy4caqA5yXRzEhkHI8Y9mfyC2VLTlER2l08xaqtM= google.golang.org/api v0.264.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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/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.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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= 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=
+40 -14
View File
@@ -7,10 +7,12 @@
# #
# LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64 # LAZYSECRETS_INSTALL_DIR installs somewhere else. macOS and Linux, arm64
# and x86_64. # and x86_64.
#
# Releases are on GitHub and git.kebcloud.no, tried in that order;
# LAZYSECRETS_RELEASES (repository addresses, space-separated) uses others.
set -eu set -eu
repo=https://git.kebcloud.no/keb/lazysecrets repos="${LAZYSECRETS_RELEASES:-https://github.com/karleinarb/lazysecrets https://git.kebcloud.no/keb/lazysecrets}"
api=https://git.kebcloud.no/api/v1/repos/keb/lazysecrets
dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}" dir="${LAZYSECRETS_INSTALL_DIR:-$HOME/.local/bin}"
fail() { fail() {
@@ -18,12 +20,13 @@ fail() {
exit 1 exit 1
} }
# Short timeouts, so a server that's down leaves time for the next.
if command -v curl >/dev/null 2>&1; then if command -v curl >/dev/null 2>&1; then
fetch() { curl -fsSL "$1"; } fetch() { curl -fsSL --connect-timeout 5 --max-time 15 "$1"; }
download() { curl -fsSL -o "$2" "$1"; } download() { curl -fsSL --connect-timeout 5 -o "$2" "$1"; }
elif command -v wget >/dev/null 2>&1; then elif command -v wget >/dev/null 2>&1; then
fetch() { wget -qO- "$1"; } fetch() { wget -qO- -T 15 "$1"; }
download() { wget -qO "$2" "$1"; } download() { wget -qO "$2" -T 30 "$1"; }
else else
fail "needs curl or wget" fail "needs curl or wget"
fi fi
@@ -39,11 +42,26 @@ arm64 | aarch64) arch=arm64 ;;
*) fail "no release for $(uname -m)" ;; *) fail "no release for $(uname -m)" ;;
esac esac
# api is a repository's API: GitHub's, or Gitea's (and Forgejo's).
api() {
case "$1" in
https://github.com/*) echo "https://api.github.com/repos/${1#https://github.com/}" ;;
*)
host=${1#*://}
host=${host%%/*}
echo "${1%%://*}://$host/api/v1/repos/${1#*://"$host"/}"
;;
esac
}
tag="${1:-}" tag="${1:-}"
if [ -z "$tag" ]; then if [ -z "$tag" ]; then
tag=$(fetch "$api/releases/latest" | sed -n 's/.*"tag_name":"\([^"]*\)".*/\1/p') || for repo in $repos; do
fail "couldn't look up the latest release" tag=$(fetch "$(api "$repo")/releases/latest" 2>/dev/null |
[ -n "$tag" ] || fail "couldn't look up the latest release" sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || tag=
[ -n "$tag" ] && break
done
[ -n "$tag" ] || fail "couldn't look up the latest release on $repos"
fi fi
case "$tag" in case "$tag" in
v*) ;; v*) ;;
@@ -55,11 +73,19 @@ name="lazysecrets_${version}_${os}_${arch}"
tmp=$(mktemp -d) tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT trap 'rm -rf "$tmp"' EXIT
echo "Downloading lazysecrets $tag for $os/$arch" # The archive and its checksums.txt from the first server that has both.
download "$repo/releases/download/$tag/$name.tar.gz" "$tmp/$name.tar.gz" || from=
fail "couldn't download $tag for $os/$arch: see $repo/releases" for repo in $repos; do
download "$repo/releases/download/$tag/checksums.txt" "$tmp/checksums.txt" || base="$repo/releases/download/$tag"
fail "couldn't download $tag's checksums.txt" if download "$base/checksums.txt" "$tmp/checksums.txt" 2>/dev/null; then
echo "Downloading lazysecrets $tag for $os/$arch from $repo"
if download "$base/$name.tar.gz" "$tmp/$name.tar.gz"; then
from=$repo
break
fi
fi
done
[ -n "$from" ] || fail "couldn't download $tag for $os/$arch from $repos"
want=$(awk -v f="$name.tar.gz" '$2 == f { print $1 }' "$tmp/checksums.txt") 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" [ -n "$want" ] || fail "$name.tar.gz isn't in checksums.txt"
+41
View File
@@ -14,6 +14,8 @@ import (
"go.yaml.in/yaml/v3" "go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/update"
) )
type Config struct { type Config struct {
@@ -40,6 +42,10 @@ type Config struct {
// background; defaults to true. U and lazysecrets update work either // background; defaults to true. U and lazysecrets update work either
// way. // way.
UpdateCheck *bool `yaml:"updateCheck"` UpdateCheck *bool `yaml:"updateCheck"`
// Releases are the repositories updates come from, tried in order:
// on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, then
// git.kebcloud.no (update.DefaultServers).
Releases []string `yaml:"releases"`
// Cache keeps what was loaded (never values) between runs, so lists // Cache keeps what was loaded (never values) between runs, so lists
// show at once and refresh in the background; defaults to true. // show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"` Cache *bool `yaml:"cache"`
@@ -49,6 +55,8 @@ type Config struct {
Providers Providers `yaml:"providers"` Providers Providers `yaml:"providers"`
// LinkHandler sets how lazysecrets:// links open; see linkhandler. // LinkHandler sets how lazysecrets:// links open; see linkhandler.
LinkHandler LinkHandler `yaml:"linkHandler"` LinkHandler LinkHandler `yaml:"linkHandler"`
// Share sets how env files are shared; see package share.
Share Share `yaml:"share"`
// CustomCommands run shell commands on the selected item; see // CustomCommands run shell commands on the selected item; see
// package commands. // package commands.
CustomCommands []commands.Command `yaml:"customCommands"` CustomCommands []commands.Command `yaml:"customCommands"`
@@ -77,6 +85,23 @@ type LinkHandler struct {
Prompt *bool `yaml:"prompt"` Prompt *bool `yaml:"prompt"`
} }
type Share struct {
// Key is the secret whose value encrypts shared files (its
// lazysecrets:// link, Key Vault URL or reference), for repos whose
// .lazysecrets.yaml doesn't name one.
Key string `yaml:"key"`
// KeyRules are what a key's value must be to share with it.
KeyRules KeyRules `yaml:"keyRules"`
}
// KeyRules default to 32 characters of 2 kinds (share.DefaultRules).
type KeyRules struct {
MinLength int `yaml:"minLength"` // at least 16
// MinClasses is how many kinds of character: lowercase, uppercase,
// digits, symbols.
MinClasses int `yaml:"minClasses"`
}
type Providers struct { type Providers struct {
Azure Azure `yaml:"azure"` Azure Azure `yaml:"azure"`
Databricks Databricks `yaml:"databricks"` Databricks Databricks `yaml:"databricks"`
@@ -211,6 +236,22 @@ func (c Config) validate(path string) error {
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) { 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) return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
} }
for i, r := range c.Releases {
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
}
}
if k := c.Share.Key; k != "" {
if _, err := share.ParseKey(k); err != nil {
return fmt.Errorf("%s: share.key: %w", path, err)
}
}
if n := c.Share.KeyRules.MinLength; n != 0 && n < 16 {
return fmt.Errorf("%s: share.keyRules.minLength: %d is under 16", path, n)
}
if n := c.Share.KeyRules.MinClasses; n < 0 || n > 4 {
return fmt.Errorf("%s: share.keyRules.minClasses: %d is not between 1 and 4", path, n)
}
for i, v := range c.Providers.Azure.Vaults { for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") { if !strings.HasPrefix(v, "https://") {
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v) return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
+13
View File
@@ -70,6 +70,12 @@ func TestLoadRejectsMistakes(t *testing.T) {
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n", "old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
"duration": "timeout: soon\n", "duration": "timeout: soon\n",
"view": "sourcesView: tree\n", "view": "sourcesView: tree\n",
"releases": "releases: [git.example.com/team/lazysecrets]\n",
"http": "releases: [http://git.example.com/team/lazysecrets]\n",
"share key": "share:\n key: https://kv.vault.azure.net/\n",
"workspace": "share:\n key: \"{{secrets/team/env-share}}\"\n",
"short keys": "share:\n keyRules:\n minLength: 8\n",
"classes": "share:\n keyRules:\n minClasses: 5\n",
} { } {
if _, err := Load(write(t, content)); err == nil { if _, err := Load(write(t, content)); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
@@ -113,6 +119,13 @@ theme:
} }
} }
func TestReleases(t *testing.T) {
c, err := Load(write(t, "releases: [https://git.example.com/team/lazysecrets]\n"))
if err != nil || len(c.Releases) != 1 {
t.Errorf("releases = %v, %v", c.Releases, err)
}
}
func TestLoadDelay(t *testing.T) { func TestLoadDelay(t *testing.T) {
c, err := Load(write(t, "loadDelay: 300ms\n")) c, err := Load(write(t, "loadDelay: 300ms\n"))
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond { if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
-70
View File
@@ -1,15 +1,10 @@
package azure package azure
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"net/http"
"slices" "slices"
"strings" "strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -160,68 +155,3 @@ func directoryRoles(wids any) string {
slices.Sort(names) slices.Sort(names)
return strings.Join(names, ", ") return strings.Join(names, ", ")
} }
// graphGroups lists the signed-in user's groups, nested ones too, from
// Microsoft Graph with az's token for it.
const graphGroups = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf?$select=displayName&$top=999"
var _ provider.AccountGroups = (*Provider)(nil)
// Groups lists the groups the account is a member of, by name.
func (p *Provider) Groups(ctx context.Context) ([]string, error) {
if p.account == nil || (p.account.cloud != "" && p.account.cloud != "AzureCloud") {
return nil, provider.ErrUnsupported
}
tok, err := p.cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{"https://graph.microsoft.com/.default"}})
if err != nil {
return nil, err
}
var names []string
for next := graphGroups; next != ""; {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+tok.Token)
resp, err := p.http().Do(req)
if err != nil {
return nil, err
}
var page struct {
Value []struct {
Type string `json:"@odata.type"`
Name string `json:"displayName"`
} `json:"value"`
Next string `json:"@odata.nextLink"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
err = json.NewDecoder(resp.Body).Decode(&page)
resp.Body.Close()
switch {
case err != nil:
return nil, fmt.Errorf("listing groups from Microsoft Graph: %w", err)
case page.Error != nil:
return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", page.Error.Message)
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", resp.Status)
}
for _, v := range page.Value {
if v.Type == "#microsoft.graph.group" && v.Name != "" {
names = append(names, v.Name)
}
}
next = page.Next
}
slices.SortFunc(names, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) })
return names, nil
}
// http is the client for Microsoft Graph; tests replace it.
func (p *Provider) http() *http.Client {
if p.graphClient != nil {
return p.graphClient
}
return http.DefaultClient
}
-47
View File
@@ -1,17 +1,7 @@
package azure package azure
import ( import (
"context"
"errors"
"io"
"net/http"
"slices"
"strings"
"testing" "testing"
"time"
"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" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -75,40 +65,3 @@ func TestSignInMethods(t *testing.T) {
} }
} }
} }
type staticToken struct{}
func (staticToken) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
return azcore.AccessToken{Token: "tok", ExpiresOn: time.Now().Add(time.Hour)}, nil
}
type graphPages map[string]string // by URL
func (g graphPages) RoundTrip(r *http.Request) (*http.Response, error) {
body, ok := g[r.URL.String()]
if !ok || r.Header.Get("Authorization") != "Bearer tok" {
return &http.Response{StatusCode: 404, Status: "404 Not Found", Body: io.NopCloser(strings.NewReader(`{}`))}, nil
}
return &http.Response{StatusCode: 200, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil
}
func TestGroups(t *testing.T) {
next := "https://graph.microsoft.com/v1.0/next"
p := &Provider{
cred: staticToken{},
account: &accountDetails{account: account{user: "alice@contoso.com"}},
graphClient: &http.Client{Transport: graphPages{
graphGroups: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"vault readers"},
{"@odata.type":"#microsoft.graph.directoryRole","displayName":"Global Reader"}],"@odata.nextLink":"` + next + `"}`,
next: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"Platform team"}]}`,
}},
}
got, err := p.Groups(context.Background())
if err != nil || !slices.Equal(got, []string{"Platform team", "vault readers"}) {
t.Errorf("groups %q, %v", got, err)
}
p.account.cloud = "AzureChinaCloud"
if _, err := p.Groups(context.Background()); !errors.Is(err, provider.ErrUnsupported) {
t.Errorf("other cloud: %v", err)
}
}
+41 -17
View File
@@ -9,7 +9,6 @@ import (
"crypto/x509" "crypto/x509"
"errors" "errors"
"fmt" "fmt"
"net/http"
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
@@ -18,9 +17,9 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore" "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/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "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/keyvault/armkeyvault/v2"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" "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/resourcemanager/resources/armsubscriptions/v2"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates" "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/azkeys"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
@@ -37,12 +36,11 @@ var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, p
type Provider struct { type Provider struct {
id, label string // "" for the single-account defaults id, label string // "" for the single-account defaults
cred azcore.TokenCredential cred azcore.TokenCredential
opts options opts options
account *accountDetails // nil for a service principal from AZURE_* account *accountDetails // nil for a service principal from AZURE_*
graphClient *http.Client // for Groups; nil for the default subs *armsubscriptions.Client
subs *armsubscriptions.Client graph *armresourcegraph.Client
graph *armresourcegraph.Client
mu sync.Mutex mu sync.Mutex
vaults map[string]*vaultClients // by vault URL vaults map[string]*vaultClients // by vault URL
@@ -301,7 +299,7 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
if s.ID == nil || deref(s.Managed) { if s.ID == nil || deref(s.Managed) {
continue continue
} }
out = append(out, secretObject(c, s)) out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
} }
} }
case provider.TypeKey: case provider.TypeKey:
@@ -394,7 +392,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
} }
for _, s := range page.Value { for _, s := range page.Value {
if s.ID != nil { if s.ID != nil {
out = append(out, secretObject(o.Container, s)) out = append(out, secretObject(o.Container, s, p.tenant(o.Container.Source.ID)))
} }
} }
} }
@@ -427,8 +425,13 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
default: default:
return nil, provider.ErrUnsupported return nil, provider.ErrUnsupported
} }
tenant := p.tenant(o.Container.Source.ID)
for i := range out { for i := range out {
out[i].Reference = versionedReference(out[i]) out[i].Reference = versionedReference(out[i])
if o.Type != provider.TypeSecret { // secretObject's is the version's already
kind := map[provider.ObjectType]string{provider.TypeKey: "Key", provider.TypeCertificate: "Certificate"}[o.Type]
out[i].URL = portalAssetURL(tenant, kind, out[i].ID+"/"+out[i].Version) // the version's page
}
} }
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) }) slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
return out, nil return out, nil
@@ -455,11 +458,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
} }
switch o.Type { switch o.Type {
case provider.TypeSecret: case provider.TypeSecret:
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil) v, _, err := p.VersionedValue(ctx, o)
if err != nil { return v, err
return provider.SecretValue{}, cleanError(err)
}
return provider.NewSecretValue([]byte(deref(resp.Value))), nil
case provider.TypeKey: case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil) resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil { if err != nil {
@@ -483,7 +483,31 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return provider.SecretValue{}, provider.ErrUnsupported return provider.SecretValue{}, provider.ErrUnsupported
} }
var _ provider.Locator = (*Provider)(nil) // VersionedValue is Value, with the version read.
func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) {
if o.Type != provider.TypeSecret {
v, err := p.Value(ctx, o)
return v, o.Version, err
}
vc, err := p.clients(o.Container.Detail)
if err != nil {
return provider.SecretValue{}, "", err
}
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
version := o.Version
if resp.ID != nil {
version = resp.ID.Version()
}
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
}
var (
_ provider.Locator = (*Provider)(nil)
_ provider.Versioned = (*Provider)(nil)
)
// validHost guards the Resource Graph query against odd input. // validHost guards the Resource Graph query against odd input.
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`) var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
+28 -2
View File
@@ -30,6 +30,7 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "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/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" "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" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -175,8 +176,11 @@ func TestForbiddenIsShortened(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource."; got != want { 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) 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 var re *azcore.ResponseError
if !errors.As(err, &re) || re.StatusCode != 403 { if !errors.As(err, &re) || re.StatusCode != 403 {
@@ -184,6 +188,13 @@ func TestForbiddenIsShortened(t *testing.T) {
} }
} }
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) { 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'" 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" want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action"
@@ -324,6 +335,21 @@ func TestPortalURLs(t *testing.T) {
} }
} }
func TestSecretPages(t *testing.T) {
c := provider.Container{Detail: "https://kv-app.vault.azure.net/"}
base := "https://portal.azure.com/#@contoso.onmicrosoft.com/asset/Microsoft_Azure_KeyVault/Secret/https://kv-app.vault.azure.net/secrets/db"
for id, want := range map[string]string{
"https://kv-app.vault.azure.net/secrets/db": base, // listed: the secret's page
"https://kv-app.vault.azure.net/secrets/db/06140fa2b9a64869a96ef040f6d4dcab": base + "/06140fa2b9a64869a96ef040f6d4dcab", // a version's
} {
sid := azsecrets.ID(id)
o := secretObject(c, &azsecrets.SecretProperties{ID: &sid}, "contoso.onmicrosoft.com")
if o.URL != want {
t.Errorf("%s: %q, want %q", id, o.URL, want)
}
}
}
func TestVersionedReference(t *testing.T) { func TestVersionedReference(t *testing.T) {
sec := provider.Object{Type: provider.TypeSecret, Version: "v1", sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"} Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
+36 -5
View File
@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"math/big" "math/big"
"net/http" "net/http"
"regexp"
"strings" "strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore"
@@ -52,8 +53,8 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
} }
// portalAssetURL links to a Key Vault object's page; objectID must include // portalAssetURL links to a Key Vault object's page: a version's with
// the version. // one in objectID; a secret's own (its versions) without.
func portalAssetURL(tenant, kind, objectID string) string { func portalAssetURL(tenant, kind, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
} }
@@ -87,7 +88,10 @@ func objectURL(c provider.Container, collection, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
} }
func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object { // secretObject is a secret, or a version of one, from a listing. Its URL
// is its page in the portal: the secret's (with its versions), or with
// a version, that version's.
func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object {
name := p.ID.Name() name := p.ID.Name()
url := objectURL(c, "secrets", name) url := objectURL(c, "secrets", name)
o := provider.Object{ o := provider.Object{
@@ -99,7 +103,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.
ContentType: deref(p.ContentType), ContentType: deref(p.ContentType),
Tags: tags(p.Tags), Tags: tags(p.Tags),
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")", Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
URL: ObjectListURL(c, provider.TypeSecret), URL: portalAssetURL(tenant, "Secret", strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
} }
if a := p.Attributes; a != nil { if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires) o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
@@ -279,13 +283,24 @@ func cleanError(err error) error {
} }
} }
} }
return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err} 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 // shortenDenied reduces Key Vault's RBAC denial, which runs to several
// sentences plus caller and resource IDs, to its first sentence and the // sentences plus caller and resource IDs, to its first sentence and the
// missing action. // missing action.
func shortenDenied(msg string) string { 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: '" const marker = "Action: '"
i := strings.Index(msg, marker) i := strings.Index(msg, marker)
if i < 0 { if i < 0 {
@@ -317,4 +332,20 @@ func (e *serviceError) Error() string {
return s 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 } func (e *serviceError) Unwrap() error { return e.err }
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
+43 -3
View File
@@ -317,15 +317,52 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return v, nil 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") var errNoFallback = errors.New("no Key Vault fallback")
// combine reports both the Databricks and the Key Vault failure. An auth // combine reports both the Databricks and the Key Vault failure, with
// error from either stays reachable with errors.As. // both their checks after. An auth error from either stays reachable
// with errors.As.
func combine(dbxErr, kvErr error) error { func combine(dbxErr, kvErr error) error {
if errors.Is(kvErr, errNoFallback) { if errors.Is(kvErr, errNoFallback) {
return dbxErr return dbxErr
} }
return fmt.Errorf("%w; Key Vault fallback: %w", dbxErr, kvErr) 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 // classify turns authentication failures into *provider.AuthError: a 401
@@ -336,6 +373,9 @@ func classify(pr profile, err error) error {
} }
var api *apierr.APIError var api *apierr.APIError
if errors.As(err, &api) { if errors.As(err, &api) {
if api.StatusCode == 403 {
return &provider.Denied{Err: err, Checks: deniedChecks(pr, api.Message)}
}
if api.StatusCode != 401 { if api.StatusCode != 401 {
return err return err
} }
+20 -18
View File
@@ -140,13 +140,8 @@ func workspaceAPI(t *testing.T, status int) *httptest.Server {
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000}, map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
map[string]any{"key": "client-secret"}, map[string]any{"key": "client-secret"},
}}) }})
case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "app": case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "locked":
write(w, 200, map[string]any{"items": []any{ write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have READ permission on scope locked"})
map[string]any{"principal": "users", "permission": "READ"},
map[string]any{"principal": "admins", "permission": "MANAGE"},
}})
case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "locked":
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have MANAGE permission"})
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app": case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
write(w, 200, map[string]any{"key": q.Get("key"), write(w, 200, map[string]any{"key": q.Get("key"),
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))}) "value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
@@ -299,19 +294,26 @@ func TestClassify(t *testing.T) {
} }
} }
func TestScopeDetails(t *testing.T) { func TestDeniedSaysWhatToCheck(t *testing.T) {
p := newTestProvider(t, workspaceAPI(t, 0), nil) p := newTestProvider(t, workspaceAPI(t, 0), nil)
src := provider.Source{ID: "prod"} _, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret)
attrs, err := p.Details(context.Background(), provider.Container{Source: src, Name: "app"}) var d *provider.Denied
if err != nil { // The test profile signs in through az: that's the first check.
t.Fatal(err) 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)
} }
want := []provider.Attr{{Label: "access", Value: "admins · manage"}, {Value: "users · read"}} if got := deniedChecks(profile{authType: "pat"}, "denied"); strings.Contains(got[0], "az") {
if !reflect.DeepEqual(attrs, want) { t.Errorf("a token profile's checks %q", got)
t.Errorf("app: %+v", attrs)
} }
attrs, err = p.Details(context.Background(), provider.Container{Source: src, Name: "locked"}) 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") {
if err != nil || len(attrs) != 1 || !strings.Contains(attrs[0].Value, "managers") { t.Errorf("IP ACL checks %q", got)
t.Errorf("locked: %+v, %v", attrs, err) }
// 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)
} }
} }
-43
View File
@@ -1,43 +0,0 @@
package databricks
import (
"cmp"
"context"
"errors"
"slices"
"strings"
"github.com/databricks/databricks-sdk-go/apierr"
"github.com/databricks/databricks-sdk-go/service/workspace"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Details are who has access to a scope, and how much. Only the scope's
// managers may see that; to others it says so.
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
w, pr, err := p.client(c.Source.ID)
if err != nil {
return nil, err
}
acls, err := w.ListAclsAll(ctx, workspace.ListAclsRequest{Scope: c.Name})
var api *apierr.APIError
switch {
case errors.As(err, &api) && api.StatusCode == 403:
return []provider.Attr{{Label: "access", Value: "only the scope's managers can see who has it"}}, nil
case err != nil:
return nil, classify(pr, err)
case len(acls) == 0:
return []provider.Attr{{Label: "access", Value: "workspace admins only"}}, nil
}
rank := map[workspace.AclPermission]int{workspace.AclPermissionManage: 0, workspace.AclPermissionWrite: 1, workspace.AclPermissionRead: 2}
slices.SortFunc(acls, func(a, b workspace.AclItem) int {
return cmp.Or(cmp.Compare(rank[a.Permission], rank[b.Permission]), cmp.Compare(a.Principal, b.Principal))
})
out := make([]provider.Attr, len(acls))
for i, a := range acls {
out[i] = provider.Attr{Value: a.Principal + " · " + strings.ToLower(string(a.Permission))}
}
out[0].Label = "access"
return out, nil
}
+10 -4
View File
@@ -24,6 +24,7 @@ type Provider struct {
errs map[listKey]error errs map[listKey]error
values map[string]string // by object ID values map[string]string // by object ID
versions map[string][]provider.Object // by object ID versions map[string][]provider.Object // by object ID
details map[string][]provider.Attr // by container ID
} }
type listKey struct { type listKey struct {
@@ -101,7 +102,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
if err := p.wait(ctx); err != nil { if err := p.wait(ctx); err != nil {
return nil, err return nil, err
} }
if p.kind == provider.KindDatabricks { if p.kind != provider.KindAzure { // only Key Vault keeps versions
return nil, provider.ErrUnsupported return nil, provider.ErrUnsupported
} }
vs := p.versions[o.ID] vs := p.versions[o.ID]
@@ -133,13 +134,14 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return provider.NewSecretValue([]byte(v)), nil return provider.NewSecretValue([]byte(v)), nil
} }
// Locate finds the source holding the vault (by host) or the workspace. // Locate finds the source holding the vault (by host), the workspace or
// the repo (by its remote).
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) { func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
if err := p.wait(ctx); err != nil { if err := p.wait(ctx); err != nil {
return "", false, err return "", false, err
} }
for _, src := range p.sources { for _, src := range p.sources {
if p.kind == provider.KindDatabricks && src.Detail == host { if (p.kind == provider.KindDatabricks || p.kind == provider.KindRepo) && strings.EqualFold(src.Detail, host) {
return src.ID, true, nil return src.ID, true, nil
} }
for _, c := range p.containers[src.ID] { for _, c := range p.containers[src.ID] {
@@ -209,7 +211,7 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
if p.kind == provider.KindDatabricks { if p.kind == provider.KindDatabricks {
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ") vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
} }
if p.kind == provider.KindAzure || vault != detail { if p.kind == provider.KindAzure || (p.kind == provider.KindDatabricks && vault != detail) {
c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview" c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview"
} }
p.containers[src.ID] = append(p.containers[src.ID], c) p.containers[src.ID] = append(p.containers[src.ID], c)
@@ -330,6 +332,10 @@ func Azure(delay time.Duration) *Provider {
}, demoCert) }, demoCert)
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
// A key to share env files with (S → f): random, as share.keyRules wants.
p.add(shared, provider.TypeSecret, provider.Object{
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
+151
View File
@@ -0,0 +1,151 @@
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 := provider.DetailPlainEnv
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", "8f3c2a9d7e1b6f4a0c5d2e9b8a7f6c3d1e0b4a5f",
"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"
}
var _ provider.Contents = (*Provider)(nil)
// Contents is a plain env file as KEY=value lines, from its keys, so the
// demo repos' env files can be shared.
func (p *Provider) Contents(ctx context.Context, c provider.Container) ([]byte, error) {
if c.Detail != provider.DetailPlainEnv {
return nil, provider.ErrUnsupported
}
var b strings.Builder
for _, o := range p.objects[listKey{c.ID, provider.TypeSecret}] {
b.WriteString(o.Name + "=" + p.values[o.ID] + "\n")
}
return []byte(b.String()), ctx.Err()
}
+37 -10
View File
@@ -12,6 +12,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
) )
@@ -166,6 +167,13 @@ type Current interface {
Current(ctx context.Context) (Source, bool) Current(ctx context.Context) (Source, bool)
} }
// Versioned is implemented by providers whose secrets have versions: it
// is Value, also returning the version read (the current one when
// o.Version is empty), so a link can pin it.
type Versioned interface {
VersionedValue(ctx context.Context, o Object) (SecretValue, string, error)
}
// Locator is implemented by providers that can find where a container lives, // 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 // 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. // workspace host. It returns the ID of the source holding it.
@@ -181,6 +189,12 @@ type Files interface {
File(c Container, o *Object) (File, error) File(c Container, o *Object) (File, error)
} }
// Contents is implemented by providers whose containers are files, so
// they can be shared (package share): the file as it is.
type Contents interface {
Contents(ctx context.Context, c Container) ([]byte, error)
}
// File is a container's file, to open. // File is a container's file, to open.
type File struct { type File struct {
Path string Path string
@@ -202,17 +216,30 @@ type Account struct {
Copy, CopyLabel string 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 }
// DetailPlainEnv is the Detail of a container that is a plain (not
// encrypted) env file in a repo.
const DetailPlainEnv = "env · plain text"
// ContainerDetails is implemented by providers that can say more about a // ContainerDetails is implemented by providers that can say more about a
// container than its listing does: a vault's settings, who has access to // container than its listing does: how a file is encrypted and what git
// a scope, how a file is encrypted and what git knows of it. It may go // knows of it. ErrUnsupported means there's nothing more for c.
// over the network; ErrUnsupported means there's nothing more for c.
type ContainerDetails interface { type ContainerDetails interface {
Details(ctx context.Context, c Container) ([]Attr, error) Details(ctx context.Context, c Container) ([]Attr, error)
} }
// AccountGroups is implemented by providers that can list the groups
// their account is a member of, directly or through other groups. It
// goes over the network.
type AccountGroups interface {
Groups(ctx context.Context) ([]string, error)
}
+1 -1
View File
@@ -55,7 +55,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
} }
return out, nil return out, nil
} }
if last, ok := git("log", "-1", "--format=%h · %ar · %an: %s", "--", rel); ok && last != "" { if last, ok := git("log", "-1", "--format=%h · %ar", "--", rel); ok && last != "" {
out = append(out, provider.Attr{Label: "last commit", Value: last}) out = append(out, provider.Attr{Label: "last commit", Value: last})
} }
return out, nil return out, nil
+46 -6
View File
@@ -311,8 +311,8 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
c.Detail = "sops · " + strings.Join(meta.methods, ", ") c.Detail = "sops · " + strings.Join(meta.methods, ", ")
case isSops: case isSops:
return // a SOPS file where .sops.yaml says SOPS files don't go return // a SOPS file where .sops.yaml says SOPS files don't go
case isEnv(f): case IsEnv(f):
c.Detail = "env · plain text" c.Detail = provider.DetailPlainEnv
default: default:
return return
} }
@@ -412,6 +412,15 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return provider.NewSecretValue(stdout.Bytes()), nil return provider.NewSecretValue(stdout.Bytes()), nil
} }
// Contents reads the file c is.
func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, error) {
f, err := p.File(c, nil)
if err != nil {
return nil, err
}
return os.ReadFile(f.Path)
}
// File is the file c is, and the line o's key is on. // File is the file c is, and the line o's key is on.
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) { func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
path := c.ID path := c.ID
@@ -464,6 +473,16 @@ func (p *Provider) Locate(_ context.Context, host string) (string, bool, error)
return "", false, nil return "", false, nil
} }
// Root is the root of the git repo dir is in, and the repo's ID: its
// remote without scheme or .git (a repo link's host), else the root.
func Root(dir string) (root, id string, err error) {
root, err = gitRoot(dir)
if err != nil {
return "", "", fmt.Errorf("%s isn't in a git repository", dir)
}
return root, info(root, "").id, nil
}
// --- git --- // --- git ---
func gitRoot(dir string) (string, error) { func gitRoot(dir string) (string, error) {
@@ -568,6 +587,27 @@ func normalizeRemote(remote string) (string, string) {
type lazysecretsFile struct { type lazysecretsFile struct {
Include []string `yaml:"include"` Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"` Exclude []string `yaml:"exclude"`
Share struct {
// Key is the link to the secret that encrypts the repo's
// shared env files (package share).
Key string `yaml:"key"`
} `yaml:"share"`
}
// ShareKey is the share.key of the repo's .lazysecrets.yaml, if any.
func ShareKey(root string) (string, error) {
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", err
}
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
return f.Share.Key, nil
} }
// selector says which files to look at: user (the include/exclude of the // selector says which files to look at: user (the include/exclude of the
@@ -618,7 +658,7 @@ func envFiles(root string, tracked []string) []string {
dirs := map[string]bool{".": true} dirs := map[string]bool{".": true}
for _, f := range tracked { for _, f := range tracked {
isTracked[f] = true isTracked[f] = true
if isEnv(f) { if IsEnv(f) {
out = append(out, f) out = append(out, f)
} }
if d := path.Dir(f); !dirs[d] && !inPackages(f) { if d := path.Dir(f); !dirs[d] && !inPackages(f) {
@@ -632,7 +672,7 @@ func envFiles(root string, tracked []string) []string {
} }
for _, e := range entries { for _, e := range entries {
f := path.Join(d, e.Name()) f := path.Join(d, e.Name())
if e.Type().IsRegular() && !isTracked[f] && isEnv(f) { if e.Type().IsRegular() && !isTracked[f] && IsEnv(f) {
out = append(out, f) out = append(out, f)
} }
} }
@@ -663,10 +703,10 @@ func inPackages(f string) bool {
return false return false
} }
// isEnv reports whether a file is named like an env file with real values: // IsEnv reports whether a file is named like an env file with real values:
// .env, .env.local, prod.env; not .env.example and the like, nor one in // .env, .env.local, prod.env; not .env.example and the like, nor one in
// dependencies. // dependencies.
func isEnv(f string) bool { func IsEnv(f string) bool {
if inPackages(f) { if inPackages(f) {
return false return false
} }
+1 -1
View File
@@ -505,7 +505,7 @@ func TestFileDetails(t *testing.T) {
d := details("secrets/app.yaml") d := details("secrets/app.yaml")
if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" || if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" ||
!strings.Contains(d["last commit"].Value, "t: files") || d["encrypted"].Value == "" { !strings.HasSuffix(d["last commit"].Value, " ago") || strings.Contains(d["last commit"].Value, ":") || d["encrypted"].Value == "" {
t.Errorf("sops yaml: %+v", d) t.Errorf("sops yaml: %+v", d)
} }
if d := details("prod.env"); d["keys"].Value != "age age1xxxx" { if d := details("prod.env"); d["keys"].Value != "age age1xxxx" {
+103
View File
@@ -0,0 +1,103 @@
package ref
import (
"cmp"
"context"
"errors"
"fmt"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Read reads what r points at without listing its vault: the vault and
// name are enough for Azure, tried with each account (account, the one
// that last read it, first, in source); for Databricks and repos the
// profile or repo is found by its host. It also returns the version read,
// when the provider tells (provider.Versioned).
func Read(ctx context.Context, providers []provider.Provider, r Ref, account, source string) (provider.SecretValue, string, error) {
if r.IsShare() || r.IsSource() || r.IsContainer() {
what := "vault or scope"
switch {
case r.IsShare():
what = "share link"
case r.IsSource():
what = "subscription or workspace"
}
return provider.SecretValue{}, "", fmt.Errorf("%s points at a %s, not a secret", r, what)
}
if r.Kind != provider.KindAzure && r.Host == "" {
return provider.SecretValue{}, "", errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)")
}
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: source}
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
}
t := try{p, o}
if p.ID() == 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, version, err := value(ctx, t.p, t.o)
if err == nil {
return v, version, nil
}
if len(tries) == 1 || ctx.Err() != nil {
return provider.SecretValue{}, "", err
}
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
}
return provider.SecretValue{}, "", errors.Join(errs...)
}
func value(ctx context.Context, p provider.Provider, o provider.Object) (provider.SecretValue, string, error) {
if vp, ok := p.(provider.Versioned); ok {
return vp.VersionedValue(ctx, o)
}
v, err := p.Value(ctx, o)
return v, o.Version, err
}
+25 -1
View File
@@ -10,11 +10,15 @@
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]] // lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]] // lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
// lazysecrets://repo/<repo>[/<file>[/<key>]] // lazysecrets://repo/<repo>[/<file>[/<key>]]
// lazysecrets://share/<sealed files>
// //
// A repo is its git remote without scheme or .git // 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 // (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. // its path in the file (db.password); slashes inside them are escaped.
// //
// A share link carries files, encrypted (package share), instead of
// naming something.
//
// Vault hosts always contain dots, so "subscriptions" can't be one. // Vault hosts always contain dots, so "subscriptions" can't be one.
// //
// Every path segment is percent-encoded, so names may hold any character. // Every path segment is percent-encoded, so names may hold any character.
@@ -49,10 +53,19 @@ type Ref struct {
Type provider.ObjectType Type provider.ObjectType
Name string // object name; empty for a container link Name string // object name; empty for a container link
Version string Version string
// Share is a share link's sealed files, as in the link; the other
// fields are empty. Package share reads it.
Share string
} }
// IsShare reports whether r is a share link.
func (r Ref) IsShare() bool { return r.Share != "" }
// IsSource reports whether r points at a subscription or workspace. // IsSource reports whether r points at a subscription or workspace.
func (r Ref) IsSource() bool { func (r Ref) IsSource() bool {
if r.IsShare() {
return false
}
if r.Kind != provider.KindAzure { if r.Kind != provider.KindAzure {
return r.Scope == "" return r.Scope == ""
} }
@@ -60,7 +73,7 @@ func (r Ref) IsSource() bool {
} }
// IsContainer reports whether r points at a vault or scope. // IsContainer reports whether r points at a vault or scope.
func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() } func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() && !r.IsShare() }
// ContainerName is the vault name (from its host) or the scope. // ContainerName is the vault name (from its host) or the scope.
func (r Ref) ContainerName() string { func (r Ref) ContainerName() string {
@@ -111,6 +124,9 @@ func (r Ref) IdentifierURL() string {
// String is the lazysecrets:// link. // String is the lazysecrets:// link.
func (r Ref) String() string { func (r Ref) String() string {
if r.IsShare() {
return Scheme + "://share/" + r.Share
}
if r.Kind == provider.KindAzure && r.Subscription != "" { if r.Kind == provider.KindAzure && r.Subscription != "" {
return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription) return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription)
} }
@@ -208,6 +224,8 @@ var (
portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`) portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`)
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL> // …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`) portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`)
// a share link's sealed files: unpadded base64url
sealed = regexp.MustCompile(`^[A-Za-z0-9_-]{16,}$`)
) )
// Parse reads any supported reference. // Parse reads any supported reference.
@@ -238,6 +256,12 @@ func parseLink(s string) (Ref, error) {
if err != nil { if err != nil {
return Ref{}, err return Ref{}, err
} }
if strings.EqualFold(u.Host, "share") {
if len(segs) != 1 || !sealed.MatchString(segs[0]) {
return Ref{}, errors.New("share link is cut short or garbled: copy all of it")
}
return Ref{Share: segs[0]}, nil
}
var r Ref var r Ref
if len(segs) == 0 || segs[0] == "" { if len(segs) == 0 || segs[0] == "" {
return Ref{}, errors.New("link has no vault or workspace") return Ref{}, errors.New("link has no vault or workspace")
+69
View File
@@ -0,0 +1,69 @@
package share
import (
"fmt"
"strings"
"unicode"
)
// Rules are what a key's value must be to share with it: random enough
// that guessing it is out of reach, since anyone with a link can try.
type Rules struct {
MinLength int // characters
MinClasses int // kinds of character: lowercase, uppercase, digits, symbols
}
// DefaultRules take a random base64 or hex value (openssl rand -base64
// 32, or -hex 32) and refuse passwords made up to be remembered.
var DefaultRules = Rules{MinLength: 32, MinClasses: 2}
// Check reports what key lacks, without showing it.
func (r Rules) Check(key []byte) error {
r = r.withDefaults()
s := string(key)
var lower, upper, digit, symbol bool
for _, c := range s {
switch {
case unicode.IsLower(c):
lower = true
case unicode.IsUpper(c):
upper = true
case unicode.IsDigit(c):
digit = true
default:
symbol = true
}
}
kinds := 0
for _, has := range []bool{lower, upper, digit, symbol} {
if has {
kinds++
}
}
n := len([]rune(s))
var has, needs []string
if n < r.MinLength {
has = append(has, fmt.Sprintf("%d characters", n))
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
}
if kinds < r.MinClasses {
has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1]))
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
}
if len(has) == 0 {
return nil
}
return fmt.Errorf("its value has %s; share.keyRules needs %s",
strings.Join(has, " and "), strings.Join(needs, " and "))
}
func (r Rules) withDefaults() Rules {
if r.MinLength == 0 {
r.MinLength = DefaultRules.MinLength
}
if r.MinClasses == 0 {
r.MinClasses = DefaultRules.MinClasses
}
r.MinLength = max(r.MinLength, MinKey)
return r
}
+308
View File
@@ -0,0 +1,308 @@
// Package share seals env files into a lazysecrets://share link, so they
// can be passed on in chat or a ticket. They're encrypted with the value of
// a secret (in Key Vault, Databricks or a repo's SOPS file): whoever can
// read that secret can open the link, nobody else.
//
// A link says in the clear which secret it's encrypted with, the repo the
// files are from and how many there are, which is what's needed to open
// it. The files' paths and contents are encrypted, with age
// (https://age-encryption.org), the secret's value as its passphrase.
//
// The link's payload is unpadded base64url of:
//
// version (1) | uvarint length of the header | header (JSON) | age file
//
// The age file holds the files as deflated JSON.
//
// A change to the format gets the next version, and Read keeps reading
// every earlier one: links stay in chat and tickets for years. A
// version it doesn't know is from a newer lazysecrets.
package share
import (
"bytes"
"compress/flate"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"strings"
"unicode/utf8"
"filippo.io/age"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
const (
// version is the format's, written first; see the package doc.
version = 1
// MaxLink keeps a link short enough for chat and tickets (Teams takes
// about 28 KB) and for passing it to a terminal window.
MaxLink = 16 << 10
// MinKey is the shortest key the rules can ask for.
MinKey = 16
// maxUnpacked caps the bytes opening a link unpacks.
maxUnpacked = 1 << 20
)
// workFactor is the scrypt work factor for sealing: 2^15, about 40 ms
// each way, instead of age's 2^18 (a third of a second). scrypt makes
// guessing a weak passphrase slow; a key keeping to the rules is random
// and long enough not to need that. maxWorkFactor is the most opening
// accepts, so a link can't make it work for minutes.
var workFactor, maxWorkFactor = 15, 20
// File is a shared file.
type File struct {
Path string // relative to the repo's root, with slashes
Data []byte
}
// Header is what a link says in the clear.
type Header struct {
// Key is the secret whose value the files are encrypted with.
Key ref.Ref
// Repo is the repo the files are from: its remote without scheme or
// .git (a repo link's host).
Repo string
// Files is how many files the link holds.
Files int
}
// Sealed is a share link read, its files still encrypted.
type Sealed struct {
Header
box []byte
}
type header struct {
Key string `json:"key"`
Repo string `json:"repo,omitempty"`
Files int `json:"files"`
}
type file struct {
Path string `json:"path"`
Data string `json:"data"`
}
// Seal encrypts files with key, the value of h.Key, into a share link,
// if key keeps to rules (*KeyError if not).
func Seal(h Header, key []byte, files []File, rules Rules) (ref.Ref, error) {
if _, err := ParseKey(h.Key.String()); err != nil {
return ref.Ref{}, fmt.Errorf("the key: %w", err)
}
if err := rules.Check(key); err != nil {
return ref.Ref{}, &KeyError{Name: h.Key.Name, Err: err}
}
if len(files) == 0 {
return ref.Ref{}, errors.New("no files to share")
}
plain := make([]file, len(files))
for i, f := range files {
if err := CheckPath(f.Path); err != nil {
return ref.Ref{}, err
}
if !utf8.Valid(f.Data) {
return ref.Ref{}, fmt.Errorf("%s isn't text", f.Path)
}
plain[i] = file{Path: f.Path, Data: string(f.Data)}
}
b, err := json.Marshal(plain)
if err != nil {
return ref.Ref{}, err
}
rcpt, err := age.NewScryptRecipient(string(key))
if err != nil {
return ref.Ref{}, err
}
rcpt.SetWorkFactor(workFactor)
var box bytes.Buffer
enc, err := age.Encrypt(&box, rcpt)
if err != nil {
return ref.Ref{}, err
}
z, _ := flate.NewWriter(enc, flate.BestCompression)
if _, err := z.Write(b); err != nil {
return ref.Ref{}, err
}
if err := z.Close(); err != nil {
return ref.Ref{}, err
}
if err := enc.Close(); err != nil {
return ref.Ref{}, err
}
hb, err := json.Marshal(header{Key: h.Key.String(), Repo: h.Repo, Files: len(files)})
if err != nil {
return ref.Ref{}, err
}
payload := binary.AppendUvarint([]byte{version}, uint64(len(hb)))
payload = append(append(payload, hb...), box.Bytes()...)
r := ref.Ref{Share: base64.RawURLEncoding.EncodeToString(payload)}
if n := len(r.String()); n > MaxLink {
return ref.Ref{}, fmt.Errorf("the link would be %d KB, more than the %d KB it can be: share fewer files", n>>10, MaxLink>>10)
}
return r, nil
}
// KeyError is a key that can't be used: it can't be read, or doesn't
// keep to the rules.
type KeyError struct {
Name string // the secret's
Err error
}
func (e *KeyError) Error() string { return "can't use " + e.Name + ": " + e.Err.Error() }
func (e *KeyError) Unwrap() error { return e.Err }
// Read reads a share link's header.
func Read(r ref.Ref) (Sealed, error) {
bad := errors.New("share link is cut short or garbled: copy all of it")
b, err := base64.RawURLEncoding.DecodeString(r.Share)
if err != nil || len(b) < 2 {
return Sealed{}, bad
}
if b[0] != version {
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
}
n, size := binary.Uvarint(b[1:])
if size <= 0 || n > uint64(len(b)) {
return Sealed{}, bad
}
rest := b[1+size:]
if n > uint64(len(rest)) {
return Sealed{}, bad
}
var h header
if err := json.Unmarshal(rest[:n], &h); err != nil {
return Sealed{}, bad
}
key, err := ParseKey(h.Key)
if err != nil {
return Sealed{}, fmt.Errorf("share link's key: %w", err)
}
return Sealed{Header: Header{Key: key, Repo: h.Repo, Files: h.Files}, box: rest[n:]}, nil
}
// Open decrypts the files with key, the value of s.Key.
func (s Sealed) Open(key []byte) ([]File, error) {
id, err := age.NewScryptIdentity(string(key))
if err != nil {
return nil, err
}
id.SetMaxWorkFactor(maxWorkFactor)
dec, err := age.Decrypt(bytes.NewReader(s.box), id)
if err != nil {
var wrong *age.NoIdentityMatchError
if errors.As(err, &wrong) {
return nil, s.wrongKey()
}
return nil, fmt.Errorf("can't decrypt the link: %w", err)
}
b, err := io.ReadAll(io.LimitReader(flate.NewReader(dec), maxUnpacked+1))
if err != nil {
return nil, fmt.Errorf("can't decrypt the link: %w", err)
}
if len(b) > maxUnpacked {
return nil, errors.New("the link holds more than lazysecrets unpacks")
}
var plain []file
if err := json.Unmarshal(b, &plain); err != nil {
return nil, fmt.Errorf("can't read the link's files: %w", err)
}
out := make([]File, len(plain))
for i, f := range plain {
if err := CheckPath(f.Path); err != nil {
return nil, err
}
out[i] = File{Path: f.Path, Data: []byte(f.Data)}
}
return out, nil
}
// wrongKey says why the key, read fine, doesn't decrypt the link: its
// value isn't the one the link was made with.
func (s Sealed) wrongKey() error {
name := s.Key.Name
switch {
case s.Key.Kind == provider.KindAzure && s.Key.Version != "":
// A version's value never changes.
return fmt.Errorf("%s at version %s doesn't decrypt it: the link may be damaged", name, s.Key.Version)
case s.Key.Kind == provider.KindAzure:
return fmt.Errorf("%s's current version isn't the one the link was made with, so it can't decrypt it: ask for a new link", name)
}
// Databricks secrets and SOPS values are changed in place.
return fmt.Errorf("%s has changed since the link was made, so it can't decrypt it: ask for a new link", name)
}
// CheckPath accepts a path relative to a repo's root, with slashes, of an
// env file (.env, .env.local, prod.env) outside .git: what a link may
// write.
func CheckPath(p string) error {
switch {
case p == "" || path.IsAbs(p) || strings.ContainsAny(p, "\\\x00") || path.Clean(p) != p ||
p == ".." || strings.HasPrefix(p, "../"):
return fmt.Errorf("%q isn't a path inside a repo", p)
case p == ".git" || strings.HasPrefix(p, ".git/") || strings.Contains(p, "/.git/"):
return fmt.Errorf("%q is inside .git", p)
case !repo.IsEnv(p):
return fmt.Errorf("%q isn't an env file: only those can be shared", p)
}
return nil
}
// DefaultKey is the key for a repo's files: share.key in the repo's
// .lazysecrets.yaml, else general (share.key in the config). ok is false
// when neither names one.
func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
link, err := repo.ShareKey(root)
if err != nil {
return ref.Ref{}, false, err
}
where := root + "/.lazysecrets.yaml"
if link == "" {
link, where = general, "the config"
}
if link == "" {
return ref.Ref{}, false, nil
}
key, err = ParseKey(link)
if err != nil {
return ref.Ref{}, false, fmt.Errorf("share.key in %s: %w", where, err)
}
return key, true, nil
}
// ParseKey reads which secret is the key: its lazysecrets:// link, Key
// Vault URL or @Microsoft.KeyVault(...) reference.
func ParseKey(s string) (ref.Ref, error) {
key, err := ref.Parse(s)
if err != nil {
return ref.Ref{}, err
}
switch {
case key.IsShare() || key.IsSource() || key.IsContainer() || (key.Type != "" && key.Type != provider.TypeSecret):
return ref.Ref{}, fmt.Errorf("%s isn't a secret", s)
case key.Kind == provider.KindDatabricks && key.Host == "":
return ref.Ref{}, fmt.Errorf("%s names no workspace: use its lazysecrets:// link", s)
}
return key, nil
}
// Pin is key at version, the version its value was read at, for Azure,
// whose old versions stay readable: a link keeps working when the key
// is rotated, until that version is disabled.
func Pin(key ref.Ref, version string) ref.Ref {
if key.Kind == provider.KindAzure && key.Version == "" {
key.Version = version
}
return key
}
+258
View File
@@ -0,0 +1,258 @@
package share
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
func init() { workFactor = 10 } // fast; opening accepts any up to maxWorkFactor
var (
key = ref.Ref{Kind: provider.KindAzure, Host: "kv-team.vault.azure.net", Type: provider.TypeSecret, Name: "env-share", Version: "91d3e0"}
pass = []byte("0123456789abcdef0123456789abcdef")
)
func TestSealOpen(t *testing.T) {
files := []File{
{Path: ".env", Data: []byte("DB_PASSWORD=hunter2\n")},
{Path: "apps/api/.env.local", Data: []byte("TOKEN=\"a b\" # quoted\n")},
}
link, err := Seal(Header{Key: key, Repo: "git.example.com/team/app"}, pass, files, Rules{})
if err != nil {
t.Fatal(err)
}
if strings.Contains(link.String(), "hunter2") || strings.Contains(link.String(), "apps") {
t.Errorf("link shows what it shares: %s", link)
}
// It goes through ref.Parse like any link.
r, err := ref.Parse(link.String())
if err != nil || !r.IsShare() {
t.Fatalf("parse: %v, %+v", err, r)
}
s, err := Read(r)
if err != nil {
t.Fatal(err)
}
if s.Key != key || s.Repo != "git.example.com/team/app" || s.Files != 2 {
t.Errorf("header %+v", s.Header)
}
got, err := s.Open(pass)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[1].Path != files[1].Path || string(got[1].Data) != string(files[1].Data) {
t.Errorf("opened %+v", got)
}
if _, err := s.Open([]byte("another key, long enough to use")); err == nil || !strings.Contains(err.Error(), "env-share at version 91d3e0 doesn't decrypt it") {
t.Errorf("wrong key: %v", err)
}
}
func TestWrongKeySaysWhy(t *testing.T) {
files := []File{{Path: ".env", Data: []byte("A=1\n")}}
for link, want := range map[string]string{
"lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share": "env-share has changed since the link was made",
"lazysecrets://repo/git.example.com%2Fteam%2Fapp/secrets.yaml/share": "share has changed since the link was made",
"lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share": "env-share's current version isn't the one the link was made with",
} {
k, err := ParseKey(link)
if err != nil {
t.Fatal(err)
}
r, err := Seal(Header{Key: k}, pass, files, Rules{})
if err != nil {
t.Fatal(err)
}
s, _ := Read(r)
if _, err := s.Open([]byte("rotated since, and long enough to be a key")); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", link, err, want)
}
}
}
func TestSealRefuses(t *testing.T) {
ok := []File{{Path: ".env", Data: []byte("A=1")}}
for name, c := range map[string]struct {
h Header
pass []byte
files []File
want string
}{
"short key": {Header{Key: key}, []byte("short"), ok, "needs 32 (minLength)"},
"vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "isn't a secret"},
"no files": {Header{Key: key}, pass, nil, "no files"},
"not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"},
"escapes": {Header{Key: key}, pass, []File{{Path: "../.env", Data: []byte("x")}}, "inside a repo"},
"binary": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte{0xff, 0xfe}}}, "isn't text"},
"too big": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte(random(2 * MaxLink))}}, "more than the 16 KB"},
} {
if _, err := Seal(c.h, c.pass, c.files, Rules{}); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v, want %q", name, err, c.want)
}
}
}
// random is text that doesn't compress.
func random(n int) string {
var b strings.Builder
x := uint32(1)
for b.Len() < n {
x ^= x << 13
x ^= x >> 17
x ^= x << 5
b.WriteByte("abcdefghijklmnopqrstuvwxyz0123456789"[x%36])
}
return b.String()
}
func TestRules(t *testing.T) {
for _, c := range []struct {
r Rules
key string
pass bool
}{
{Rules{}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", true}, // base64-like, 32
{Rules{}, "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", true}, // hex
{Rules{}, "correct-horse-battery-staple", false}, // 28: short
{Rules{}, strings.Repeat("a", 40), false}, // one kind
{Rules{MinLength: 16, MinClasses: 1}, strings.Repeat("a", 16), true},
{Rules{MinLength: 4}, "abc123", false}, // never under MinKey
{Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", false},
{Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5U+", true},
} {
if err := c.r.Check([]byte(c.key)); (err == nil) != c.pass {
t.Errorf("%+v %q: %v", c.r, c.key, err)
} else if err != nil && strings.Contains(err.Error(), c.key) {
t.Errorf("the error shows the key: %v", err)
}
}
}
func TestReadGarbled(t *testing.T) {
link, err := Seal(Header{Key: key}, pass, []File{{Path: ".env", Data: []byte("A=1")}}, Rules{})
if err != nil {
t.Fatal(err)
}
cut := ref.Ref{Share: link.Share[:len(link.Share)/2]}
s, err := Read(cut)
if err == nil {
_, err = s.Open(pass)
}
if err == nil {
t.Error("a link cut in half opened")
}
if _, err := Read(ref.Ref{Share: "AgAAAAAAAAAAAAAAAAAAAA"}); err == nil || !strings.Contains(err.Error(), "newer lazysecrets") {
t.Errorf("version 2: %v", err)
}
}
func TestCheckPath(t *testing.T) {
for _, p := range []string{".env", "apps/api/.env.local", "prod.env"} {
if err := CheckPath(p); err != nil {
t.Errorf("%s: %v", p, err)
}
}
for _, p := range []string{"", "/etc/.env", "../.env", "a/../.env", ".git/.env", "a/.git/x.env", `a\.env`, ".env.example", "node_modules/x/.env", "Makefile"} {
if err := CheckPath(p); err == nil {
t.Errorf("%q accepted", p)
}
}
}
func TestPlanWrite(t *testing.T) {
dir := t.TempDir()
if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil {
t.Skip("git:", string(out))
}
os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".env\n"), 0o644)
os.WriteFile(filepath.Join(dir, ".env"), []byte("OLD=1\n"), 0o644)
targets, err := Plan(dir, []File{
{Path: ".env", Data: []byte("NEW=1\n")},
{Path: "apps/api/.env.local", Data: []byte("B=2\n")},
})
if err != nil {
t.Fatal(err)
}
if !targets[0].Exists || targets[0].Exposed {
t.Errorf(".env: %+v", targets[0])
}
if targets[1].Exists || !targets[1].Exposed {
t.Errorf("apps/api/.env.local: %+v", targets[1])
}
for _, tg := range targets {
if err := tg.Write(); err != nil {
t.Fatal(err)
}
}
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "NEW=1\n" {
t.Errorf(".env is %q", b)
}
info, err := os.Stat(filepath.Join(dir, "apps", "api", ".env.local"))
if err != nil || info.Mode().Perm() != 0o600 {
t.Errorf("new file: %v, %v", info.Mode(), err)
}
}
func TestPlanSymlinks(t *testing.T) {
dir, outside := t.TempDir(), t.TempDir()
if err := os.Symlink(outside, filepath.Join(dir, "apps")); err != nil {
t.Skip(err)
}
if _, err := Plan(dir, []File{{Path: "apps/.env"}}); err == nil || !strings.Contains(err.Error(), "out of the folder") {
t.Errorf("through a linked folder: %v", err)
}
os.Symlink(filepath.Join(outside, "x"), filepath.Join(dir, ".env"))
if _, err := Plan(dir, []File{{Path: ".env"}}); err == nil || !strings.Contains(err.Error(), "symlink") {
t.Errorf("onto a symlink: %v", err)
}
}
func TestParseKey(t *testing.T) {
for _, s := range []string{
"lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share",
"https://kv-team.vault.azure.net/secrets/env-share",
"@Microsoft.KeyVault(VaultName=kv-team;SecretName=env-share)",
"lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share",
} {
if k, err := ParseKey(s); err != nil || k.Name != "env-share" {
t.Errorf("%s: %+v, %v", s, k, err)
}
}
for s, want := range map[string]string{
"{{secrets/team/env-share}}": "names no workspace",
"https://kv-team.vault.azure.net/keys/sops": "isn't a secret",
"https://kv-team.vault.azure.net/": "isn't a secret",
"lazysecrets://azure/subscriptions/0000-1111": "isn't a secret",
} {
if _, err := ParseKey(s); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", s, err, want)
}
}
}
func TestDefaultKey(t *testing.T) {
dir := t.TempDir()
general := "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share"
k, ok, err := DefaultKey(dir, general)
if err != nil || !ok || k.Name != "env-share" {
t.Errorf("general: %+v %v %v", k, ok, err)
}
os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("share:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644)
if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" {
t.Errorf("the repo's should win: %+v", k)
}
if _, ok, _ := DefaultKey(t.TempDir(), ""); ok {
t.Error("a key with none set")
}
unpinned := key
unpinned.Version = ""
if p := Pin(unpinned, "abc"); p.Version != "abc" {
t.Errorf("pin: %+v", p)
}
}
+87
View File
@@ -0,0 +1,87 @@
package share
import (
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Target is where an opened file goes.
type Target struct {
File
Abs string
Exists bool
// Exposed is set when dir is a git repo that doesn't ignore the file:
// its values could end up in a commit.
Exposed bool
}
// Plan says where files go under dir, without writing anything. A path
// that leads out of dir through a symlink, or is one, is an error.
func Plan(dir string, files []File) ([]Target, error) {
root, err := filepath.EvalSymlinks(dir)
if err != nil {
return nil, err
}
out := make([]Target, len(files))
for i, f := range files {
if err := CheckPath(f.Path); err != nil {
return nil, err
}
t := Target{File: f, Abs: filepath.Join(root, filepath.FromSlash(f.Path))}
if err := inside(root, filepath.Dir(t.Abs)); err != nil {
return nil, fmt.Errorf("%s: %w", f.Path, err)
}
switch info, err := os.Lstat(t.Abs); {
case err == nil && info.Mode()&fs.ModeSymlink != 0:
return nil, fmt.Errorf("%s is a symlink: not writing through it", f.Path)
case err == nil && !info.Mode().IsRegular():
return nil, fmt.Errorf("%s isn't a file", f.Path)
case err == nil:
t.Exists = true
case !errors.Is(err, fs.ErrNotExist):
return nil, err
}
t.Exposed = exposed(root, f.Path)
out[i] = t
}
return out, nil
}
// inside checks that dir, or the part of it that exists, is under root
// once symlinks are followed.
func inside(root, dir string) error {
for {
real, err := filepath.EvalSymlinks(dir)
if errors.Is(err, fs.ErrNotExist) && dir != root {
dir = filepath.Dir(dir) // created when writing
continue
}
if err != nil {
return err
}
if real != root && !strings.HasPrefix(real, root+string(filepath.Separator)) {
return errors.New("leads out of the folder through a symlink")
}
return nil
}
}
// exposed reports whether root is in a git repo that doesn't ignore p.
func exposed(root, p string) bool {
err := exec.Command("git", "-C", root, "check-ignore", "-q", "--", p).Run()
var exit *exec.ExitError
return errors.As(err, &exit) && exit.ExitCode() == 1 // 0: ignored; 128: no repo
}
// Write writes the file, readable by the user only when it's new.
func (t Target) Write() error {
if err := os.MkdirAll(filepath.Dir(t.Abs), 0o755); err != nil {
return err
}
return os.WriteFile(t.Abs, t.Data, 0o600)
}
+47 -14
View File
@@ -24,6 +24,7 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons" "git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys" "git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
@@ -56,7 +57,7 @@ const (
slotDescribe // extra metadata for the selected object slotDescribe // extra metadata for the selected object
slotCopy // fetch a value straight to the clipboard slotCopy // fetch a value straight to the clipboard
slotVersions // count the selected object's versions slotVersions // count the selected object's versions
slotDetails // a vault's, scope's or file's details slotDetails // a file's details
slotCount slotCount
) )
@@ -81,6 +82,11 @@ type Options struct {
// CacheFile keeps what was loaded between runs; "" keeps it for this // CacheFile keeps what was loaded between runs; "" keeps it for this
// run only. // run only.
CacheFile string CacheFile string
// ShareKey is the config's share.key: the secret that encrypts shared
// env files, for repos whose .lazysecrets.yaml names none.
ShareKey string
// ShareRules are what a key's value must be to share with it.
ShareRules share.Rules
// Dir is the folder lazysecrets runs in: it goes back to where it was // Dir is the folder lazysecrets runs in: it goes back to where it was
// left there, unless it was started on a link. "" doesn't. // left there, unless it was started on a link. "" doesn't.
Dir string Dir string
@@ -172,16 +178,18 @@ type Model struct {
clip Clipboard clip Clipboard
clipClear time.Duration clipClear time.Duration
openURL func(string) error openURL func(string) error
// fields is the fourth pane for anything but a secret, with each // fields is the fourth pane for anything but a secret; lastLeft is
// account's groups as they load; lastLeft is the left pane it // the left pane it describes while the fourth has focus (fields.go).
// describes while the fourth has focus (fields.go).
fields panes.Fields fields panes.Fields
lastLeft pane lastLeft pane
groups map[string]groupsState
details map[string]detailsState // by containerKey details map[string]detailsState // by containerKey
getenv func(string) string // errLog is this session's errors, newest first; loggedFlash the
nvimOpen func(server, path string, line int) error // edit.go // footer's text when last looked at (errlog.go).
now func() time.Time errLog []loggedErr
loggedFlash string
getenv func(string) string
nvimOpen func(server, path string, line int) error // edit.go
now func() time.Time
// The cache file, whether the cache changed since it was saved, and // The cache file, whether the cache changed since it was saved, and
// when each provider's Sources list was loaded (persist.go). // when each provider's Sources list was loaded (persist.go).
cachePath string cachePath string
@@ -268,6 +276,9 @@ type Model struct {
linked []provider.Container // vaults opened from links, this session linked []provider.Container // vaults opened from links, this session
open *ref.Ref // reference to open at startup open *ref.Ref // reference to open at startup
shareKey string // the config's share.key
shareRules share.Rules // and share.keyRules
// A lazysecrets:// link found on the clipboard, offered in the footer. // A lazysecrets:// link found on the clipboard, offered in the footer.
dismissedLink string // the last link offered: not offered again dismissedLink string // the last link offered: not offered again
clipChecked time.Time clipChecked time.Time
@@ -377,7 +388,6 @@ func New(providers []provider.Provider, opts Options) Model {
getenv: os.Getenv, getenv: os.Getenv,
nvimOpen: nvimOpen, nvimOpen: nvimOpen,
cache: newCache(), cache: newCache(),
groups: map[string]groupsState{},
details: map[string]detailsState{}, details: map[string]detailsState{},
fields: panes.NewFields(), fields: panes.NewFields(),
} }
@@ -454,6 +464,7 @@ func New(providers []provider.Provider, opts Options) Model {
m.spinning = true m.spinning = true
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{} m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog) m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
if m.cachePath != "" { if m.cachePath != "" {
m.loadCacheFile() m.loadCacheFile()
@@ -488,7 +499,7 @@ func (m *Model) setVersions(items []provider.Object) {
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()} cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard(), tea.RequestBackgroundColor}
if m.cachePath != "" || m.placeDir != "" { if m.cachePath != "" || m.placeDir != "" {
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })) cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
} }
@@ -513,6 +524,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.BackgroundColorMsg:
m.st = m.st.OnBackground(msg.Color, msg.IsDark())
return m, nil
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.w, m.h = msg.Width, msg.Height m.w, m.h = msg.Width, msg.Height
@@ -566,6 +580,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case resolvedMsg: case resolvedMsg:
m.handleResolved(msg) m.handleResolved(msg)
case shareFilesMsg:
cmds = append(cmds, m.handleShareFiles(msg))
case sealedMsg:
cmds = append(cmds, m.handleSealed(msg))
case shareOpenedMsg:
cmds = append(cmds, m.handleShareOpened(msg))
case tea.PasteMsg: case tea.PasteMsg:
cmds = append(cmds, m.handlePaste(msg)) cmds = append(cmds, m.handlePaste(msg))
@@ -590,9 +613,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case persistedMsg: case persistedMsg:
m.handlePersisted(msg) m.handlePersisted(msg)
case groupsMsg:
m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err}
case detailsMsg: case detailsMsg:
if msg.req == m.reqID[slotDetails] { if msg.req == m.reqID[slotDetails] {
m.done(slotDetails) m.done(slotDetails)
@@ -629,6 +649,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotContainers) m.done(slotContainers)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok {
m.logErrOf(src.Name, msg.err)
}
delete(m.cache.containers, msg.key) delete(m.cache.containers, msg.key)
m.containers.SetError(msg.err) m.containers.SetError(msg.err)
} else { } else {
@@ -647,18 +670,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.closeVersions() m.closeVersions()
} else if msg.err != nil { } else if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
if c, ok := m.containers.Selected(); ok {
m.logErrOf(c.Name, msg.err)
}
delete(m.cache.objects, msg.key) delete(m.cache.objects, msg.key)
m.secrets.SetError(msg.err) m.secrets.SetError(msg.err)
} else { } else {
if msg.bound != nil { if msg.bound != nil {
msg.key = m.rebind(*msg.bound) msg.key = m.rebind(*msg.bound)
} }
before := m.cache.objects[msg.key].value
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
m.cacheDirty = true m.cacheDirty = true
if m.versionsOf != nil { if m.versionsOf != nil {
m.setVersions(msg.items) m.setVersions(msg.items)
} else { } else {
m.secrets.SetItems(m.starredFirst(msg.items)) m.secrets.SetItems(m.starredFirst(msg.items))
cmds = append(cmds, m.refreshShown(before))
} }
} }
@@ -669,6 +697,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotValue) m.done(slotValue)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
m.detail.SetValueError(msg.err) m.detail.SetValueError(msg.err)
} else { } else {
m.detail.SetValue(msg.value) m.detail.SetValue(msg.value)
@@ -695,6 +724,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done(slotDescribe) m.done(slotDescribe)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
m.detail.SetDescribeError(msg.err) m.detail.SetDescribeError(msg.err)
} else { } else {
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()} m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
@@ -765,6 +795,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.focus != paneDetail { if m.focus != paneDetail {
m.lastLeft = m.focus m.lastLeft = m.focus
} }
m.logFlash()
cmds = append(cmds, m.syncFields()) cmds = append(cmds, m.syncFields())
if m.anyLoading() && !m.spinning { if m.anyLoading() && !m.spinning {
m.spinning = true m.spinning = true
@@ -1359,6 +1390,7 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
for _, p := range m.providers { for _, p := range m.providers {
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil { if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
m.noteErr(r.err) m.noteErr(r.err)
m.logErrOf(p.Label(), r.err)
errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err)) errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err))
} }
} }
@@ -1708,7 +1740,8 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() { } else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else { } else {
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown()}.ShortHelp(), 2) revealed := m.detail.State() == panes.ValueShown
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.ShortHelp(), 2)
} }
var lines []string var lines []string
if !m.clipDeadline.IsZero() { if !m.clipDeadline.IsZero() {
+60 -30
View File
@@ -494,14 +494,17 @@ func TestVersionHistoryInDetail(t *testing.T) {
screen := d.screen() screen := d.screen()
for _, want := range []string{ for _, want := range []string{
"versions 3 · v to browse", "versions 3 · v to browse",
"▸ 3f9c2a 2026-09-01 current", // the version shown above " 3f9c2a 2026-09-01 current",
" 91d3e0 2026-03-01 expired", " 91d3e0 2026-03-01 expired",
" 0a77c4 2025-09-01 disabled", " 0a77c4 2025-09-01 disabled",
} { } {
if !strings.Contains(screen, want) { if !strings.Contains(screen, want) {
t.Errorf("missing %q:\n%s", want, screen) t.Errorf("missing %q:\n%s", want, screen)
} }
} }
if strings.Contains(screen, "▸ 3f9c2a") {
t.Errorf("a cursor on a list that can't be moved in:\n%s", screen)
}
d.keys("3", "j") // api-key: only its current version d.keys("3", "j") // api-key: only its current version
if !strings.Contains(d.screen(), "versions 1") || strings.Contains(d.screen(), "v to browse") { if !strings.Contains(d.screen(), "versions 1") || strings.Contains(d.screen(), "v to browse") {
t.Errorf("wrong history for api-key:\n%s", d.screen()) t.Errorf("wrong history for api-key:\n%s", d.screen())
@@ -988,9 +991,14 @@ func TestRebindingShowsEverywhere(t *testing.T) {
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") { if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
t.Errorf("detail hint doesn't follow the binding:\n%s", scr) t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
} }
if !strings.Contains(lastLine(scr), "z reveal/hide") { if !strings.Contains(lastLine(scr), "z show") {
t.Errorf("help bar: %q", lastLine(scr)) t.Errorf("help bar: %q", lastLine(scr))
} }
d.keys("z")
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") {
t.Errorf("help bar with the value shown: %q", bar)
}
d.keys("z")
if strings.Contains(scr, "to browse") { if strings.Contains(scr, "to browse") {
t.Errorf("hint for unbound versions:\n%s", scr) t.Errorf("hint for unbound versions:\n%s", scr)
} }
@@ -1106,25 +1114,16 @@ func TestAccountInDetails(t *testing.T) {
} }
} }
// grouped is an account in two groups.
type grouped struct{ described }
func (grouped) Groups(context.Context) ([]string, error) {
return []string{"Platform team", "Vault readers"}, nil
}
func TestAccountPane(t *testing.T) { func TestAccountPane(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t, grouped{described{fake.Azure(0)}}) d := newDriver(t, described{fake.Azure(0)})
d.keys("g", "tab") // the account's row, then straight to its pane d.keys("g", "l") // the account's row, then its pane
if d.m.focus != paneDetail { if d.m.focus != paneDetail {
t.Fatalf("focus %v, want the fourth pane", d.m.focus) t.Fatalf("focus %v, want the fourth pane", d.m.focus)
} }
scr := d.screen() scr := d.screen()
for _, want := range []string{"Alice Example", "groups 2", "Platform team", "Vault readers"} { if !strings.Contains(scr, "Alice Example") {
if !strings.Contains(scr, want) { t.Errorf("account not shown:\n%s", scr)
t.Errorf("missing %q:\n%s", want, scr)
}
} }
bar := lastLine(scr) bar := lastLine(scr)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") { if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
@@ -1134,28 +1133,20 @@ func TestAccountPane(t *testing.T) {
if got := d.clip().content; got != "oid-123" { if got := d.clip().content; got != "oid-123" {
t.Errorf("y on the object ID copied %q", got) t.Errorf("y on the object ID copied %q", got)
} }
d.keys("G", "y")
if got := d.clip().content; got != "Vault readers" {
t.Errorf("y on a group copied %q", got)
}
// / filters it, labels too. // / filters it, labels too.
d.keys("/") d.keys("/")
d.typeText("vault") d.typeText("object")
d.keys("enter") d.keys("enter")
if scr := d.screen(); !strings.Contains(scr, "Vault readers") || strings.Contains(scr, "Platform team") { if scr := d.screen(); !strings.Contains(scr, "oid-123") || strings.Contains(scr, "Alice Example") {
t.Errorf("filtered:\n%s", scr) t.Errorf("filtered:\n%s", scr)
} }
d.keys("y")
if got := d.clip().content; got != "Vault readers" {
t.Errorf("y in the filtered pane copied %q", got)
}
d.keys("esc") d.keys("esc")
if !strings.Contains(d.screen(), "Platform team") { if !strings.Contains(d.screen(), "Alice Example") {
t.Errorf("filter not cleared:\n%s", d.screen()) t.Errorf("filter not cleared:\n%s", d.screen())
} }
d.keys("shift+tab") d.keys("h")
if d.m.focus != paneSources { if d.m.focus != paneSources {
t.Errorf("back to %v, want Sources", d.m.focus) t.Errorf("back to %v, want Sources", d.m.focus)
} }
@@ -1193,6 +1184,18 @@ func TestDetailsFollowFocus(t *testing.T) {
} }
} }
func TestStatusLineCountsRepos(t *testing.T) {
t.Parallel()
d := newDriver(t, fake.Azure(0), fake.Repos())
if line := strings.Split(d.screen(), "\n")[1]; !strings.Contains(line, "1 account · 3 repos") {
t.Errorf("status line: %q", line)
}
d = newDriver(t, fake.Azure(0))
if line := strings.Split(d.screen(), "\n")[1]; strings.Contains(line, "repo") {
t.Errorf("repos counted without any: %q", line)
}
}
func TestStatusPane(t *testing.T) { func TestStatusPane(t *testing.T) {
t.Parallel() t.Parallel()
var opened []string var opened []string
@@ -1207,7 +1210,7 @@ func TestStatusPane(t *testing.T) {
t.Fatalf("focus %v", d.m.focus) t.Fatalf("focus %v", d.m.focus)
} }
scr := d.screen() scr := d.screen()
for _, want := range []string{"version v0.1.0", "GPL-3.0-or-later", "Azure · 2 subscriptions", "Azure · admin · sign-in required", for _, want := range []string{logo[2], "version v0.1.0", "GPL-3.0-or-later", "Azure · 2 subscriptions", "Azure · admin · sign-in required",
"/cfg/config.yaml", "/state/state.json", "lazysecrets/…/docs"} { "/cfg/config.yaml", "/state/state.json", "lazysecrets/…/docs"} {
if !strings.Contains(scr, want) { if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr) t.Errorf("missing %q:\n%s", want, scr)
@@ -1233,6 +1236,11 @@ func TestStatusPane(t *testing.T) {
if len(opened) != 1 || !strings.HasSuffix(opened[0], "/src/branch/main/docs") { if len(opened) != 1 || !strings.HasSuffix(opened[0], "/src/branch/main/docs") {
t.Errorf("o opened %q", opened) t.Errorf("o opened %q", opened)
} }
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "version" })
d.keys("o")
if len(opened) != 2 || !strings.HasSuffix(opened[1], "/releases/tag/v0.1.0") {
t.Errorf("o on the version opened %q", opened)
}
d.keys("shift+tab") d.keys("shift+tab")
if d.m.focus != paneStatus { if d.m.focus != paneStatus {
t.Errorf("back to %v", d.m.focus) t.Errorf("back to %v", d.m.focus)
@@ -1312,3 +1320,25 @@ func TestExpiringInStatus(t *testing.T) {
t.Errorf("enter went to %s/%s\n%s", v, o, d.screen()) t.Errorf("enter went to %s/%s\n%s", v, o, d.screen())
} }
} }
func TestErrorLog(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{}, fake.Azure(0))
d.keys("2", "j", "j", "3") // kv-restricted: its secrets can't be listed
d.m.flash = flash{text: "copy failed: no clipboard", isErr: true}
d.send(nil)
d.send(nil) // still showing: logged once
d.keys("0")
scr := d.screen()
if !strings.Contains(scr, "errors") || !strings.Contains(scr, "copy failed: no clipboard") || !strings.Contains(scr, "kv-restricted: 403 Forbidden") {
t.Errorf("errors not listed:\n%s", scr)
}
if n := len(d.m.errLog); n != 2 || !strings.HasPrefix(d.m.errLog[0].text, "copy failed") {
t.Errorf("log %+v", d.m.errLog)
}
d.m.logErr("403 Forbidden: again") // a new one, and an old one again: on top
d.m.logErr(d.m.errLog[2].text)
if len(d.m.errLog) != 3 || d.m.errLog[0].text == "403 Forbidden: again" {
t.Errorf("log %+v", d.m.errLog)
}
}
+32
View File
@@ -1,8 +1,12 @@
package app package app
import ( import (
"maps"
"slices"
"time" "time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -48,3 +52,31 @@ func (c cache) rememberCursor(parent, child string) {
c.cursor[parent] = child c.cursor[parent] = child
} }
} }
// refreshShown shows the selected secret's newer metadata when its list
// reloaded with it changed from before (a cached list refreshed in the
// background), and describes it afresh: its cached details came from
// the old listing.
func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
s, ok := m.secrets.Selected()
if !ok || m.secrets.SelectedID() != m.selSecret {
return nil // a new selection is shown as usual
}
i := slices.IndexFunc(before, func(o provider.Object) bool { return o.ID == s.ID })
if i < 0 || sameListing(before[i], s) {
return nil
}
m.detail.Refresh(s)
delete(m.cache.details, detailKey(s))
return m.describe(s)
}
// sameListing reports whether a and b list the same: what a list
// says of an object, not what describing it adds.
func sameListing(a, b provider.Object) bool {
enabled := func(e *bool) bool { return e == nil || *e }
return a.Name == b.Name && a.Version == b.Version && enabled(a.Enabled) == enabled(b.Enabled) &&
a.Updated.Equal(b.Updated) && a.Expires.Equal(b.Expires) && a.ContentType == b.ContentType &&
maps.Equal(a.Tags, b.Tags) && a.Reference == b.Reference && a.URL == b.URL &&
slices.Equal(a.Attrs, b.Attrs)
}
+31
View File
@@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"slices"
"testing" "testing"
"time" "time"
@@ -159,3 +160,33 @@ func TestMovingOnDropsUnsettledLoads(t *testing.T) {
t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before) t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before)
} }
} }
func TestReloadedListRefreshesShownSecret(t *testing.T) {
t.Parallel()
d, _, clock := newCachingDriver(t)
d.keys("3") // kv-app-prod's secrets, db-password selected
shown, _ := d.m.detail.Secret()
// What an older lazysecrets listed and cached for it.
const old = "https://portal.example/old"
key := d.m.selContainer
e := d.m.cache.objects[key]
items := slices.Clone(e.value)
for i := range items {
if items[i].ID == shown.ID {
items[i].URL = old
}
}
d.m.cache.objects[key] = entry[[]provider.Object]{items, e.at}
stale := shown
stale.URL = old
d.m.cache.details[detailKey(shown)] = entry[provider.Object]{stale, *clock}
// Away and back once the list is stale: it's shown from the cache,
// then reloaded, and the details pane follows.
d.keys("1", "j")
*clock = clock.Add(cacheTTL + time.Second)
d.keys("k", "3")
if got, _ := d.m.detail.Secret(); got.ID != shown.ID || got.URL == old {
t.Errorf("shown %s with %q after the reload", got.Name, got.URL)
}
}
+40 -4
View File
@@ -3,6 +3,7 @@ package app
import ( import (
"cmp" "cmp"
"errors" "errors"
"io/fs"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -13,18 +14,20 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/browser" "git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/update"
) )
// Opening a repo's file (e): SOPS files with sops edit, which decrypts // Opening a repo's file (e), or on the status pane the config: SOPS files with sops edit, which decrypts
// them for the editor and encrypts them again after. Other files, inside // them for the editor and encrypts them again after. Other files, inside
// nvim: in that nvim, once lazysecrets has quit; else in $VISUAL or // 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 // $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. // secret, editors that can open at a line start on its key.
type editDoneMsg struct { type editDoneMsg struct {
c provider.Container c provider.Container
name string name string
err error config bool // the config file: read at startup
err error
} }
// sopsUnchanged is sops edit's exit code when the file wasn't changed. // sopsUnchanged is sops edit's exit code when the file wasn't changed.
@@ -44,6 +47,8 @@ func (m *Model) edit() tea.Cmd {
var c provider.Container var c provider.Container
var o *provider.Object var o *provider.Object
switch m.focus { switch m.focus {
case paneStatus: // like lazygit's status panel
return m.editConfig()
case paneSources: case paneSources:
m.flash = warning("select a file to open it") m.flash = warning("select a file to open it")
return nil return nil
@@ -93,6 +98,33 @@ func (m *Model) edit() tea.Cmd {
return m.openInEditor(f, finished) return m.openInEditor(f, finished)
} }
// configHeader starts a config file made to be edited.
const configHeader = "# lazysecrets config: " + update.DefaultRepo + "/src/branch/main/docs/config.md\n"
// editConfig opens the user's config file (the first of them), as e
// opens a file, making it if it isn't there yet: e on the status pane.
func (m *Model) editConfig() tea.Cmd {
if len(m.configFiles) == 0 {
m.flash = warning("no config file")
return nil
}
path := m.configFiles[0]
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
err := os.MkdirAll(filepath.Dir(path), 0o755)
if err == nil {
err = os.WriteFile(path, []byte(configHeader), 0o644)
}
if err != nil {
m.flash = flash{text: "edit config: " + err.Error(), isErr: true}
return nil
}
}
name := filepath.Base(path)
return m.openInEditor(provider.File{Path: path}, func(err error) tea.Msg {
return editDoneMsg{name: name, config: true, err: err}
})
}
// plusLine are editors that open at a line with +N before the file. // plusLine are editors that open at a line with +N before the file.
var plusLine = map[string]bool{ var plusLine = map[string]bool{
"vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true, "vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true,
@@ -142,6 +174,10 @@ func (m *Model) handleEditDone(msg editDoneMsg) tea.Cmd {
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true} m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true}
return nil return nil
} }
if msg.config {
m.flash = done("closed " + msg.name + ": restart lazysecrets to use it")
return nil
}
m.flash = done("closed " + msg.name) m.flash = done("closed " + msg.name)
if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) { if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) {
m.hideValue() m.hideValue()
+64
View File
@@ -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
}
+1 -1
View File
@@ -60,7 +60,7 @@ func (m Model) expiringFields() []panes.Field {
} }
out = append(out, f) out = append(out, f)
} }
cov := m.coverageOf(provider.KindAzure) cov := m.coverageOf(ofKind(provider.KindAzure))
switch { switch {
case cov.vaults == 0 && len(items) == 0: case cov.vaults == 0 && len(items) == 0:
return nil return nil
+10 -63
View File
@@ -1,7 +1,6 @@
package app package app
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -14,22 +13,9 @@ import (
) )
// The fourth pane shows Fields for anything but a secret. On a provider's // The fourth pane shows Fields for anything but a secret. On a provider's
// row: its account, from what the provider knows locally, then the // row: its account, from what the provider knows locally.
// groups the account is in, loaded once a session when first shown.
type groupsState struct { // A file's details beyond its listing load once a
names []string
err error
loading bool
}
type groupsMsg struct {
provider string
names []string
err error
}
// A vault's, scope's or file's details beyond its listing load once a
// session, when the cursor rests on it, and again with refresh. They're // 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. // extra: moving on cancels the load, and search never waits for them.
type detailsState struct { type detailsState struct {
@@ -57,7 +43,6 @@ func (m Model) detailOf() pane {
// syncFields shows the Fields pane for what the focused pane has selected, // 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 // 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. // the secret pane shows, as it does a revealed value whatever the focus.
// An account's groups load the first time.
func (m *Model) syncFields() tea.Cmd { func (m *Model) syncFields() tea.Cmd {
if m.detail.State() != panes.ValueHidden { if m.detail.State() != panes.ValueHidden {
// A value revealed (or being fetched) from any pane stays in // A value revealed (or being fetched) from any pane stays in
@@ -65,6 +50,7 @@ func (m *Model) syncFields() tea.Cmd {
m.fields.Hide() m.fields.Hide()
return nil return nil
} }
m.fields.Header, m.fields.HeaderShort = nil, ""
switch m.detailOf() { switch m.detailOf() {
case paneSources: case paneSources:
r, ok := m.sources.Selected() r, ok := m.sources.Selected()
@@ -76,13 +62,14 @@ func (m *Model) syncFields() tea.Cmd {
case m.selectedProblem() != "": case m.selectedProblem() != "":
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem())) m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup: case r.kind == rowGroup:
return m.showAccount(r) m.showAccount(r)
case r.kind == rowSource: case r.kind == rowSource:
m.fields.Show(r.source.Name, m.sourceFields(r.source)) m.fields.Show(r.source.Name, m.sourceFields(r.source))
default: default:
m.fields.Show("", nil) m.fields.Show("", nil)
} }
case paneStatus: case paneStatus:
m.fields.Header, m.fields.HeaderShort = logo, strings.TrimSpace("lazysecrets "+m.versionLabel())
m.fields.Show("lazysecrets", m.statusFields()) m.fields.Show("lazysecrets", m.statusFields())
case paneContainers: case paneContainers:
if c, ok := m.containers.Selected(); ok { if c, ok := m.containers.Selected(); ok {
@@ -98,9 +85,8 @@ func (m *Model) syncFields() tea.Cmd {
return nil return nil
} }
// showAccount shows a provider's account, loading its groups the first // showAccount shows a provider's account.
// time. func (m *Model) showAccount(r sourceRow) {
func (m *Model) showAccount(r sourceRow) tea.Cmd {
p := m.byID[r.provider] p := m.byID[r.provider]
var fields []panes.Field var fields []panes.Field
if a, ok := p.(provider.Accounts); ok { if a, ok := p.(provider.Accounts); ok {
@@ -110,28 +96,11 @@ func (m *Model) showAccount(r sourceRow) tea.Cmd {
} }
} }
} }
var cmd tea.Cmd
if g, ok := p.(provider.AccountGroups); ok {
st, seen := m.groups[r.provider]
if !seen {
st = groupsState{loading: true}
m.groups[r.provider] = st
id, timeout := r.provider, m.timeout
cmd = func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
names, err := g.Groups(ctx)
return groupsMsg{provider: id, names: names, err: err}
}
}
fields = append(fields, groupFields(st)...)
}
if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 { if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 {
hint := emptyHint(p.Kind()) hint := emptyHint(p.Kind())
fields = append(fields, panes.Field{Label: "nothing", Value: "found: try", Info: true}, panes.Field{Value: hint.Value, URL: hint.URL}) 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) m.fields.Show(p.Label(), fields)
return cmd
} }
// sourceFields describe a subscription, workspace or repo. // sourceFields describe a subscription, workspace or repo.
@@ -248,30 +217,8 @@ func (m Model) problemFields(problem string) []panes.Field {
return out return out
} }
// fieldName names a copied field: its label, or for a group, the group. // 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 { func fieldName(f panes.Field) string {
if f.Label == "" || strings.HasPrefix(f.Label, "groups") { return cmpOr(f.Label, f.Value)
return "group " + f.Value
}
return f.Label
}
// groupFields are the groups' rows: labelled on the first.
func groupFields(st groupsState) []panes.Field {
switch {
case st.loading:
return []panes.Field{{Label: "groups", Value: "loading…", Info: true}}
case errors.Is(st.err, provider.ErrUnsupported):
return nil
case st.err != nil:
return []panes.Field{{Label: "groups", Value: "couldn't list them: " + st.err.Error(), Info: true}}
case len(st.names) == 0:
return []panes.Field{{Label: "groups", Value: "none", Info: true}}
}
out := make([]panes.Field, len(st.names))
for i, n := range st.names {
out[i] = panes.Field{Value: n}
}
out[0].Label = fmt.Sprintf("groups %d", len(st.names))
return out
} }
+13 -4
View File
@@ -38,6 +38,10 @@ type contextHelp struct {
commands bool // custom commands apply here commands bool // custom commands apply here
account bool // the fourth pane shows Fields (fields.go) account bool // the fourth pane shows Fields (fields.go)
of pane // what the fourth pane describes 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 { func (c contextHelp) ShortHelp() []key.Binding {
@@ -50,7 +54,8 @@ func (c contextHelp) ShortHelp() []key.Binding {
switch { switch {
case c.focus == paneStatus: case c.focus == paneStatus:
details := key.NewBinding(key.WithKeys(k.Enter.Keys()...), key.WithHelp(k.Enter.Help().Key, "details")) 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) config := key.NewBinding(key.WithKeys(k.Edit.Keys()...), key.WithHelp(k.Edit.Help().Key, "edit config"))
return append(b, details, config, k.Update, k.Help, k.Quit)
case c.focus == paneDetail && c.account && c.of == paneStatus: 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")) 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) return append(b, k.Up, k.Down, copyField, open, k.Edit, k.Filter, k.Help, k.Quit)
@@ -59,11 +64,15 @@ func (c contextHelp) ShortHelp() []key.Binding {
} }
switch c.focus { switch c.focus {
case paneSources: case paneSources:
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.Filter) b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Filter)
case paneContainers: case paneContainers:
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Note, k.Open, k.Filter) b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
default: default:
b = append(b, k.Reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Note, k.Filter) 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 { if c.commands {
b = append(b, k.Commands) b = append(b, k.Commands)
+28 -3
View File
@@ -47,6 +47,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
out = append(out, entry{depth, sort, option{ out = append(out, entry{depth, sort, option{
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ", label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct), id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
plain: m.inPlainEnv(r),
run: func(m *Model) tea.Cmd { run: func(m *Model) tea.Cmd {
m.used(key) m.used(key)
return run(m) return run(m)
@@ -138,6 +139,30 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account string) stri
return label return label
} }
// inPlainEnv reports whether r is a key in a plain env file, as far as
// the files loaded tell.
func (m Model) inPlainEnv(r ref.Ref) bool {
if r.Kind != provider.KindRepo || r.Name == "" {
return false
}
file := r
file.Name, file.Type = "", ""
for _, e := range m.cache.containers {
for _, c := range e.value {
if c.Detail == provider.DetailPlainEnv && storeKey(c) == file.String() {
return true
}
}
}
return false
}
// repoLabel draws a repo, or a file in it, as everywhere else: the repo
// icon and colour, "app / apps/api/.env".
func (m Model) repoLabel(repo string, file ...string) string {
return m.pathLabel(provider.KindRepo, append([]string{repo}, file...), "")
}
// sourceMatches reports whether src is the subscription or workspace r // sourceMatches reports whether src is the subscription or workspace r
// links to. // links to.
func sourceMatches(r ref.Ref, src provider.Source) bool { func sourceMatches(r ref.Ref, src provider.Source) bool {
@@ -147,8 +172,8 @@ func sourceMatches(r ref.Ref, src provider.Source) bool {
return src.ID == r.Subscription return src.ID == r.Subscription
} }
// accountOf is the account part of a source's provider label ("dev" in // accountOf is the account part of a source's provider label ("alice" in
// "Azure · dev"), or "" when there's a single account. // "Azure · alice"), or "" when there's a single account.
func accountOf(src provider.Source) string { func accountOf(src provider.Source) string {
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ") account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
if !ok { if !ok {
@@ -192,7 +217,7 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
case 1: case 1:
m.startNav(r, actGo, choices[0].sourceRow) m.startNav(r, actGo, choices[0].sourceRow)
default: default:
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices) m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices, false)
} }
return nil return nil
} }
+2 -2
View File
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
func TestContextHelpBar(t *testing.T) { func TestContextHelpBar(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share…") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space reveal") { 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) t.Errorf("Sources help bar: %q", bar)
} }
d.keys("3") d.keys("3")
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share…") { if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
t.Errorf("Secrets help bar: %q", bar) t.Errorf("Secrets help bar: %q", bar)
} }
} }
+38 -5
View File
@@ -46,6 +46,9 @@ type nav struct {
// quiet gives up without a word: going back to where lazysecrets // quiet gives up without a word: going back to where lazysecrets
// was left, which may be gone. // was left, which may be gone.
quiet bool quiet bool
// link ends on the object's details, as enter on it would: opening
// a link to it opens it, rather than just finding it in its list.
link bool
} }
type resolvedMsg struct { type resolvedMsg struct {
@@ -78,6 +81,9 @@ const (
// just open it. // just open it.
// With ask false, vault and scope links open straight away. // With ask false, vault and scope links open straight away.
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd { func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
if r.IsShare() {
return m.offerShare(r)
}
if r.IsContainer() || r.IsSource() { if r.IsContainer() || r.IsSource() {
if !ask { if !ask {
return m.openRef(r, actGo) return m.openRef(r, actGo)
@@ -398,23 +404,30 @@ func (m *Model) handleResolved(msg resolvedMsg) {
for i := range msg.choices { for i := range msg.choices {
msg.choices[i].label = "Open in " + msg.choices[i].label 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) m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, true)
case len(msg.others) > 0: case len(msg.others) > 0:
var choices []choice var choices []choice
for _, row := range append([]string{msg.sourceRow}, msg.others...) { for _, row := range append([]string{msg.sourceRow}, msg.others...) {
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row}) 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) m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true)
case msg.linked != nil: case msg.linked != nil:
m.addLinked(*msg.linked) m.addLinked(*msg.linked)
m.startNav(msg.ref, msg.act, linksRowID) m.startLinkNav(msg.ref, msg.act, linksRowID)
default: default:
m.startNav(msg.ref, msg.act, msg.sourceRow) m.startLinkNav(msg.ref, msg.act, msg.sourceRow)
} }
} }
// startLinkNav opens a link: like startNav, ending on its object's
// details.
func (m *Model) startLinkNav(r ref.Ref, act linkAction, sourceRow string) {
m.startNav(r, act, sourceRow)
m.nav.link = true
}
// chooseSource asks where to open r. // chooseSource asks where to open r.
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice) { func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice, link bool) {
var opts []option var opts []option
for i, c := range choices { for i, c := range choices {
key := "" key := ""
@@ -424,6 +437,7 @@ func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []
row := c.sourceRow row := c.sourceRow
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd { opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
m.startNav(r, act, row) m.startNav(r, act, row)
m.nav.link = link
return nil return nil
}}) }})
} }
@@ -574,6 +588,9 @@ func (m *Model) advanceNav() bool {
m.pendingCmd = m.toggleReveal() m.pendingCmd = m.toggleReveal()
} }
} }
if n.link {
m.focus = paneDetail // after: copying acts on the focused pane
}
return true return true
} }
return false return false
@@ -607,6 +624,9 @@ func (m *Model) share(menu bool) tea.Cmd {
if s.URL != "" { if s.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")}) opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
} }
if m.canShareFiles(s) {
opts = append(opts, option{key: "f", label: "Encrypted link to its env files", run: func(m *Model) tea.Cmd { return m.shareFiles(s, nil) }})
}
m.showMenu("Share "+s.Name, nil, opts) m.showMenu("Share "+s.Name, nil, opts)
case paneContainers: case paneContainers:
c, ok := m.containers.Selected() c, ok := m.containers.Selected()
@@ -629,6 +649,11 @@ func (m *Model) share(menu bool) tea.Cmd {
if c.URL != "" { if c.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")}) opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
} }
if c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
opts = append(opts, option{key: "f", label: "Encrypted link to the file", run: func(m *Model) tea.Cmd {
return m.shareFiles(c.Source, []provider.Container{c})
}})
}
m.showMenu("Share "+c.Name, nil, opts) m.showMenu("Share "+c.Name, nil, opts)
default: default:
o, ok := m.detail.Secret() o, ok := m.detail.Secret()
@@ -653,6 +678,11 @@ func (m *Model) share(menu bool) tea.Cmd {
if o.URL != "" { if o.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")}) opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
} }
if c := o.Container; c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
opts = append(opts, option{key: "f", label: "Encrypted link to its file", run: func(m *Model) tea.Cmd {
return m.shareFiles(c.Source, []provider.Container{c})
}})
}
m.showMenu("Share "+o.Name, nil, opts) m.showMenu("Share "+o.Name, nil, opts)
} }
return nil return nil
@@ -802,6 +832,9 @@ func (m *Model) openText(s string) tea.Cmd {
// handlePaste opens a pasted reference, unless a text input has focus. // handlePaste opens a pasted reference, unless a text input has focus.
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd { func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
if m.search != nil && m.search.picker != nil {
return m.pickPasted(msg.Content) // a reference to the secret
}
if p := m.prompt; p != nil && p.input != nil { if p := m.prompt; p != nil && p.input != nil {
in, cmd := p.input.Update(msg) in, cmd := p.input.Update(msg)
p.input = &in p.input = &in
+2 -1
View File
@@ -40,7 +40,8 @@ func TestOpenSecretLink(t *testing.T) {
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "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) t.Fatalf("opened %s/%s/%s", src, c, s)
} }
if d.m.focus != paneSecrets || d.m.detail.State() != panes.ValueHidden { // Opened: on its details, as enter on it would; its value hidden.
if d.m.focus != paneDetail || d.m.detail.State() != panes.ValueHidden {
t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State()) t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State())
} }
} }
+20 -4
View File
@@ -1,9 +1,11 @@
package app package app
import ( import (
"cmp"
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
"unicode"
"unicode/utf8" "unicode/utf8"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
@@ -40,6 +42,7 @@ type option struct {
group string // the source row it's in (search) group string // the source row it's in (search)
kind provider.Kind // the kind of provider it's from (search) kind provider.Kind // the kind of provider it's from (search)
hits []int // characters of the label the filter matched (Go to) hits []int // characters of the label the filter matched (Go to)
plain bool // a key in a plain env file: no key to encrypt with (Go to)
run func(m *Model) tea.Cmd run func(m *Model) tea.Cmd
// header makes this a section title: not selectable, and hidden // header makes this a section title: not selectable, and hidden
// while a filter narrows the list. // while a filter narrows the list.
@@ -53,9 +56,13 @@ type modal struct {
options []option options []option
cursor int cursor int
// A text input instead of options; submit runs on enter. // A text input instead of options; submit runs on enter (named
input *textinput.Model // submitLabel in the footer; "open" if empty). err says why the last
submit func(m *Model, text string) tea.Cmd // one wasn't taken, under the input.
input *textinput.Model
submit func(m *Model, text string) tea.Cmd
submitLabel string
err string
// A filter over the options: typing narrows them (all holds them // A filter over the options: typing narrows them (all holds them
// all), so moving uses arrows or ctrl+j/k. // all), so moving uses arrows or ctrl+j/k.
@@ -259,7 +266,10 @@ func (m Model) modalView() string {
foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss")) foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss"))
if p.input != nil { if p.input != nil {
body = append(body, " "+p.input.View()) body = append(body, " "+p.input.View())
foot = footer(hint(km.PopupSelect, "open"), hint(km.PopupClose, "cancel")) if p.err != "" {
body = append(body, "", " "+m.st.Error.Render("✗ "+capitalize(p.err)))
}
foot = footer(hint(km.PopupSelect, cmp.Or(p.submitLabel, "open")), hint(km.PopupClose, "cancel"))
} }
// Size from all options, not the ones shown or the cursor's, so the // Size from all options, not the ones shown or the cursor's, so the
@@ -406,3 +416,9 @@ func (m Model) overlay(base string) string {
lipgloss.NewLayer(box).X(x).Y(y).Z(1), lipgloss.NewLayer(box).X(x).Y(y).Z(1),
).Render() ).Render()
} }
// capitalize upper-cases the first letter of a message.
func capitalize(s string) string {
r, n := utf8.DecodeRuneInString(s)
return string(unicode.ToUpper(r)) + s[n:]
}
+1 -2
View File
@@ -3,7 +3,6 @@ package app
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@@ -244,6 +243,6 @@ func (m *Model) restorePlace() {
func (m *Model) handlePersisted(msg persistedMsg) { func (m *Model) handlePersisted(msg persistedMsg) {
if msg.err != nil { if msg.err != nil {
m.noteErr(fmt.Errorf("saving the cache or place: %w", msg.err)) m.logErrOf("saving the cache or place", msg.err)
} }
} }
+53 -3
View File
@@ -206,9 +206,9 @@ func TestSearchAllRepos(t *testing.T) {
for d.m.search.src > 0 { for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp}) d.send(tea.KeyPressMsg{Code: tea.KeyUp})
} }
for d.m.search.scope().id != kindScope+string(provider.KindRepo) { for d.m.search.scope().id != accountScope+"Repos" {
if d.m.search.src == len(d.m.search.scopes)-1 { if d.m.search.src == len(d.m.search.scopes)-1 {
t.Fatalf("no All repos scope: %v", d.m.search.scopes) t.Fatalf("no Repos scope: %v", d.m.search.scopes)
} }
d.send(tea.KeyPressMsg{Code: tea.KeyDown}) d.send(tea.KeyPressMsg{Code: tea.KeyDown})
} }
@@ -235,7 +235,7 @@ func TestSearchAllRepos(t *testing.T) {
if azure() != azureBefore || d.m.crawl.running { if azure() != azureBefore || d.m.crawl.running {
t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure()) t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure())
} }
if !strings.Contains(d.screen(), "Results in all repos") { if !strings.Contains(d.screen(), "Results in Repos") {
t.Errorf("title:\n%s", d.screen()) t.Errorf("title:\n%s", d.screen())
} }
} }
@@ -342,3 +342,53 @@ func TestEditorAtLine(t *testing.T) {
} }
} }
} }
func TestEditConfig(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "lazysecrets", "config.yaml")
var opened []string
d := newDriverOpts(t, Options{ConfigFiles: []string{path}, OpenURL: func(u string) error { opened = append(opened, u); return nil }})
d.m.getenv = func(string) string { return "" }
// e on the status pane. No editor: the system's default app, on a
// config made for it.
d.keys("0", "e")
if !slices.Equal(opened, []string{path}) {
t.Errorf("opened %q", opened)
}
if b, err := os.ReadFile(path); err != nil || !strings.HasPrefix(string(b), "# lazysecrets config") {
t.Errorf("config made: %q, %v", b, err)
}
// Inside nvim: in that nvim; an existing config is left as it is.
os.WriteFile(path, []byte("icons: false\n"), 0o644)
d.m.getenv = func(k string) string { return map[string]string{"NVIM": "/tmp/nvim.sock"}[k] }
var nvim []string
d.m.nvimOpen = func(server, path string, line int) error {
nvim = append(nvim, server+" "+path)
return nil
}
if msg := d.m.editConfig()(); msg != (tea.QuitMsg{}) {
t.Errorf("didn't quit for nvim: %v", msg)
}
if want := []string{"/tmp/nvim.sock " + path}; !slices.Equal(nvim, want) {
t.Errorf("nvim got %q", nvim)
}
if b, _ := os.ReadFile(path); string(b) != "icons: false\n" {
t.Errorf("config changed: %q", b)
}
d.send(editDoneMsg{name: "config.yaml", config: true})
if !strings.Contains(d.m.flash.text, "restart") {
t.Errorf("flash %q", d.m.flash.text)
}
}
func TestStatusFooterEditsConfig(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("0")
if !strings.Contains(d.screen(), "e edit config") {
t.Errorf("no edit config in the footer:\n%s", d.screen())
}
}
+24 -10
View File
@@ -61,13 +61,14 @@ func (m *Model) searchEntries() (sources, items []option) {
var es []entry var es []entry
// key is the store key, so ctrl+f stars the entry; mark shows how // key is the store key, so ctrl+f stars the entry; mark shows how
// much is loaded, reload (ctrl+r) loads it again. // much is loaded, reload (ctrl+r) loads it again.
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) { add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) *option {
n := m.store.Node(key) n := m.store.Node(key)
sort := strings.ToLower(strings.Join(path, "/")) + " " + account sort := strings.ToLower(strings.Join(path, "/")) + " " + account
es = append(es, entry{depth, sort, es = append(es, entry{depth, sort,
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key, option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)), starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}}) mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
return &es[len(es)-1].opt
} }
for _, p := range m.providers { for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources { for _, src := range m.sourceResults[p.ID()].sources {
@@ -98,10 +99,11 @@ func (m *Model) searchEntries() (sources, items []option) {
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key" typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
suffix = strings.Trim(typ+" · "+suffix, " ·") suffix = strings.Trim(typ+" · "+suffix, " ·")
} }
add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd { e := add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
m.startNav(r, actGo, row) m.startNav(r, actGo, row)
return nil return nil
}, reload) }, reload)
e.plain = c.Detail == provider.DetailPlainEnv
} }
} }
} }
@@ -131,13 +133,25 @@ func (c coverage) complete() bool {
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
} }
func (m *Model) searchCoverage() coverage { return m.coverageOf("") } func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
// coverageOf is searchCoverage for one kind of provider ("" for all). // providerFilter picks the providers a search or a load covers; nil is
func (m *Model) coverageOf(kind provider.Kind) coverage { // all of them.
type providerFilter func(provider.Provider) bool
func ofKind(k provider.Kind) providerFilter {
return func(p provider.Provider) bool { return p.Kind() == k }
}
func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id }
}
// coverageOf is searchCoverage for the providers in.
func (m *Model) coverageOf(in providerFilter) coverage {
var c coverage var c coverage
for _, p := range m.providers { for _, p := range m.providers {
if kind != "" && p.Kind() != kind { if in != nil && !in(p) {
continue continue
} }
for _, src := range m.sourceResults[p.ID()].sources { for _, src := range m.sourceResults[p.ID()].sources {
@@ -197,12 +211,12 @@ func plural(n int, word string) string {
// startCrawl loads every unlisted subscription's vaults and every vault's // startCrawl loads every unlisted subscription's vaults and every vault's
// secrets not loaded yet. // secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf("") } func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
// startCrawlOf is startCrawl for one kind of provider ("" for all). // startCrawlOf is startCrawl for the providers in.
func (m *Model) startCrawlOf(kind provider.Kind) tea.Cmd { func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
for _, p := range m.providers { for _, p := range m.providers {
if kind != "" && p.Kind() != kind { if in != nil && !in(p) {
continue continue
} }
for _, src := range m.sourceResults[p.ID()].sources { for _, src := range m.sourceResults[p.ID()].sources {
+76 -14
View File
@@ -42,11 +42,15 @@ func TestSearchLoaded(t *testing.T) {
t.Fatalf("missing %q:\n%s", want, d.screen()) t.Fatalf("missing %q:\n%s", want, d.screen())
} }
} }
d.typeText("api-key") d.typeText("api-key") // on All: found at once
if !strings.Contains(d.screen(), "no matches here · All searches everything") { if !strings.Contains(d.screen(), "ctrl+s only platform-dev") {
t.Errorf("no hint to narrow to the source:\n%s", d.screen())
}
d.send(ctrl('s'))
if !strings.Contains(d.screen(), "no matches here · All searches everything") || !strings.Contains(d.screen(), "ctrl+s searches all") {
t.Errorf("no hint to search everything:\n%s", d.screen()) t.Errorf("no hint to search everything:\n%s", d.screen())
} }
d.searchAll() d.send(ctrl('s'))
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") { if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") {
t.Fatalf("loaded secret not found:\n%s", d.screen()) t.Fatalf("loaded secret not found:\n%s", d.screen())
} }
@@ -62,7 +66,8 @@ func TestSearchLoaded(t *testing.T) {
func TestSearchLoadsAsYouType(t *testing.T) { func TestSearchLoadsAsYouType(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.openSearch() // on the source the cursor is on: platform-prod d.openSearch()
d.send(ctrl('s')) // only the source the cursor is on: platform-prod
// Typing loads the scope: that source, without ctrl+a. // Typing loads the scope: that source, without ctrl+a.
d.typeText("smtp") d.typeText("smtp")
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") { if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
@@ -76,10 +81,7 @@ func TestSearchLoadsAsYouType(t *testing.T) {
} }
// On All: everything. // On All: everything.
d.keys("tab") // to the scopes d.send(ctrl('s'))
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
d.typeText("-") d.typeText("-")
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok { if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok {
t.Errorf("typing on All didn't load the other sources:\n%s", d.screen()) t.Errorf("typing on All didn't load the other sources:\n%s", d.screen())
@@ -183,19 +185,61 @@ func TestSearchStartsWhereYouAre(t *testing.T) {
d.keys("2", "j") // platform-prod / kv-shared-prod d.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch() d.openSearch()
v := d.m.search v := d.m.search
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-prod") { scope := func() string { return ansiStrip(v.scopes[v.src].label) }
t.Errorf("source not preselected: %q", got) if scope() != "All" {
t.Errorf("opened on %q, want All", scope())
} }
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") { if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
t.Errorf("vault not under the cursor:\n%s", d.screen()) t.Errorf("vault not under the cursor:\n%s", d.screen())
} }
d.send(ctrl('s')) // narrows to the source, and back
if !strings.Contains(scope(), "platform-prod") {
t.Errorf("ctrl+s narrowed to %q", scope())
}
d.send(ctrl('s'))
if scope() != "All" {
t.Errorf("second ctrl+s on %q, want All", scope())
}
// From the Sources pane: just the source. // From Favourites: to Search, on All.
d.keys("esc", "esc", "1", "j") // stop typing, close; platform-dev d.keys("esc", "]", "]") // stop typing; Favourites
d.send(ctrl('s'))
if v.tab != tabSearch || scope() != "All" {
t.Errorf("ctrl+s from another tab: tab %d, %q", v.tab, scope())
}
// From the Sources pane: the source.
d.keys("esc", "1", "j") // close; platform-dev
d.openSearch() d.openSearch()
v = d.m.search v = d.m.search
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-dev") { d.send(ctrl('s'))
t.Errorf("source not preselected: %q", got) if !strings.Contains(scope(), "platform-dev") {
t.Errorf("ctrl+s narrowed to %q", scope())
}
}
func TestSearchNarrowsToAccount(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("1", "g") // the first group header
header, _ := d.m.sources.Selected()
d.openSearch()
d.send(ctrl('s'))
v := d.m.search
if v.scope().id != accountScope+header.provider {
t.Fatalf("ctrl+s on %q narrowed to %q", header.label, v.scope().id)
}
if len(v.shown) == 0 || !strings.Contains(d.screen(), "Results in "+header.label) {
t.Errorf("no results in the account:\n%s", d.screen())
}
for _, o := range v.shown {
if providerOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansiStrip(o.label))
}
}
// The account's sources are under it, indented.
if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") {
t.Errorf("after the account: %q", ansiStrip(next.label))
} }
} }
@@ -239,6 +283,24 @@ func TestGoToTabsAndModes(t *testing.T) {
t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen()) t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen())
} }
// g and G: top and bottom of the list with the cursor.
d.keys("G")
if v.res != len(v.shown)-1 || v.res == 0 {
t.Errorf("G on results: %d of %d", v.res, len(v.shown))
}
d.keys("g")
if v.res != 0 {
t.Errorf("g on results: %d", v.res)
}
d.keys("h", "G")
if v.src != len(v.scopes)-1 {
t.Errorf("G on scopes: %d of %d", v.src, len(v.scopes))
}
d.keys("g", "l")
if v.src != 0 {
t.Errorf("g on scopes: %d", v.src)
}
d.keys("/") d.keys("/")
d.typeText("shared") d.typeText("shared")
if !v.typing || len(v.shown) == 0 { if !v.typing || len(v.shown) == 0 {
+248 -76
View File
@@ -14,22 +14,24 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/match" "git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "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/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys" "git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
) )
// Go to is one popup for finding a place and going there. Tabs at the // Go to is one popup for finding a place and going there. Tabs at the
// top: Favourites and Recent (from the store, so they work before // top: Search has two lists side by side, the scope on the left (All, or
// anything loads) are one list each; Search has two lists side by side, // one source with how much of it is loaded: ○ ◐ ● ◌ ✗) and what's in it
// the scope on the left (All, or one source with how much of it is // on the right; Favourites and Recent (from the store, so they work
// loaded: ○ ◐ ● ◌ ✗) and what's in it on the right. // before anything loads) are one list each.
// //
// Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f // Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f
// star, / to type) or typing a filter (ctrl+ letters). esc while typing // star, / to type) or typing a filter (ctrl+ letters). esc while typing
// clears the text and goes back to browsing; esc while browsing closes. // clears the text and goes back to browsing; esc while browsing closes.
// F opens it on Favourites and R on Recent, browsing; search on Search, // F opens it on Favourites and R on Recent, browsing; search on Search,
// typing. // typing, on All. search again narrows to the source you were in, and
// back to All.
// //
// The results don't move by themselves: as lists load, rows already shown // The results don't move by themselves: as lists load, rows already shown
// keep their order and new ones come after them, with the cursor on the // keep their order and new ones come after them, with the cursor on the
@@ -39,7 +41,7 @@ import (
type searchView struct { type searchView struct {
input textinput.Model input textinput.Model
typing bool // the text field has the keyboard typing bool // the text field has the keyboard
tab int // tabFavs, tabRecent or tabSearch tab int // tabSearch, tabFavs or tabRecent
results bool // in Search, the results have the cursor (else the scopes) results bool // in Search, the results have the cursor (else the scopes)
scopes []option // All, then the sources (Search) scopes []option // All, then the sources (Search)
@@ -49,6 +51,10 @@ type searchView struct {
shown []option // the tab's (or scope's) entries matching the input shown []option // the tab's (or scope's) entries matching the input
src int // cursor in scopes src int // cursor in scopes
res int // cursor in shown res int // cursor in shown
here string // the scope of the source Go to was opened from, if any
// picker, if set, makes Go to pick a secret instead of going to it.
picker *picker
// loaded are the scopes a search has loaded since the popup opened: // loaded are the scopes a search has loaded since the popup opened:
// typing loads the scope once, so lists that fail aren't asked for // typing loads the scope once, so lists that fail aren't asked for
@@ -57,25 +63,25 @@ type searchView struct {
} }
const ( const (
tabFavs = iota tabSearch = iota
tabFavs
tabRecent tabRecent
tabSearch
tabCount tabCount
) )
var tabNames = [tabCount]string{"Favourites", "Recent", "Search"} var tabNames = [tabCount]string{"Search", "Favourites", "Recent"}
const ( const (
scopeFavs = "#favs" scopeFavs = "#favs"
scopeRecent = "#recent" scopeRecent = "#recent"
allSources = "#all" allSources = "#all"
kindScope = "#kind:" // + a provider kind: all of Azure, Databricks or repos accountScope = "#account:" // + a provider ID: an account's sources, or Databricks', or the repos
) )
const goToTitle = "Go to" const goToTitle = "Go to"
// showSearch opens Go to on Search, typing, in the source you're in // showSearch opens Go to on Search, typing, on All, with the selected
// (else All), with the selected vault under the cursor. // vault under the cursor.
func (m *Model) showSearch() tea.Cmd { func (m *Model) showSearch() tea.Cmd {
cmd := m.openGoTo(tabSearch, true) cmd := m.openGoTo(tabSearch, true)
m.preselect() m.preselect()
@@ -131,17 +137,20 @@ func (v *searchView) scope() option {
// onSource reports whether the results come from one source. // onSource reports whether the results come from one source.
func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" } func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" }
// preselect starts where the user is: the selected source picked on the // preselect starts where the user is: the selected source is the one
// left, and the selected vault or scope under the cursor on the right. // search narrows to, and the selected vault or scope is under the cursor.
func (m *Model) preselect() { func (m *Model) preselect() {
v := m.search v := m.search
row := m.sources.SelectedID() row := m.sources.SelectedID()
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.group != "" && o.group == row }) account := ""
if id, ok := strings.CutPrefix(row, "group:"); ok { // a group header
account = accountScope + id
}
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == account || o.group != "" && o.group == row })
if i < 0 { if i < 0 {
return return
} }
v.src = i v.here = v.scopes[i].id
m.fillSearch(false)
if m.focus == paneSources { if m.focus == paneSources {
return return
} }
@@ -171,25 +180,36 @@ func (m *Model) fillSearch(keep bool) {
} }
sources, items := m.searchEntries() sources, items := m.searchEntries()
all := option{label: "All", id: allSources, mark: m.coverageMark("")} all := option{label: "All", id: allSources, mark: m.coverageMark(nil)}
scopes := []option{all} // Grouped like the Sources pane: each account (or Databricks, or the
// All of one kind, so a search can stay on repos (local, quick) // repos) is a scope too, its sources under it, so a search can stay on
// without asking Azure for every vault; only with several kinds. // one, like the repos (local, quick), without asking Azure for every
var kinds []provider.Kind // vault. With only one, it's the same as All.
var groups []provider.Provider
for _, p := range m.providers { for _, p := range m.providers {
if len(m.sourceResults[p.ID()].sources) > 0 && !slices.Contains(kinds, p.Kind()) { if len(m.sourceResults[p.ID()].sources) > 0 {
kinds = append(kinds, p.Kind()) groups = append(groups, p)
} }
} }
if len(kinds) > 1 { scopes := []option{all}
for _, k := range kinds { if len(groups) < 2 {
scopes = append(scopes, sources...)
} else {
for _, p := range groups {
k := p.Kind()
scopes = append(scopes, option{ scopes = append(scopes, option{
label: m.st.Kind(k).Render(m.icons.Prefix(k) + kindScopeLabel(k)), label: m.st.Kind(k).Render(m.icons.Prefix(k) + p.Label()),
id: kindScope + string(k), kind: k, mark: m.coverageMark(k), id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
}) })
for _, o := range sources {
if providerOf(o.group) == p.ID() {
o.label = " " + o.label
scopes = append(scopes, o)
}
}
} }
} }
v.scopes, v.items = append(scopes, sources...), items v.scopes, v.items = scopes, items
v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc })) v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc }))
v.shown = v.match() v.shown = v.match()
@@ -202,17 +222,9 @@ func (m *Model) fillSearch(keep bool) {
v.res = max(0, min(v.res, len(v.shown)-1)) v.res = max(0, min(v.res, len(v.shown)-1))
} }
// kindScopeLabel names the scope of all of a kind's sources. // coverageMark is ○ ◐ ● for how much of the providers in is loaded.
func kindScopeLabel(k provider.Kind) string { func (m *Model) coverageMark(in providerFilter) string {
if k == provider.KindRepo { switch c := m.coverageOf(in); {
return "All repos"
}
return "All " + string(k)
}
// coverageMark is ○ ◐ ● for how much of a kind ("" for all) is loaded.
func (m *Model) coverageMark(kind provider.Kind) string {
switch c := m.coverageOf(kind); {
case c.complete(): case c.complete():
return m.st.Success.Render("●") return m.st.Success.Render("●")
case c.vaultsLoaded > 0: case c.vaultsLoaded > 0:
@@ -221,15 +233,28 @@ func (m *Model) coverageMark(kind provider.Kind) string {
return m.st.Muted.Render("○") return m.st.Muted.Render("○")
} }
// scopeKind is the kind a scope covers all of, if it's one of those. // scopeAccount is the provider whose sources the scope covers, if it's
func (v *searchView) scopeKind() (provider.Kind, bool) { // an account's.
k, ok := strings.CutPrefix(v.scope().id, kindScope) func (v *searchView) scopeAccount() (string, bool) {
id, ok := strings.CutPrefix(v.scope().id, accountScope)
if !ok || v.tab != tabSearch { if !ok || v.tab != tabSearch {
return "", false return "", false
} }
return provider.Kind(k), true return id, true
} }
// scopeFilter is what loading everything in the scope covers: an
// account's, else all of them.
func (v *searchView) scopeFilter() providerFilter {
if id, ok := v.scopeAccount(); ok {
return ofProvider(id)
}
return nil
}
// scopeName is the scope's label without colours or indent.
func scopeName(o option) string { return strings.TrimSpace(ansi.Strip(o.label)) }
// match lists the scope's entries matching the input, best first. // match lists the scope's entries matching the input, best first.
func (v *searchView) match() []option { func (v *searchView) match() []option {
var cand []option var cand []option
@@ -239,13 +264,16 @@ func (v *searchView) match() []option {
case scopeRecent: case scopeRecent:
cand = v.recent cand = v.recent
default: default:
kind, ofKind := v.scopeKind() account, ofAccount := v.scopeAccount()
for _, o := range v.items { for _, o := range v.items {
if sc.id == allSources || ofKind && o.kind == kind || sc.group != "" && o.group == sc.group { if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
cand = append(cand, o) cand = append(cand, o)
} }
} }
} }
if v.picker != nil {
cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok })
}
q := v.input.Value() q := v.input.Value()
if q == "" { if q == "" {
return slices.Clone(cand) return slices.Clone(cand)
@@ -320,6 +348,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
// The actions shared by both modes, by their keys in the mode. // The actions shared by both modes, by their keys in the mode.
type modeKeys struct{ down, up, sources, results, switchList, star, load, loadAll, sel key.Binding } type modeKeys struct{ down, up, sources, results, switchList, star, load, loadAll, sel key.Binding }
k := modeKeys{km.GoDown, km.GoUp, km.GoSources, km.GoResults, km.GoSwitch, km.GoStar, km.GoLoad, km.GoLoadAll, km.GoSelect} k := modeKeys{km.GoDown, km.GoUp, km.GoSources, km.GoResults, km.GoSwitch, km.GoStar, km.GoLoad, km.GoLoadAll, km.GoSelect}
if key.Matches(msg, km.Search) {
return m.narrow()
}
if v.typing { if v.typing {
k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect} k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect}
if key.Matches(msg, km.PopupClose) { if key.Matches(msg, km.PopupClose) {
@@ -338,6 +369,22 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
return nil return nil
case key.Matches(msg, km.GoFilter): case key.Matches(msg, km.GoFilter):
return m.setTyping(true) return m.setTyping(true)
case key.Matches(msg, km.GoTop), key.Matches(msg, km.GoBottom):
n := len(v.shown)
if !v.results {
n = len(v.scopes)
}
i := 0
if key.Matches(msg, km.GoBottom) {
i = max(0, n-1)
}
if v.results {
v.res = i
} else if v.src != i {
v.src, v.res = i, 0
m.fillSearch(false)
}
return nil
case key.Matches(msg, km.Help): case key.Matches(msg, km.Help):
return m.showKeybindingsFor(keys.GoTo) return m.showKeybindingsFor(keys.GoTo)
case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab): case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab):
@@ -383,9 +430,14 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
return nil return nil
case key.Matches(msg, k.sel): case key.Matches(msg, k.sel):
switch { switch {
case v.results && v.res < len(v.shown) && v.picker != nil:
r, _ := pickable(v.shown[v.res])
return v.picker.choose(m, r)
case v.results && v.res < len(v.shown): case v.results && v.res < len(v.shown):
m.search = nil m.search = nil
return v.shown[v.res].run(m) return v.shown[v.res].run(m)
case !v.results && v.picker != nil:
v.results = true // a picker only narrows to the scope
case !v.results && v.onSource(): case !v.results && v.onSource():
m.search = nil m.search = nil
return v.scope().run(m) return v.scope().run(m)
@@ -401,11 +453,9 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
if v.onSource() { if v.onSource() {
return v.scope().reload(m) return v.scope().reload(m)
} }
kind, _ := v.scopeKind() return m.startCrawlOf(v.scopeFilter())
return m.startCrawlOf(kind)
case inSearch && key.Matches(msg, k.loadAll): case inSearch && key.Matches(msg, k.loadAll):
kind, _ := v.scopeKind() return m.startCrawlOf(v.scopeFilter())
return m.startCrawlOf(kind)
case key.Matches(msg, k.star): case key.Matches(msg, k.star):
if o, ok := v.selected(); ok && o.star != "" { if o, ok := v.selected(); ok && o.star != "" {
m.toggleStarKey(o.star, o.starAccount) m.toggleStarKey(o.star, o.starAccount)
@@ -436,6 +486,22 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
return cmd return cmd
} }
// narrow switches Search between All and the source Go to was opened
// from; from another tab, or another scope, it goes to Search on All.
func (m *Model) narrow() tea.Cmd {
v := m.search
src := 0
if v.tab == tabSearch && v.scope().id == allSources {
src = slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here })
if src < 0 {
return nil
}
}
v.tab, v.src, v.res, v.results = tabSearch, src, 0, true
m.fillSearch(false)
return m.searchLoads()
}
// searchLoads loads what a search in the Search tab covers, like the // searchLoads loads what a search in the Search tab covers, like the
// load-all key: everything on All, else the source picked. Only what // load-all key: everything on All, else the source picked. Only what
// isn't loaded yet, once a popup; repos load by themselves anyway. // isn't loaded yet, once a popup; repos load by themselves anyway.
@@ -452,8 +518,8 @@ func (m *Model) searchLoads() tea.Cmd {
v.loaded = map[string]bool{} v.loaded = map[string]bool{}
} }
v.loaded[sc.id] = true v.loaded[sc.id] = true
if kind, ok := v.scopeKind(); ok { if _, ok := v.scopeAccount(); ok {
return m.startCrawlOf(kind) return m.startCrawlOf(v.scopeFilter())
} }
if !v.onSource() { if !v.onSource() {
return m.startCrawl() return m.startCrawl()
@@ -497,6 +563,35 @@ func (m *Model) toggleStarKey(key, account string) {
// searchHint tops the results when what they come from isn't all // searchHint tops the results when what they come from isn't all
// loaded, saying how to load it. // loaded, saying how to load it.
func (m Model) searchHint() string { func (m Model) searchHint() string {
load, narrow := m.loadHint(), m.narrowHint()
switch {
case narrow == "":
return load
case load != "":
narrow = " · " + narrow
}
return load + m.st.Muted.Render(narrow)
}
// narrowHint names the key for the other of All and the source Go to was
// opened from.
func (m Model) narrowHint() string {
v := m.search
if v.tab != tabSearch || m.crawl.running {
return ""
}
if v.scope().id == v.here && v.here != "" {
return withKey(m.keys.Search, "%s searches all")
}
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here })
if i < 0 || v.scope().id != allSources {
return ""
}
return withKey(m.keys.Search, "%s only "+scopeName(v.scopes[i]))
}
// loadHint says how much of the scope is loaded, and the key to load it.
func (m Model) loadHint() string {
v := m.search v := m.search
if m.crawl.running { if m.crawl.running {
return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active)) return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active))
@@ -504,8 +599,8 @@ func (m Model) searchHint() string {
if v.tab != tabSearch { if v.tab != tabSearch {
return "" // from the store: nothing to load return "" // from the store: nothing to load
} }
if kind, ok := v.scopeKind(); ok || v.scope().id == allSources { if _, ok := v.scopeAccount(); ok || v.scope().id == allSources {
c := m.coverageOf(kind) c := m.coverageOf(v.scopeFilter())
if c.complete() { if c.complete() {
return "" return ""
} }
@@ -558,7 +653,16 @@ func (m Model) searchViewBox() string {
v := m.search v := m.search
focused := !m.termBlurred focused := !m.termBlurred
w := searchWidth(m.w) w := searchWidth(m.w)
popupW := w
if v.picker != nil {
w -= 4 // inside the picker's border, in the popup
}
listH := max(5, min(m.h-10, 14)) listH := max(5, min(m.h-10, 14))
if v.picker != nil {
// What it's for, the help, a line or two on the key picked, the
// tabs and a border more.
listH = max(5, min(m.h-10-len(v.picker.lines)-len(v.picker.help)-8, 14))
}
srcW := 10 srcW := 10
for _, o := range v.scopes { for _, o := range v.scopes {
@@ -637,14 +741,8 @@ func (m Model) searchViewBox() string {
} }
title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) } title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) }
resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id] resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id]
if v.onSource() { if _, ok := v.scopeAccount(); ok || v.onSource() {
resultsTitle = "Results in " + ansi.Strip(v.scope().label) resultsTitle = "Results in " + scopeName(v.scope())
}
if kind, ok := v.scopeKind(); ok {
resultsTitle = "Results in " + string(kind)
if kind == provider.KindRepo {
resultsTitle = "Results in all repos"
}
} }
var lists string var lists string
if v.tab == tabSearch { if v.tab == tabSearch {
@@ -656,7 +754,24 @@ func (m Model) searchViewBox() string {
lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused) lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused)
} }
body := []string{" " + v.input.View(), ""} ts := panes.TitleStyle(m.st, focused)
var body []string
if p := v.picker; p != nil {
for _, l := range p.help {
body = append(body, " "+l)
}
switch {
case p.busy != "":
body = append(body, "", " "+m.st.Notice.Render(p.busy))
case p.err != "":
body = append(body, "")
for _, l := range strings.Split(ansi.Wordwrap(p.err, w-4, " "), "\n") {
body = append(body, " "+m.st.Error.Render(l))
}
}
body = append(body, "", " "+m.goToTabs(ts))
}
body = append(body, " "+v.input.View(), "")
for _, l := range strings.Split(lists, "\n") { for _, l := range strings.Split(lists, "\n") {
body = append(body, " "+l) body = append(body, " "+l)
} }
@@ -676,26 +791,83 @@ func (m Model) searchViewBox() string {
if v.tab == tabSearch { if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load")) hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load"))
} }
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, "go"), hint(km.PopupClose, "stop typing"))...) foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, v.selectLabel()), hint(km.PopupClose, "stop typing"))...)
default: default:
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}} hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
if v.tab == tabSearch { if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all")) hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all"))
} }
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, "go"), hint(km.GoClose, "close"))...) foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, v.selectLabel()), hint(km.GoClose, "close"))...)
} }
// Tabs in the title, the current one underlined, like the panes. // Tabs in the title, the current one underlined, like the panes; a
ts := panes.TitleStyle(m.st, focused) // picker has its own title, and the tabs over the input.
head := ts.Render(goToTitle + " ") if v.picker == nil {
head := ts.Render(goToTitle+" ") + m.goToTabs(ts)
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
}
// A picker: in its own border, under what it's for.
var outer []string
for _, l := range v.picker.lines {
outer = append(outer, " "+l)
}
outer = append(outer, "")
for _, l := range strings.Split(panes.Box(m.st, ts.Render(v.picker.heading), "", body, w, len(body)+2, focused), "\n") {
outer = append(outer, " "+l)
}
return panes.Box(m.st, ts.Render(v.picker.title), foot, outer, popupW, len(outer)+2, focused)
}
// picker is Go to picking a secret for something (the key to share
// with): it lists secrets only, and enter hands the selected one's link
// to pick, which closes Go to when it's done with it.
type picker struct {
pick func(m *Model, r ref.Ref) tea.Cmd
title string // the popup's
lines []string // what picking is for, above the picker
heading string // the picker's title, on its border
help []string // inside it, above the tabs
busy string // what the pick is doing (picks wait meanwhile)
err string // why the secret picked can't be used
}
// choose hands r to the picker, unless it's still busy with one.
func (p *picker) choose(m *Model, r ref.Ref) tea.Cmd {
if p.busy != "" {
return nil
}
return p.pick(m, r)
}
// selectLabel names what enter does with the selected entry.
func (v *searchView) selectLabel() string {
if v.picker != nil {
return "pick"
}
return "go"
}
// pickable is the link of a Go to entry that a picker can pick: a
// secret (not a key or certificate, nor a key in a plain env file).
func pickable(o option) (ref.Ref, bool) {
r, err := ref.Parse(o.star)
if err != nil || o.plain || r.IsShare() || r.IsSource() || r.IsContainer() || (r.Type != "" && r.Type != provider.TypeSecret) {
return ref.Ref{}, false
}
return r, true
}
// goToTabs draws Go to's tabs, the current one underlined.
func (m Model) goToTabs(ts lipgloss.Style) string {
var out string
for i, name := range tabNames { for i, name := range tabNames {
if i > 0 { if i > 0 {
head += ts.Render(" - ") out += ts.Render(" - ")
} }
if i == v.tab { if i == m.search.tab {
head += ts.Underline(true).Render(name) out += ts.Underline(true).Render(name)
} else { } else {
head += m.st.Muted.Render(name) out += m.st.Muted.Render(name)
} }
} }
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused) return out
} }
+471
View File
@@ -0,0 +1,471 @@
package app
import (
"context"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"slices"
"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/ref"
"git.kebcloud.no/keb/lazysecrets/internal/share"
)
// Sharing env files as an encrypted lazysecrets://share link (package
// share), and opening one: the files are encrypted with a secret's value,
// so whoever can read that secret can open the link.
// sharing is a share being made: the files read, and the key to use.
type sharing struct {
repo provider.Source
files []share.File
key ref.Ref // zero until chosen
}
type shareFilesMsg struct {
sharing
err error
}
type sealedMsg struct {
link ref.Ref
n int
// from is the picker the key was picked in; nil for share.key's.
from *picker
err error
}
// canShareFiles reports whether src's env files can be shared: it's a
// repo whose files can be read.
func (m Model) canShareFiles(src provider.Source) bool {
_, ok := m.providerFor(src).(provider.Contents)
return src.Kind == provider.KindRepo && ok
}
// shareFiles shares env files of a repo: cs, or with cs nil all its
// plain ones.
func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd {
p := m.providerFor(src)
files, ok := p.(provider.Contents)
if !ok {
m.flash = warning("these files can't be shared")
return nil
}
general, timeout := m.shareKey, m.timeout
m.flash = flash{text: "reading the files…"}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
msg := shareFilesMsg{sharing: sharing{repo: src}}
if cs == nil {
all, err := p.Containers(ctx, src)
if err != nil {
msg.err = err
return msg
}
for _, c := range all {
if c.Detail == provider.DetailPlainEnv {
cs = append(cs, c)
}
}
if len(cs) == 0 {
msg.err = errors.New(src.Name + " has no plain env files")
return msg
}
}
for _, c := range cs {
b, err := files.Contents(ctx, c)
if err != nil {
msg.err = err
return msg
}
msg.files = append(msg.files, share.File{Path: c.Name, Data: b})
}
key, ok, err := share.DefaultKey(src.ID, general)
if ok {
msg.key = key
}
msg.err = err
return msg
}
}
func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd {
switch {
case msg.err != nil:
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
case msg.key == (ref.Ref{}):
return m.pickShareKey(msg.sharing)
default:
m.confirmShare(msg.sharing)
}
return nil
}
// shareTitle names the action: "Share .env", "Share 2 env files".
func shareTitle(s sharing) string {
if len(s.files) == 1 {
return "Share " + path.Base(s.files[0].Path)
}
return "Share " + plural(len(s.files), "env file")
}
// sharedFiles lists the files being shared, under what's happening.
func (m *Model) sharedFiles(s sharing) []string {
lines := []string{"Sharing " + plural(len(s.files), "env file") + " as an encrypted link:"}
for _, f := range s.files {
lines = append(lines, " "+m.repoLabel(s.repo.Name, f.Path))
}
return lines
}
// confirmShare says what the link will hold before making it.
func (m *Model) confirmShare(s sharing) {
lines := append(m.sharedFiles(s), "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
m.showMenu(shareTitle(s), lines, []option{
{key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s, nil) }},
{key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.pickShareKey(s) }},
})
}
// pickShareKey asks which secret to encrypt with, in Go to, then shares
// with it: the picker showed what's shared, so there's no asking again.
// It stays open until the link is made, to say why a secret can't be
// used.
func (m *Model) pickShareKey(s sharing) tea.Cmd {
cmd := m.openGoTo(tabSearch, true)
p := &picker{
title: shareTitle(s),
lines: m.sharedFiles(s),
heading: "Pick a secret to encrypt with",
help: []string{
m.st.Muted.Render("The recipient needs read access to it to decrypt the files."),
m.st.Muted.Render("Its value isn't shown or put in the link."),
},
}
p.pick = func(m *Model, key ref.Ref) tea.Cmd {
s.key = key
p.err, p.busy = "", "encrypting with "+key.Name+"…"
cmd := m.seal(s, p)
m.flash = flash{}
return cmd
}
m.search.picker = p
m.search.input.SetWidth(searchWidth(m.w) - 10) // inside the picker's border
m.fillSearch(false)
return tea.Batch(cmd, m.searchLoads())
}
// pickPasted picks a pasted reference to a secret, while picking one.
func (m *Model) pickPasted(text string) tea.Cmd {
key, err := share.ParseKey(strings.TrimSpace(text))
if err != nil {
m.search.picker.err = "✗ " + capitalize(err.Error())
return nil
}
return m.search.picker.choose(m, key)
}
// seal reads the key and encrypts the files with it, for the picker
// from (nil: the key wasn't picked).
func (m *Model) seal(s sharing, from *picker) tea.Cmd {
read, rules := m.keyReader(), m.shareRules
m.flash = flash{text: "encrypting with " + s.key.Name + "…"}
return func() tea.Msg {
v, version, err := read(s.key)
if err != nil {
return sealedMsg{from: from, err: &share.KeyError{Name: s.key.Name, Err: err}}
}
link, err := share.Seal(share.Header{Key: share.Pin(s.key, version), Repo: s.repo.Detail}, []byte(v.Reveal()), s.files, rules)
return sealedMsg{link: link, n: len(s.files), from: from, err: err}
}
}
func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
if p := msg.from; p != nil {
if m.search == nil || m.search.picker != p {
return nil // the picker was closed: cancelled
}
p.busy = ""
var bad *share.KeyError
if errors.As(msg.err, &bad) {
p.err = "✗ " + capitalize(bad.Error()) // to pick another
return nil
}
m.search = nil
}
if msg.err != nil {
m.noteErr(msg.err)
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
return nil
}
m.dismissedLink = msg.link.String() // not offered back on focus
return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false)
}
// keyReader reads a key's value with the providers, the account that
// last read it first, off the UI goroutine.
func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) {
providers, st, timeout := m.providers, m.store, m.timeout
return func(key ref.Ref) (provider.SecretValue, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
k := key
k.Version = ""
node := st.Node(k.String())
return ref.Read(ctx, providers, key, node.Account, node.Source)
}
}
// --- opening ---
type shareOpenedMsg struct {
sealed share.Sealed
files []share.File
dir string // the repo's clone; "" when it isn't here
err error
}
// offerShare says what a share link holds before decrypting it: links
// can come from anywhere, so nothing is read or written unasked.
func (m *Model) offerShare(r ref.Ref) tea.Cmd {
s, err := share.Read(r)
if err != nil {
m.showMenu("Share link can't be read", []string{m.st.Error.Render(err.Error())}, nil)
return nil
}
from := ""
if s.Repo != "" {
from = m.st.Muted.Render(" from ") + m.repoLabel(s.Repo)
}
m.showMenu("Shared env files", []string{
m.st.Muted.Render(plural(s.Files, "env file")) + from,
m.st.Muted.Render("encrypted with ") + m.refLabel(s.Key),
}, []option{
{key: "o", label: "Decrypt…", tip: "reads the secret, then asks before saving", run: func(m *Model) tea.Cmd { return m.openShare(s) }},
})
return nil
}
// openShare reads the key, decrypts the files and finds the repo's clone.
func (m *Model) openShare(s share.Sealed) tea.Cmd {
read := m.keyReader()
providers, timeout := m.providers, m.timeout
m.flash = flash{text: "decrypting with " + s.Key.Name + "…"}
return func() tea.Msg {
msg := shareOpenedMsg{sealed: s}
v, _, err := read(s.Key)
if err != nil {
msg.err = &share.KeyError{Name: s.Key.Name, Err: err}
return msg
}
if msg.files, msg.err = s.Open([]byte(v.Reveal())); msg.err != nil {
return msg
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
for _, p := range providers {
if loc, ok := p.(provider.Locator); ok && p.Kind() == provider.KindRepo && s.Repo != "" {
// Its clone, if it's on disk (the demo's aren't).
if root, found, _ := loc.Locate(ctx, s.Repo); found && isDir(root) {
msg.dir = root
break
}
}
}
return msg
}
}
func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
switch {
case msg.err != nil:
m.noteErr(msg.err)
m.openFailed(msg.sealed, msg.err, false)
case msg.dir == "":
return m.askShareDir(msg)
default:
targets, err := planSave(msg.files, msg.dir)
if err != nil {
m.flash = flash{text: "save: " + err.Error(), isErr: true}
return nil
}
m.offerSave(targets, msg.dir)
}
return nil
}
// openFailed says why a share link couldn't be decrypted, in a popup
// where its offer was: its first line (wrapped), or with whole all of it
// (Key Vault's refusals say what to check on later lines). From there,
// the key's secret can be gone to: its version, which may be disabled.
func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
width := max(30, min(m.w-12, 76))
text := capitalize(err.Error())
first, rest, _ := strings.Cut(text, "\n")
cut := !whole && rest != "" // more than the reason: what to check
if !whole {
text = first
}
var lines []string
for i, l := range strings.Split(ansi.Wordwrap(text, width-2, " "), "\n") {
if i == 0 {
l = "✗ " + l
} else if l != "" {
l = " " + l
}
lines = append(lines, m.st.Error.Render(l))
}
var opts []option
if cut {
opts = append(opts, option{key: "r", label: "Show the whole message", run: func(m *Model) tea.Cmd {
m.openFailed(s, err, true)
return nil
}})
}
opts = append(opts, option{key: "g", label: "Go to " + s.Key.Name, run: func(m *Model) tea.Cmd { return m.openRef(s.Key, actGo) }})
m.showMenu("Can't decrypt the shared files", lines, opts)
}
// askShareDir asks where to save files from a repo that isn't here. It
// stays until they're saved, saying why when they can't be.
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
in := textinput.New()
in.Prompt = "folder: "
in.SetWidth(max(20, min(m.w-12, 64)))
dir := m.placeDir
if dir == "" {
dir, _ = os.Getwd()
}
in.SetValue(dir)
in.CursorEnd()
lines := []string{m.st.Muted.Render("Paths are from the repo's root.")}
if msg.sealed.Repo != "" {
lines = append([]string{m.repoLabel(msg.sealed.Repo) + m.st.Muted.Render(" isn't among your repos. Save where?")}, lines...)
}
p := &modal{title: "Save the files", input: &in, lines: lines, submitLabel: "save"}
p.submit = func(m *Model, text string) tea.Cmd {
dir := expandHome(strings.TrimSpace(text))
targets, err := planSave(msg.files, dir)
switch {
case err == nil && slices.ContainsFunc(targets, func(t share.Target) bool { return t.Exists }):
m.offerSave(targets, dir) // ask before replacing
return nil
case err == nil:
// Chosen, with nothing to replace: saved at once.
if err = m.writeShared(targets, dir, false); err == nil {
return nil
}
}
p.err = err.Error()
m.prompt = p // again, to fix the folder
return p.input.Focus()
}
m.prompt = p
return p.input.Focus()
}
// planSave says where each file goes in dir, which must be a folder.
func planSave(files []share.File, dir string) ([]share.Target, error) {
if !isDir(dir) {
return nil, fmt.Errorf("no folder %s", dir)
}
return share.Plan(dir, files)
}
// offerSave shows where each file goes, then saves them as chosen:
// files there already are replaced only when asked.
func (m *Model) offerSave(targets []share.Target, dir string) {
var lines []string
exist := 0
for _, t := range targets {
file := " " + m.repoLabel(filepath.Base(dir), t.Path)
line := file + m.st.Muted.Render(" · new")
if t.Exists {
exist++
line = file + m.st.Muted.Render(" · replaces the one there")
}
if t.Exposed {
line += m.st.Error.Render(" · not ignored by git")
}
lines = append(lines, line)
}
lines = append([]string{m.st.Muted.Render("In " + dir + ":")}, lines...)
write := func(onlyNew bool) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd {
if err := m.writeShared(targets, dir, onlyNew); err != nil {
m.flash = flash{text: "save: " + err.Error(), isErr: true}
}
return nil
}
}
var opts []option
switch {
case exist == 0:
opts = append(opts, option{key: "w", label: "Save", run: write(false)})
case exist == len(targets):
opts = append(opts, option{key: "w", label: "Replace", run: write(false)})
default:
opts = append(opts,
option{key: "w", label: fmt.Sprintf("Save all, replacing %d", exist), run: write(false)},
option{key: "n", label: onlyNew(len(targets) - exist), run: write(true)},
)
}
m.showMenu("Save the shared files", lines, opts)
}
// writeShared writes the files (with onlyNew, those not there yet) and
// says where, and which ones git doesn't ignore.
func (m *Model) writeShared(targets []share.Target, dir string, onlyNew bool) error {
n := 0
var exposed []string
for _, t := range targets {
if onlyNew && t.Exists {
continue
}
if err := t.Write(); err != nil {
return fmt.Errorf("%s: %w", t.Path, err)
}
n++
if t.Exposed {
exposed = append(exposed, t.Path)
}
}
m.flash = done("saved " + plural(n, "file") + " in " + dir)
if len(exposed) > 0 {
m.flash = warning("saved " + plural(n, "file") + " in " + dir + "; git doesn't ignore " + strings.Join(exposed, ", "))
}
return nil
}
// isDir reports whether dir is a folder on disk.
func isDir(dir string) bool {
info, err := os.Stat(dir)
return err == nil && info.IsDir()
}
// expandHome expands a leading ~/ to the home folder.
func expandHome(dir string) string {
if rest, ok := strings.CutPrefix(dir, "~/"); ok {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, rest)
}
}
return dir
}
// onlyNew labels saving only the n files not there yet.
func onlyNew(n int) string {
if n == 1 {
return "Save only the new one"
}
return fmt.Sprintf("Save only the %d new ones", n)
}
+355
View File
@@ -0,0 +1,355 @@
package app
import (
"context"
"errors"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// scryptWait is how long the driver waits for encrypting and decrypting:
// tens of milliseconds, more than its usual wait, and more under load.
const scryptWait = 20 * time.Second
// demoRules take the demo key, made up to be remembered.
var demoRules = share.Rules{MinLength: 16, MinClasses: 1}
// cloneWith makes a clone of git.example.com/team/app holding files.
func cloneWith(t *testing.T, files map[string]string) string {
t.Helper()
root := gitRepoWith(t, "https://git.example.com/team/app.git")
os.WriteFile(filepath.Join(root, ".gitignore"), []byte(".env\n"), 0o644)
for name, data := range files {
os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0o755)
os.WriteFile(filepath.Join(root, name), []byte(data), 0o600)
}
return root
}
// shareFrom shares a repo's env files from lazysecrets started in it,
// returning the link copied.
func shareFrom(t *testing.T, root string, paste bool) string {
t.Helper()
clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
d.wait = scryptWait
if src, ok := d.m.selectedSource(); !ok || src.ID != root {
t.Fatalf("selected %v, want the repo", src.ID)
}
d.keys("S", "f")
// No share.key set: it asks which secret, in Go to, which lists
// only secrets.
v := d.m.search
if v == nil || v.picker == nil {
t.Fatalf("no secret picker: %s", d.m.flash.text)
}
for _, o := range v.shown {
if o.plain || strings.Contains(o.label, ".env") {
t.Errorf("a plain env value offered as the key: %s", o.label)
}
}
if !strings.Contains(d.screen(), "as an encrypted link") {
t.Errorf("doesn't say what picking does:\n%s", d.screen())
}
if paste {
// A reference pasted is picked as it is.
d.send(tea.PasteMsg{Content: "https://" + kv + "/secrets/db-password"})
} else {
for _, r := range "app-prod db-password" {
d.keys(string(r))
}
if len(v.shown) == 0 {
t.Fatalf("no secrets to pick:\n%s", d.screen())
}
for _, o := range v.shown {
if _, ok := pickable(o); !ok {
t.Errorf("not a secret: %s", o.label)
}
}
if !strings.Contains(d.screen(), "Share 2 env files") && !strings.Contains(d.screen(), "Share .env") {
t.Errorf("not titled as a picker:\n%s", d.screen())
}
d.keys("enter")
}
// Picking it shares: the picker said what.
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text)
}
return clip.content
}
func TestShareFilesRoundTrip(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
link := shareFrom(t, cloneWith(t, map[string]string{
".env": "DB_PASSWORD=hunter2\n",
"prod.env": "TOKEN=abc\n",
}), false)
// A colleague's clone: .env is there already, prod.env isn't.
theirs := cloneWith(t, map[string]string{".env": "DB_PASSWORD=old\n"})
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Dir: theirs}))
d.wait = scryptWait
d.send(tea.PasteMsg{Content: link})
if d.m.prompt == nil || d.m.prompt.title != "Shared env files" || !strings.Contains(d.screen(), "2 env files from git.example.com/team/app") {
t.Fatalf("not offered:\n%s", d.screen())
}
d.keys("o")
if d.m.prompt == nil || d.m.prompt.title != "Save the shared files" {
t.Fatalf("no save menu (%s)", d.m.flash.text)
}
if s := d.screen(); !strings.Contains(s, "replaces the one there") || !strings.Contains(s, "not ignored by git") {
t.Errorf("save menu doesn't say what happens:\n%s", s)
}
d.keys("n") // only the new one
if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=old\n" {
t.Errorf(".env replaced: %q", b)
}
if b, _ := os.ReadFile(filepath.Join(theirs, "prod.env")); string(b) != "TOKEN=abc\n" {
t.Errorf("prod.env: %q", b)
}
// Again, replacing.
d.send(tea.PasteMsg{Content: link})
d.keys("o", "w")
if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=hunter2\n" {
t.Errorf(".env not replaced: %q", b)
}
}
func TestShareWithShareKeyConfirms(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root := cloneWith(t, map[string]string{
".env": "A=1\n",
".lazysecrets.yaml": "share:\n key: https://" + kv + "/secrets/db-password\n",
})
clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
d.wait = scryptWait
d.keys("S", "f")
// The key wasn't picked: it shows which, before sharing.
if d.m.search != nil || d.m.prompt == nil || d.m.prompt.title != "Share .env" || !strings.Contains(d.screen(), "db-password") {
t.Fatalf("no confirmation:\n%s", d.screen())
}
d.keys("c")
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
t.Errorf("copied %q (%s)", clip.content, d.m.flash.text)
}
}
func TestShareLinkElsewhereAsksWhere(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"}), true)
dir, _ := filepath.EvalSymlinks(t.TempDir())
d := newDriverOpts(t, Options{Dir: dir}, fake.Azure(0))
d.wait = scryptWait
d.send(tea.PasteMsg{Content: link})
d.keys("o")
if d.m.prompt == nil || d.m.prompt.input == nil || d.m.prompt.input.Value() != dir {
t.Fatalf("doesn't ask where, starting from where it runs:\n%s", d.screen())
}
// A folder that isn't there: the prompt stays, saying so.
d.m.prompt.input.SetValue(filepath.Join(dir, "nope"))
d.keys("enter")
if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "✗ No folder") {
t.Fatalf("a missing folder isn't said in the prompt:\n%s", d.screen())
}
if !strings.Contains(d.screen(), "enter save") {
t.Errorf("the footer doesn't say enter saves:\n%s", d.screen())
}
d.m.prompt.input.SetValue(dir)
d.keys("enter") // saved at once: nothing there to replace
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "A=1\n" {
t.Errorf(".env: %q (%s)", b, d.m.flash.text)
}
if d.m.prompt != nil || !strings.Contains(d.m.flash.text, "saved 1 file in "+dir) {
t.Errorf("not closed with where it saved: %q", d.m.flash.text)
}
}
func TestShareLinkGarbled(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.send(tea.PasteMsg{Content: "lazysecrets://share/AQ" + strings.Repeat("A", 30)})
if d.m.prompt == nil || !strings.Contains(d.screen(), "garbled") {
t.Errorf("not reported:\n%s", d.screen())
}
}
// TestShareInDemo shares a demo repo's env files with the demo key and
// opens the link, as lazysecrets --demo does: the demo repos aren't on
// disk, so it asks where to save.
func TestShareInDemo(t *testing.T) {
clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip},
fake.Azure(0), fake.AzureOps(0).As("Azure:admin", "Azure · admin"), fake.Repos())
d.wait = scryptWait
var src provider.Source
for _, s := range d.m.sourceResults["Repos"].sources {
if s.Here {
src = s
}
}
if src.ID == "" {
t.Fatal("no demo repo")
}
src.Provider = "Repos"
d.run(d.m.shareFiles(src, nil))
if d.m.search == nil || d.m.search.picker == nil {
t.Fatalf("no picker: %s", d.m.flash.text)
}
// Too short a key: the picker stays, saying why.
d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"})
if d.m.search == nil || !strings.Contains(d.m.search.picker.err, "Can't use db-password: its value has 28 characters; share.keyRules needs 32 (minLength)") {
t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen())
}
if !strings.Contains(d.screen(), "28 characters") {
t.Errorf("not shown:\n%s", d.screen())
}
d.send(tea.PasteMsg{Content: "https://kv-shared-prod.vault.azure.net/secrets/env-share"})
if !strings.HasPrefix(clip.content, "lazysecrets://share/") || d.m.search != nil {
t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text)
}
link := clip.content
// A value in a demo repo's SOPS file can be the key too, found by
// the repo's remote.
clip.content = ""
d.run(d.m.shareFiles(src, nil))
d.send(tea.PasteMsg{Content: "lazysecrets://repo/" + url.PathEscape(src.Detail) + "/config%2Foauth.enc.json/github.client_secret"})
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
t.Errorf("with the demo repo's key: copied %q (%s)", clip.content, d.m.flash.text)
}
dir, _ := filepath.EvalSymlinks(t.TempDir())
d.m.placeDir = dir
d.send(tea.PasteMsg{Content: link})
d.keys("o")
if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "isn't among your repos") {
t.Fatalf("doesn't ask where:\n%s", d.screen())
}
d.keys("enter") // saved at once: nothing there to replace
entries, _ := os.ReadDir(dir)
if len(entries) == 0 {
t.Fatalf("nothing saved (%s)", d.m.flash.text)
}
}
func TestPickerCancelAndBusy(t *testing.T) {
clip := &fakeClipboard{}
d := newDriverOpts(t, Options{Clipboard: clip},
fake.Azure(0), fake.AzureOps(0).As("Azure:admin", "Azure · admin"), fake.Repos())
var src provider.Source
for _, s := range d.m.sourceResults["Repos"].sources {
if s.Here {
src = s
}
}
src.Provider = "Repos"
d.run(d.m.shareFiles(src, nil))
p := d.m.search.picker
key, _ := share.ParseKey("https://kv-shared-prod.vault.azure.net/secrets/env-share")
// Picking starts sealing; another pick meanwhile is ignored.
cmd := p.choose(&d.m, key)
if cmd == nil || p.busy == "" {
t.Fatal("picking didn't start")
}
if again := p.choose(&d.m, key); again != nil {
t.Error("a second pick while busy wasn't ignored")
}
// Closed before the link is made: cancelled, nothing copied.
d.m.search = nil
d.wait = scryptWait
d.run(cmd)
if clip.content != "" {
t.Errorf("copied after the picker was closed: %q", clip.content)
}
}
func TestPlainEnvNotPickableFromFavourites(t *testing.T) {
mem, _ := store.Load("")
d := newDriverOpts(t, Options{Store: mem}, fake.Azure(0), fake.Repos())
var src provider.Source
for _, s := range d.m.sourceResults["Repos"].sources {
if s.Here {
src = s
}
}
// A key in the demo repo's plain .env, starred.
key := "lazysecrets://repo/" + url.PathEscape(src.Detail) + "/.env/DATABASE_URL"
mem.Update(key, func(n *store.Node) {
n.StarredBy = []string{"Repos"}
n.Path = []string{src.Name, ".env"}
n.Source = src.ID
})
src.Provider = "Repos"
d.run(d.m.shareFiles(src, nil))
favs, _ := d.m.storeEntries()
for _, o := range favs {
if o.star == key {
if _, ok := pickable(o); ok {
t.Error("a plain .env value is offered as a key in Favourites")
}
return
}
}
t.Fatalf("the starred key isn't among the favourites: %d", len(favs))
}
// refusing is a provider whose values can't be read, like a disabled
// version in Key Vault.
type refusing struct{ provider.Provider }
func (refusing) Value(context.Context, provider.Object) (provider.SecretValue, error) {
return provider.SecretValue{}, &provider.Denied{
Err: errors.New("Operation get is not allowed on a disabled secret."),
Checks: []string{"the secret's version is enabled"},
}
}
func TestOpenFailsInAPopup(t *testing.T) {
key, _ := share.ParseKey("https://" + kv + "/secrets/db-password")
link, err := share.Seal(share.Header{Key: key, Repo: "git.example.com/team/app"},
[]byte("correct-horse-battery-staple"), []share.File{{Path: ".env", Data: []byte("A=1\n")}}, demoRules)
if err != nil {
t.Fatal(err)
}
d := newDriverOpts(t, Options{}, refusing{fake.Azure(0)})
d.wait = scryptWait
d.send(tea.PasteMsg{Content: link.String()})
d.keys("o")
p := d.m.prompt
if p == nil || p.title != "Can't decrypt the shared files" || !strings.Contains(d.screen(), "Can't use db-password: Operation get is not allowed") {
t.Fatalf("not in a popup:\n%s", d.screen())
}
if strings.Contains(d.screen(), "version is enabled") {
t.Error("the whole message is shown before asking")
}
d.keys("r")
if !strings.Contains(d.screen(), "version is enabled") {
t.Errorf("r doesn't show the whole message:\n%s", d.screen())
}
d.keys("g")
if _, _, sel := d.selected(); d.m.prompt != nil || (sel != "db-password" && (d.m.nav == nil || d.m.nav.ref.Name != "db-password")) {
t.Errorf("g doesn't go to the key: %q, %q\n%s", sel, d.m.flash.text, d.screen())
}
}
+19 -2
View File
@@ -16,6 +16,16 @@ import (
// (0), the right pane has the rest: updates, each account, the files // (0), the right pane has the rest: updates, each account, the files
// lazysecrets uses, links, and the changelog. // lazysecrets uses, links, and the changelog.
// logo heads the status pane's rows.
var logo = []string{
` _ _`,
`| | __ _ _____ _ ___ ___ ___ _ __ ___| |_ ___`,
`| |/ _` + "`" + ` |_ / | | / __|/ _ \/ __| '__/ _ \ __/ __|`,
`| | (_| |/ /| |_| \__ \ __/ (__| | | __/ |_\__ \`,
`|_|\__,_/___|\__, |___/\___|\___|_| \___|\__|___/`,
` |___/`,
}
// statusH is the status pane's height: one line in a box. // statusH is the status pane's height: one line in a box.
const statusH = 3 const statusH = 3
@@ -28,9 +38,10 @@ func (m Model) statusLine() string {
case m.newer != nil: case m.newer != nil:
parts = append(parts, m.st.Notice.Render("↑ "+m.newer.Tag+withKey(m.keys.Update, " %s"))) parts = append(parts, m.st.Notice.Render("↑ "+m.newer.Tag+withKey(m.keys.Update, " %s")))
} }
accounts, failing := 0, 0 accounts, failing, repos := 0, 0, 0
for _, p := range m.providers { for _, p := range m.providers {
if p.Kind() == provider.KindRepo { if p.Kind() == provider.KindRepo {
repos += len(m.sourceResults[p.ID()].sources) // those with secret files
continue continue
} }
accounts++ accounts++
@@ -48,6 +59,9 @@ func (m Model) statusLine() string {
} }
parts = append(parts, s) parts = append(parts, s)
} }
if repos > 0 {
parts = append(parts, plural(repos, "repo"))
}
return " " + strings.Join(parts, m.st.Muted.Render(" · ")) return " " + strings.Join(parts, m.st.Muted.Render(" · "))
} }
@@ -92,6 +106,7 @@ func (m Model) statusFields() []panes.Field {
} }
out = append(out, m.expiringFields()...) out = append(out, m.expiringFields()...)
out = append(out, m.errorFields()...)
for i, path := range m.configFiles { for i, path := range m.configFiles {
label := "" label := ""
@@ -107,7 +122,9 @@ func (m Model) statusFields() []panes.Field {
repo := strings.TrimPrefix(update.DefaultRepo, "https://") repo := strings.TrimPrefix(update.DefaultRepo, "https://")
add(panes.Field{Label: "docs", Value: repo + "/…/docs", URL: update.DefaultRepo + "/src/branch/main/docs"}) add(panes.Field{Label: "docs", Value: repo + "/…/docs", URL: update.DefaultRepo + "/src/branch/main/docs"})
add(panes.Field{Label: "source", Value: repo, URL: update.DefaultRepo}) add(panes.Field{Label: "source", Value: repo, URL: update.DefaultRepo})
add(panes.Field{Label: "issues", Value: repo + "/issues", URL: update.DefaultRepo + "/issues"}) // Issues are on the GitHub mirror.
github := strings.TrimPrefix(update.GitHubRepo, "https://")
add(panes.Field{Label: "issues", Value: github + "/issues", URL: update.GitHubRepo + "/issues"})
add(panes.Field{Label: "releases", Value: repo + "/releases", URL: update.DefaultRepo + "/releases"}) add(panes.Field{Label: "releases", Value: repo + "/releases", URL: update.DefaultRepo + "/releases"})
add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"}) add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"})
return append(out, m.changelog...) return append(out, m.changelog...)
+1 -1
View File
@@ -30,4 +30,4 @@
│ ││ │ │ ││ │
│ ││ │ │ ││ │
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯ ╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
enter open • ctrl+s search • s copy link • S share… • f star • F favourites • … • ? help • q quit enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
+9 -6
View File
@@ -61,7 +61,8 @@ type Map struct {
SearchSources, SearchResults, SearchSwitch key.Binding SearchSources, SearchResults, SearchSwitch key.Binding
SearchLoad, SearchLoadAll key.Binding SearchLoad, SearchLoadAll key.Binding
// goto // goto
GoDown, GoUp, GoPrevTab, GoNextTab key.Binding GoDown, GoUp, GoTop, GoBottom key.Binding
GoPrevTab, GoNextTab key.Binding
GoSources, GoResults, GoSwitch key.Binding GoSources, GoResults, GoSwitch key.Binding
GoStar, GoLoad, GoLoadAll, GoFilter key.Binding GoStar, GoLoad, GoLoadAll, GoFilter key.Binding
GoSelect, GoClose key.Binding GoSelect, GoClose key.Binding
@@ -102,7 +103,7 @@ func (m *Map) Actions() []Action {
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."), a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."), a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."), a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one, starting in the source you're in. On the left, the scope: Favourites, Recent, All, or one source (○ ◐ ● for how much of it is loaded)."), a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."), a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."), a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."), a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
@@ -113,10 +114,10 @@ func (m *Map) Actions() []Action {
a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."), a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."),
a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."), a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."),
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."), a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line. SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."), a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; on the status pane, your config (made if it isn't there yet). SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."), a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
a(Lists, "shareMenu", &m.ShareMenu, "share…", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."), a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, the web page, or env files as an encrypted link."),
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."), a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."), a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."),
@@ -160,7 +161,9 @@ func (m *Map) Actions() []Action {
a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."), a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."),
a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."), a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."),
a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Favourites, Recent, Search."), a(GoTo, "top", &m.GoTop, "top", "go to", "Jump to the top of the list in Go to while browsing."),
a(GoTo, "bottom", &m.GoBottom, "bottom", "go to", "Jump to the bottom of the list in Go to while browsing."),
a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Search, Favourites, Recent."),
a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."), a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."),
a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."), a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."),
a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."), a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."),
@@ -206,7 +209,7 @@ var defaults = map[string][]string{
"search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"}, "search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"},
"search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"}, "search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"},
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"}, "goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.top": {"g", "home"}, "goto.bottom": {"G", "end"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
"goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"}, "goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"},
"goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"}, "goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"},
"goto.select": {"enter"}, "goto.close": {"esc", "q"}, "goto.select": {"enter"}, "goto.close": {"esc", "q"},
+24 -5
View File
@@ -60,6 +60,16 @@ func (d *Detail) ShowSecret(s provider.Object) {
d.container = &s.Container d.container = &s.Container
} }
// Refresh shows s's newer metadata if s is the secret shown, keeping
// its account and any value shown or loading.
func (d *Detail) Refresh(s provider.Object) {
if d.secret == nil || d.secret.ID != s.ID {
return
}
s.Container = d.secret.Container
d.secret = &s
}
// ShowContainer shows a container summary when no secret is selected. // ShowContainer shows a container summary when no secret is selected.
func (d *Detail) ShowContainer(c provider.Container) { func (d *Detail) ShowContainer(c provider.Container) {
d.Clear() d.Clear()
@@ -313,7 +323,8 @@ func (d Detail) lines(st styles.Styles) []string {
case ValueShown: case ValueShown:
// Where the dots were, continuing under itself; the keys below. // Where the dots were, continuing under itself; the keys below.
first := true first := true
for _, line := range strings.Split(d.value.Reveal(), "\n") { lines, note := valueView(d.value.Reveal())
for _, line := range lines {
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") { for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
if first { if first {
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w)) out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w))
@@ -324,6 +335,12 @@ func (d Detail) lines(st styles.Styles) []string {
} }
} }
var more []string var more []string
if note != "" && d.CopyKey != "" {
note += ": " + d.CopyKey + " copies it as stored"
}
if note != "" {
more = append(more, note)
}
if d.hidesIn != "" { if d.hidesIn != "" {
more = append(more, "hides in "+d.hidesIn) more = append(more, "hides in "+d.hidesIn)
} }
@@ -355,11 +372,13 @@ func (d Detail) versionLines(st styles.Styles) []string {
out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i))) out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i)))
break break
} }
marker := " " // A list to read, not to move in (v browses them): no cursor. The
if d.secret.Version != "" && v.Version == d.secret.Version { // version shown above, when it isn't the current one, is named.
marker = "▸ " // the version shown above line := " " + VersionLine(st, v, d.Now(), current)
if d.secret.Version != "" && v.Version == d.secret.Version && v.Version != current {
line += muted(" · shown above")
} }
out = append(out, " "+marker+VersionLine(st, v, d.Now(), current)) out = append(out, line)
} }
return out return out
} }
+55 -1
View File
@@ -37,6 +37,11 @@ type List[T any] struct {
// Age says how old the items are ("2h ago") while they reload, by // Age says how old the items are ("2h ago") while they reload, by
// the counter. // the counter.
Age string Age string
// Header lines (a logo) go above the items while the cursor is in
// the rows under it, the pane has room and there's no filter; else
// HeaderShort, a line of plain text, does.
Header []string
HeaderShort string
item Item[T] item Item[T]
items []T items []T
@@ -163,9 +168,36 @@ func (l List[T]) rows() int {
if l.filtering { if l.filtering {
r-- r--
} }
r -= len(l.header())
return max(0, r) return max(0, r)
} }
// headerMinRows is how many item rows Header leaves at least.
const headerMinRows = 5
// header is the lines above the items: Header and a blank line, if it
// fits, leaves headerMinRows and the cursor is in the rows under it;
// else HeaderShort.
func (l List[T]) header() []string {
short := []string{}
if l.HeaderShort != "" {
short = []string{l.HeaderShort}
}
if len(l.Header) == 0 || l.filtering || l.input.Value() != "" {
return short
}
for _, line := range l.Header {
if ansi.StringWidth(line) > l.w-3 {
return short
}
}
below := l.h - 2 - len(l.Header) - 1
if below < headerMinRows || l.cursor >= below {
return short
}
return append(l.Header[:len(l.Header):len(l.Header)], "")
}
// Filtering reports whether the filter input has focus. // Filtering reports whether the filter input has focus.
func (l List[T]) Filtering() bool { return l.filtering } func (l List[T]) Filtering() bool { return l.filtering }
@@ -329,12 +361,15 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
if l.filtering { if l.filtering {
body = append(body, l.input.View()) body = append(body, l.input.View())
} }
for _, line := range l.header() {
body = append(body, st.Notice.Render(" "+line))
}
switch { switch {
case l.Loading && len(l.items) == 0: case l.Loading && len(l.items) == 0:
body = append(body, " "+spin+" loading…") body = append(body, " "+spin+" loading…")
case l.Err != nil: case l.Err != nil:
for _, line := range strings.Split(ansi.Wrap(l.Err.Error(), inner-1, " -/"), "\n") { for _, line := range wrapLines(l.Err.Error(), inner-1) {
body = append(body, st.Error.Render(" "+line)) body = append(body, st.Error.Render(" "+line))
} }
case len(l.visible) == 0 && len(l.items) > 0: case len(l.visible) == 0 && len(l.items) > 0:
@@ -393,3 +428,22 @@ func (l *List[T]) scroll() {
} }
l.offset = max(0, min(l.offset, len(l.visible)-rows)) l.offset = max(0, min(l.offset, len(l.visible)-rows))
} }
// wrapLines wraps text to width, line by line; a "• " item's further
// lines line up under its text.
func wrapLines(text string, width int) []string {
var out []string
for _, line := range strings.Split(text, "\n") {
indent := ""
if strings.HasPrefix(line, "• ") {
indent = " "
}
for i, l := range strings.Split(ansi.Wrap(line, max(1, width-len(indent)), " -/"), "\n") {
if i > 0 {
l = indent + l
}
out = append(out, l)
}
}
return out
}
+24
View File
@@ -177,3 +177,27 @@ func TestHighlightKeepsColours(t *testing.T) {
t.Error("reverse kept the colours") t.Error("reverse kept the colours")
} }
} }
func TestListHeader(t *testing.T) {
l := NewList("t", Item[string]{
ID: func(s string) string { return s },
Filter: func(s string) string { return s },
Render: func(_ styles.Styles, s string, _ int) string { return s },
})
l.Header, l.HeaderShort = []string{"ART1", "ART2"}, "short"
l.SetItems([]string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"})
l.SetSize(30, 2+2+1+6) // the art, a blank line and 6 rows
st := styles.Default()
if v := l.View(st, true, ""); !strings.Contains(v, "ART1") || strings.Contains(v, "short") {
t.Errorf("at the top, the art:\n%s", v)
}
l.Move(6) // below the rows under the art
if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") || !strings.Contains(v, " g") {
t.Errorf("further down, the text:\n%s", v)
}
l.Top()
l.SetSize(30, 8) // too short for the art and 5 rows
if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") {
t.Errorf("no room, the text:\n%s", v)
}
}
+79
View File
@@ -0,0 +1,79 @@
package panes
import (
"bytes"
"crypto/sha1"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"strings"
"time"
)
// A revealed value is shown as it reads best: JSON indented, and PEM
// certificates as what they say (subject, issuer, dates, names) rather
// than base64, with any other PEM block, a private key say, named but not
// shown. Anything else is shown as it is. y copies it as stored.
// valueView is how to show v, and what's been done to it ("" when it's
// shown as it is).
func valueView(v string) (lines []string, note string) {
if lines, ok := pemView(v); ok {
return lines, "certificate"
}
if t := strings.TrimSpace(v); (strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[")) && json.Valid([]byte(t)) {
var b bytes.Buffer
if json.Indent(&b, []byte(t), "", " ") == nil {
return strings.Split(b.String(), "\n"), "JSON, formatted"
}
}
return strings.Split(v, "\n"), ""
}
// pemView describes the PEM blocks in v, if it's PEM with a certificate.
func pemView(v string) ([]string, bool) {
rest := []byte(strings.TrimSpace(v))
var out []string
certs := 0
for len(rest) > 0 {
block, after := pem.Decode(rest)
if block == nil {
return nil, false // text that isn't PEM: show it all
}
rest = bytes.TrimSpace(after)
if len(out) > 0 {
out = append(out, "")
}
if block.Type != "CERTIFICATE" {
out = append(out, block.Type+" (not shown)")
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, false
}
certs++
out = append(out, certLines(cert)...)
}
return out, certs > 0
}
func certLines(c *x509.Certificate) []string {
day := func(t time.Time) string { return t.UTC().Format("2006-01-02") }
out := []string{
"subject " + c.Subject.String(),
"issuer " + c.Issuer.String(),
"valid " + day(c.NotBefore) + " to " + day(c.NotAfter),
}
names := append([]string{}, c.DNSNames...)
for _, ip := range c.IPAddresses {
names = append(names, ip.String())
}
names = append(names, c.EmailAddresses...)
if len(names) > 0 {
out = append(out, "names "+strings.Join(names, ", "))
}
sum := sha1.Sum(c.Raw) // what Azure and Windows call the thumbprint
return append(out, "SHA-1 "+fmt.Sprintf("%X", sum[:]))
}
+60
View File
@@ -0,0 +1,60 @@
package panes
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"slices"
"strings"
"testing"
"time"
)
func TestValueViewJSON(t *testing.T) {
lines, note := valueView(`{"user":"app","port":5432,"tags":["a"]}`)
want := []string{"{", ` "user": "app",`, ` "port": 5432,`, ` "tags": [`, ` "a"`, " ]", "}"}
if !slices.Equal(lines, want) || note != "JSON, formatted" {
t.Errorf("lines %q, note %q", lines, note)
}
for _, v := range []string{"plain value", "{not json", "42", "line one\nline two"} {
if lines, note := valueView(v); note != "" || strings.Join(lines, "\n") != v {
t.Errorf("%q shown as %q (%q)", v, lines, note)
}
}
}
func TestValueViewCertificate(t *testing.T) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "app.example.com"},
DNSNames: []string{"app.example.com", "www.app.example.com"},
NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
keyDER, _ := x509.MarshalECPrivateKey(key)
bundle := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +
string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
lines, note := valueView(bundle)
text := strings.Join(lines, "\n")
for _, want := range []string{"subject CN=app.example.com", "valid 2026-01-01 to 2027-01-01",
"names app.example.com, www.app.example.com", "SHA-1 ", "EC PRIVATE KEY (not shown)"} {
if !strings.Contains(text, want) {
t.Errorf("missing %q:\n%s", want, text)
}
}
if note != "certificate" || strings.Contains(text, "BEGIN") {
t.Errorf("note %q, or PEM shown:\n%s", note, text)
}
// A private key alone isn't a certificate: shown as it is.
if _, note := valueView(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))); note != "" {
t.Errorf("a key alone: note %q", note)
}
}
+31 -2
View File
@@ -3,6 +3,8 @@
package styles package styles
import ( import (
"image/color"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -32,6 +34,10 @@ type Styles struct {
KindAzure lipgloss.Style KindAzure lipgloss.Style
KindDatabricks lipgloss.Style KindDatabricks lipgloss.Style
KindRepo lipgloss.Style KindRepo lipgloss.Style
// themed marks the selection colours as set by the theme, so
// OnBackground leaves them.
themed, themedBlurred bool
} }
func Default() Styles { func Default() Styles {
@@ -43,9 +49,12 @@ func Default() Styles {
TitleBlurred: lipgloss.NewStyle(), TitleBlurred: lipgloss.NewStyle(),
Footer: lipgloss.NewStyle().Faint(true), Footer: lipgloss.NewStyle().Faint(true),
// A background rather than reverse, so the row keeps its colours. // A background rather than reverse, so the row keeps its colours;
// in every pane, so each shows what's selected in it. The focused
// pane's is bold too. Palette 8 until the terminal says what its
// background is (OnBackground).
Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true), Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true),
SelectedBlurred: lipgloss.NewStyle().Bold(true), SelectedBlurred: lipgloss.NewStyle().Background(lipgloss.BrightBlack),
Muted: lipgloss.NewStyle().Faint(true), Muted: lipgloss.NewStyle().Faint(true),
Error: lipgloss.NewStyle().Foreground(lipgloss.Red), Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green), Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
@@ -74,3 +83,23 @@ func (s Styles) Kind(k provider.Kind) lipgloss.Style {
} }
return s.KindAzure return s.KindAzure
} }
// OnBackground shades the selection colours from the terminal's
// background, a little lighter on a dark one and darker on a light one:
// a quiet band that keeps the row's colours readable, where palette 8 is
// often too bright. Colours the theme sets are kept.
func (s Styles) OnBackground(bg color.Color, dark bool) Styles {
shade := func(p float64) color.Color {
if dark {
return lipgloss.Lighten(bg, p)
}
return lipgloss.Darken(bg, p)
}
if !s.themed {
s.Selected = s.Selected.Background(shade(0.09))
}
if !s.themedBlurred {
s.SelectedBlurred = s.SelectedBlurred.Background(shade(0.05))
}
return s
}
+2
View File
@@ -70,8 +70,10 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st.BorderBlurred = v.fg(lipgloss.NewStyle()) st.BorderBlurred = v.fg(lipgloss.NewStyle())
case "selectedLineBgColor": case "selectedLineBgColor":
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true) st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
st.themed = true
case "inactiveViewSelectedLineBgColor": case "inactiveViewSelectedLineBgColor":
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true) st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
st.themedBlurred = true
case "defaultFgColor": case "defaultFgColor":
fg = v.color fg = v.color
case "optionsTextColor": case "optionsTextColor":
+30
View File
@@ -1,6 +1,7 @@
package styles package styles
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
@@ -62,3 +63,32 @@ func TestEmptyThemeIsDefault(t *testing.T) {
t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground()) t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground())
} }
} }
func TestOnBackground(t *testing.T) {
hex := func(c any) string {
r, g, b, _ := c.(interface{ RGBA() (r, g, b, a uint32) }).RGBA()
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
}
st := Default().OnBackground(lipgloss.Color("#282c34"), true)
if got := hex(st.Selected.GetBackground()); got != "#3e424a" {
t.Errorf("dark: selected %s", got)
}
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
t.Errorf("dark: blurred %s", got)
}
if got := hex(Default().OnBackground(lipgloss.Color("#ffffff"), false).Selected.GetBackground()); got != "#e8e8e8" {
t.Errorf("light: selected %s", got)
}
themed, _, err := FromTheme(map[string][]string{"selectedLineBgColor": {"#2d3f76"}})
if err != nil {
t.Fatal(err)
}
st = themed.OnBackground(lipgloss.Color("#282c34"), true)
if got := hex(st.Selected.GetBackground()); got != "#2d3f76" {
t.Errorf("themed: selected %s (want the theme's)", got)
}
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
t.Errorf("themed: blurred %s (not themed: shaded)", got)
}
}
+91 -30
View File
@@ -3,6 +3,9 @@
// against the release's checksums.txt, tried (--version), then renamed // against the release's checksums.txt, tried (--version), then renamed
// over the binary. A running lazysecrets keeps its old binary until it // over the binary. A running lazysecrets keeps its old binary until it
// restarts. // restarts.
//
// Releases are published in more than one place; each is tried in turn
// (GitHub, then git.kebcloud.no), so one being down doesn't stop updates.
package update package update
import ( import (
@@ -18,6 +21,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path" "path"
@@ -30,12 +34,20 @@ import (
"golang.org/x/mod/semver" "golang.org/x/mod/semver"
) )
// Where releases are published. // DefaultRepo is lazysecrets' home: its source, docs and releases.
// GitHubRepo mirrors it, with the issues and the releases too.
const ( const (
DefaultAPI = "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets"
DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets" DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets"
GitHubRepo = "https://github.com/karleinarb/lazysecrets"
) )
// DefaultServers are where releases are looked for, in order.
var DefaultServers = []string{GitHubRepo, DefaultRepo}
// latestTimeout bounds asking one server for the latest release, so a
// server that's down leaves time for the next.
const latestTimeout = 5 * time.Second
// maxDownload bounds what's read from the server. // maxDownload bounds what's read from the server.
const maxDownload = 200 << 20 const maxDownload = 200 << 20
@@ -60,15 +72,20 @@ func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
// and a terminal shows them as their text. // and a terminal shows them as their text.
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`) var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
// Client talks to the release server. // Client talks to the release servers.
type Client struct { type Client struct {
API string // the repository's API, DefaultAPI when empty // Servers are the repositories' web addresses, on GitHub or a Gitea
Repo string // the repository's web address, DefaultRepo when empty // or Forgejo server, tried in order; DefaultServers when empty.
HTTP *http.Client Servers []string
HTTP *http.Client
} }
func (c *Client) api() string { return cmpOr(c.API, DefaultAPI) } func (c *Client) servers() []string {
func (c *Client) repo() string { return cmpOr(c.Repo, DefaultRepo) } if len(c.Servers) > 0 {
return c.Servers
}
return DefaultServers
}
func (c *Client) client() *http.Client { func (c *Client) client() *http.Client {
if c.HTTP != nil { if c.HTTP != nil {
@@ -77,25 +94,50 @@ func (c *Client) client() *http.Client {
return &http.Client{Timeout: 2 * time.Minute} return &http.Client{Timeout: 2 * time.Minute}
} }
func cmpOr(a, b string) string { // API is a repository's API: GitHub's for github.com, else Gitea's (and
if a != "" { // Forgejo's).
return a func API(repo string) (string, error) {
u, err := url.Parse(strings.TrimSuffix(repo, "/"))
if err != nil || u.Host == "" || strings.Count(u.Path, "/") != 2 {
return "", fmt.Errorf("%q isn't a repository's address, like %s", repo, GitHubRepo)
} }
return b if u.Host == "github.com" {
return "https://api.github.com/repos" + u.Path, nil
}
return u.Scheme + "://" + u.Host + "/api/v1/repos" + u.Path, nil
} }
// Latest is the newest release (pre-releases aside). // Latest is the newest release (pre-releases aside), from the first
// server that answers.
func (c *Client) Latest(ctx context.Context) (Release, error) { func (c *Client) Latest(ctx context.Context) (Release, error) {
b, err := c.get(ctx, c.api()+"/releases/latest", 1<<20) var errs []error
for _, repo := range c.servers() {
r, err := c.latest(ctx, repo)
if err == nil {
return r, nil
}
errs = append(errs, err)
}
return Release{}, errors.Join(errs...)
}
func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
api, err := API(repo)
if err != nil {
return Release{}, err
}
ctx, cancel := context.WithTimeout(ctx, latestTimeout)
defer cancel()
b, err := c.get(ctx, api+"/releases/latest", 1<<20)
if err != nil { if err != nil {
return Release{}, err return Release{}, err
} }
var r Release var r Release
if err := json.Unmarshal(b, &r); err != nil { if err := json.Unmarshal(b, &r); err != nil {
return Release{}, fmt.Errorf("reading the latest release: %w", err) return Release{}, fmt.Errorf("reading the latest release from %s: %w", repo, err)
} }
if !semver.IsValid(r.Tag) { if !semver.IsValid(r.Tag) {
return Release{}, fmt.Errorf("latest release %q isn't a version", r.Tag) return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo)
} }
return r, nil return r, nil
} }
@@ -147,23 +189,10 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
} }
version := strings.TrimPrefix(r.Tag, "v") version := strings.TrimPrefix(r.Tag, "v")
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH) name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
base := c.repo() + "/releases/download/" + r.Tag + "/" archive, err := c.download(ctx, r.Tag, name+".tar.gz")
sums, err := c.get(ctx, base+"checksums.txt", 1<<20)
if err != nil { if err != nil {
return err return err
} }
want, err := checksum(sums, name+".tar.gz")
if err != nil {
return err
}
archive, err := c.get(ctx, base+name+".tar.gz", maxDownload)
if err != nil {
return err
}
if got := sha256.Sum256(archive); hex.EncodeToString(got[:]) != want {
return fmt.Errorf("%s.tar.gz doesn't match checksums.txt: damaged, or not the release", name)
}
bin, err := extract(archive, path.Join(name, "lazysecrets")) bin, err := extract(archive, path.Join(name, "lazysecrets"))
if err != nil { if err != nil {
return err return err
@@ -198,6 +227,38 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
return nil return nil
} }
// download fetches a release's file from the first server that has it,
// checked against that server's checksums.txt. A file that doesn't match
// stops there: it's damaged, or not the release.
func (c *Client) download(ctx context.Context, tag, file string) ([]byte, error) {
var errs []error
for _, repo := range c.servers() {
base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/"
sctx, cancel := context.WithTimeout(ctx, latestTimeout)
sums, err := c.get(sctx, base+"checksums.txt", 1<<20)
cancel()
if err != nil {
errs = append(errs, err)
continue
}
want, err := checksum(sums, file)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", repo, err))
continue
}
b, err := c.get(ctx, base+file, maxDownload)
if err != nil {
errs = append(errs, err)
continue
}
if got := sha256.Sum256(b); hex.EncodeToString(got[:]) != want {
return nil, fmt.Errorf("%s from %s doesn't match checksums.txt: damaged, or not the release", file, repo)
}
return b, nil
}
return nil, errors.Join(errs...)
}
func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) { func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
+52 -5
View File
@@ -70,13 +70,13 @@ func server(t *testing.T, tamper bool) *httptest.Server {
sum[0] ^= 0xff sum[0] ^= 0xff
} }
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`) fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`)
}) })
mux.HandleFunc("/repo/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name) fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)
}) })
mux.HandleFunc("/repo/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
w.Write(archive.Bytes()) w.Write(archive.Bytes())
}) })
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
@@ -89,7 +89,7 @@ func TestInstall(t *testing.T) {
t.Skip("releases are for macOS and Linux") t.Skip("releases are for macOS and Linux")
} }
srv := server(t, false) srv := server(t, false)
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"} c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}
r, err := c.Latest(context.Background()) r, err := c.Latest(context.Background())
if err != nil || r.Tag != "v0.2.0" { if err != nil || r.Tag != "v0.2.0" {
t.Fatalf("latest %+v %v", r, err) t.Fatalf("latest %+v %v", r, err)
@@ -107,12 +107,59 @@ func TestInstall(t *testing.T) {
} }
} }
// A server that's down, or doesn't have the release, is skipped.
func TestFallsBackToTheNextServer(t *testing.T) {
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
t.Skip("releases are for macOS and Linux")
}
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "down", http.StatusBadGateway)
}))
t.Cleanup(down.Close)
srv := server(t, false)
c := &Client{Servers: []string{down.URL + "/keb/lazysecrets", srv.URL + "/keb/lazysecrets"}}
r, err := c.Latest(context.Background())
if err != nil || r.Tag != "v0.2.0" {
t.Fatalf("latest %+v %v", r, err)
}
exe := filepath.Join(t.TempDir(), "lazysecrets")
os.WriteFile(exe, []byte("old"), 0o755)
if err := c.Install(context.Background(), r, exe); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(exe); !strings.Contains(string(b), "echo lazysecrets 0.2.0") {
t.Errorf("not replaced: %q", b)
}
// Every server down: both say why.
c = &Client{Servers: []string{down.URL + "/a/b", down.URL + "/c/d"}}
if _, err := c.Latest(context.Background()); err == nil || strings.Count(err.Error(), "502") != 2 {
t.Errorf("all down: %v", err)
}
}
func TestAPI(t *testing.T) {
for repo, want := range map[string]string{
"https://github.com/karleinarb/lazysecrets": "https://api.github.com/repos/karleinarb/lazysecrets",
"https://git.kebcloud.no/keb/lazysecrets/": "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets",
"https://codeberg.org/someone/lazysecrets": "https://codeberg.org/api/v1/repos/someone/lazysecrets",
"https://git.kebcloud.no/keb": "",
"git.kebcloud.no/keb/lazysecrets": "",
"https://github.com/karleinarb/lazysecrets/x": "",
} {
got, err := API(repo)
if got != want || (err != nil) != (want == "") {
t.Errorf("API(%q) = %q, %v", repo, got, err)
}
}
}
func TestInstallRefusesATamperedArchive(t *testing.T) { func TestInstallRefusesATamperedArchive(t *testing.T) {
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
t.Skip("releases are for macOS and Linux") t.Skip("releases are for macOS and Linux")
} }
srv := server(t, true) srv := server(t, true)
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"} c := &Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}
exe := filepath.Join(t.TempDir(), "lazysecrets") exe := filepath.Join(t.TempDir(), "lazysecrets")
os.WriteFile(exe, []byte("old"), 0o755) os.WriteFile(exe, []byte("old"), 0o755)
err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe) err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe)
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>keb/renovate"
],
"packageRules": [
{
"description": "A test helper pinned to a commit, with no releases to update to: Renovate can't look it up and warns on every run.",
"matchPackageNames": ["github.com/charmbracelet/x/exp/golden"],
"enabled": false
}
]
}
+75
View File
@@ -0,0 +1,75 @@
// Copies a release GoReleaser published on Gitea to the GitHub mirror:
// the same notes and the same files (dist/), so lazysecrets can update
// from either (internal/update tries GitHub first). Run by the release
// workflow after GoReleaser; a release already on GitHub for the tag is
// replaced, like GoReleaser's mode: replace on Gitea.
//
// TAG=v0.2.0 GH_TOKEN=… GITEA_TOKEN=… node scripts/github-release.mjs
//
// GH_TOKEN is a fine-grained token that can write the mirror's contents;
// without it, this does nothing. No dependencies: Node 20's fetch.
import { readdir, readFile } from "node:fs/promises";
const github = "karleinarb/lazysecrets";
const { TAG: tag, GH_TOKEN: token, GITEA_TOKEN: giteaToken, GITHUB_SHA: sha } = process.env;
const gitea = `${process.env.GITHUB_SERVER_URL ?? "https://git.kebcloud.no"}/api/v1/repos/${process.env.GITHUB_REPOSITORY ?? "keb/lazysecrets"}`;
if (!token) {
console.log("No GH_TOKEN: not copying the release to GitHub.");
process.exit(0);
}
if (!tag) throw new Error("TAG isn't set");
const gh = (path, init = {}) =>
fetch(path.startsWith("https://") ? path : `https://api.github.com/repos/${github}${path}`, {
...init,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
...init.headers,
},
});
async function ok(res, what) {
if (!res.ok) throw new Error(`${what}: ${res.status} ${await res.text()}`);
return res;
}
// The notes as GoReleaser wrote them on Gitea.
const rel = await (
await ok(await fetch(`${gitea}/releases/tags/${tag}`, { headers: giteaToken ? { Authorization: `token ${giteaToken}` } : {} }), "reading the Gitea release")
).json();
// Replace what's there for the tag.
const old = await gh(`/releases/tags/${tag}`);
if (old.ok) {
const { id } = await old.json();
await ok(await gh(`/releases/${id}`, { method: "DELETE" }), "deleting the old GitHub release");
console.log(`Deleted the GitHub release for ${tag}.`);
}
// The mirror may not have the tag's commit yet: it pushes on its own.
// Wait for it a while.
const body = JSON.stringify({ tag_name: tag, target_commitish: sha, name: rel.name || tag, body: rel.body, prerelease: rel.prerelease });
let created;
for (let i = 0; ; i++) {
const res = await gh("/releases", { method: "POST", body });
if (res.ok) {
created = await res.json();
break;
}
if (res.status !== 422 || i === 9) await ok(res, "creating the GitHub release");
console.log("GitHub doesn't have the commit yet; waiting for the mirror…");
await new Promise((r) => setTimeout(r, 30_000));
}
const files = (await readdir("dist")).filter((f) => f.endsWith(".tar.gz") || f === "checksums.txt");
if (files.length === 0) throw new Error("nothing to upload in dist/");
for (const f of files) {
const url = `https://uploads.github.com/repos/${github}/releases/${created.id}/assets?name=${encodeURIComponent(f)}`;
await ok(await gh(url, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: await readFile(`dist/${f}`) }), `uploading ${f}`);
console.log(`Uploaded ${f}.`);
}
console.log(`Published ${tag} on GitHub: ${created.html_url}`);