Compare commits

..

41 Commits

Author SHA1 Message Date
Joe Julian 51f2a0121a Fix extra string actions
ci / lint-test (pull_request) Successful in 5m26s
ci / build (pull_request) Successful in 6m28s
2026-04-28 21:31:40 -07:00
joejulian 11e883279d Merge pull request 'Bump release version to 0.8.2' (#13) from release/v0.8.2 into main
ci / lint-test (push) Successful in 4m36s
ci / build (push) Successful in 5m48s
2026-04-28 04:55:42 +00:00
Joe Julian e305a25802 Bump release version to 0.8.2
ci / lint-test (pull_request) Successful in 5m3s
ci / build (pull_request) Successful in 6m3s
ci / lint-test (push) Successful in 5m3s
ci / build (push) Successful in 5m28s
2026-04-27 21:35:23 -07:00
joejulian 8b4609c141 Merge pull request 'Scope Android autofill candidates' (#12) from bugfix/android-autofill-relevant-picker into main
ci / lint-test (push) Successful in 5m15s
ci / build (push) Successful in 6m41s
Reviewed-on: #12
2026-04-28 04:29:30 +00:00
Joe Julian d18627cda4 Scope Android autofill candidates
ci / lint-test (pull_request) Successful in 5m45s
ci / build (pull_request) Successful in 6m19s
2026-04-27 19:50:55 -07:00
joejulian 586de0169d Merge pull request 'Limit APK builds to 64-bit ABIs' (#11) from bugfix/apk-64bit-android-abis into main
ci / lint-test (push) Successful in 5m48s
ci / build (push) Successful in 6m45s
Reviewed-on: #11
2026-04-27 03:18:56 +00:00
Joe Julian de19f84305 Upgrade golangci-lint
ci / lint-test (pull_request) Successful in 6m0s
ci / build (pull_request) Successful in 6m19s
2026-04-26 16:48:10 -07:00
Joe Julian ff81ae633c Increase CI lint timeout
ci / lint-test (pull_request) Successful in 5m34s
ci / build (pull_request) Successful in 6m15s
2026-04-26 10:09:30 -07:00
Joe Julian 270a950a72 Serialize CI lint analysis
ci / lint-test (pull_request) Failing after 5m50s
ci / build (pull_request) Has been skipped
2026-04-26 10:02:12 -07:00
Joe Julian 18084b5e83 Limit APK builds to 64-bit ABIs
ci / lint-test (pull_request) Failing after 4m5s
ci / build (pull_request) Has been skipped
2026-04-25 23:03:37 -07:00
joejulian ab9214af99 Merge pull request 'Add browser best match option' (#10) from feature/browser-best-match-only into main
ci / lint-test (push) Successful in 4m24s
ci / build (push) Failing after 6m18s
Reviewed-on: #10
2026-04-25 21:03:43 +00:00
Joe Julian d1f30f5936 Add browser best match option
ci / lint-test (pull_request) Successful in 5m1s
ci / build (pull_request) Successful in 7m28s
2026-04-25 11:42:43 -07:00
Joe Julian 2269944702 Add Firefox extension web-ext targets
ci / lint-test (push) Successful in 6m5s
ci / build (push) Successful in 6m42s
2026-04-23 21:51:41 -07:00
Joe Julian 2ccd5bc337 Require cleanup before starting a new feature 2026-04-23 21:50:02 -07:00
Joe Julian 9a9d9e7447 Add Firefox extension icons and gap review
ci / lint-test (push) Successful in 4m8s
ci / build (push) Failing after 5m40s
2026-04-23 21:42:52 -07:00
Joe Julian 2c065a04a4 Narrow Android autofill chooser results 2026-04-23 21:02:34 -07:00
Joe Julian f82ddf7435 Add browser save and update workflow 2026-04-23 21:00:29 -07:00
Joe Julian 14c9bc72f6 Support Android share-driven credential lookup 2026-04-23 20:51:39 -07:00
Joe Julian 515eb730f0 Broaden Android accessibility autofill fallback 2026-04-23 20:44:32 -07:00
Joe Julian d60a8d2fbf Improve locked vault browser workflow 2026-04-23 20:37:49 -07:00
Joe Julian 4afbc3c933 Add browser search and richer URL matching 2026-04-23 20:36:17 -07:00
Joe Julian c7d35927f3 Add GUI test plan
ci / lint-test (push) Successful in 3m55s
ci / build (push) Successful in 6m14s
2026-04-19 22:01:33 -07:00
joejulian a6340f5c9e Merge pull request 'Fix CI APK JDK selection' (#8) from bugfix/ci-apk-java-selection into main
ci / lint-test (push) Successful in 3m41s
ci / build (push) Successful in 6m11s
2026-04-20 04:30:31 +00:00
Joe Julian 0adf1b8826 Run CI for pull requests
ci / lint-test (pull_request) Successful in 5m56s
ci / build (pull_request) Successful in 5m39s
2026-04-19 21:17:49 -07:00
Joe Julian c517794182 Provision Java 25 directly in CI 2026-04-19 20:37:46 -07:00
Joe Julian b511ab4dc0 Fix CI APK JDK selection 2026-04-19 20:27:14 -07:00
joejulian 7b06388712 Merge pull request 'Add Android autofill chooser and learned app binding' (#7) from feature/android-autofill-chooser into main
ci / lint-test (push) Successful in 3m14s
ci / build (push) Failing after 3m4s
2026-04-20 00:02:55 +00:00
Joe Julian fea1a75cdf Keep release signing secrets out of APK build logs 2026-04-18 22:16:25 -07:00
Joe Julian 0dfaeef7bf Require dedicated release signing for APK builds 2026-04-18 22:00:56 -07:00
Joe Julian 92a7853258 Harden Android shared-vault import intents 2026-04-16 21:33:40 -07:00
Joe Julian 14f22b4ebf Fix Android packaging asset discovery 2026-04-16 21:08:31 -07:00
Joe Julian 4d972bfab0 Simplify Android packaging around gogio 2026-04-16 20:47:51 -07:00
Joe Julian e005a42a3f Point gio-cmd dependency at patched fork 2026-04-16 20:29:27 -07:00
Joe Julian 58d6d510f9 Point gio dependency at patched fork 2026-04-16 18:16:40 -07:00
Joe Julian bb114cee16 Patch gogio at build time for Android snippets 2026-04-13 22:03:00 -07:00
Joe Julian 2431467aa7 Add Android autofill chooser and app binding 2026-04-13 22:02:51 -07:00
Joe Julian c302c29d4f Add autofill app binding helpers 2026-04-13 17:30:33 -07:00
Joe Julian 361d6dbe03 Add failing Android autofill binding tests 2026-04-13 17:26:51 -07:00
joejulian a41e842a65 Merge pull request 'Tighten browser inline overlay qualification' (#6) from bugfix/browser-inline-overlay into main
ci / lint-test (push) Successful in 3m29s
ci / build (push) Successful in 6m6s
2026-04-14 00:24:37 +00:00
Joe Julian 54398837e6 Tighten browser inline overlay qualification 2026-04-13 17:23:41 -07:00
joejulian 989b41735f Merge pull request 'Normalize vault storage root views' (#5) from bugfix/vault-root-view into main
ci / lint-test (push) Successful in 3m25s
ci / build (push) Successful in 6m21s
2026-04-13 16:31:56 +00:00
64 changed files with 4520 additions and 746 deletions
+9 -3
View File
@@ -45,8 +45,8 @@ Use this skill together with the installed `android-emulator-debug` skill. That
## Build Workflow ## Build Workflow
1. Verify the JDK/SDK paths match the known working environment. 1. Verify the JDK/SDK paths match the known working environment.
2. Build with `make apk`. 2. Build with `make apk` for debug validation, or `make apk-release` when validating production signing behavior.
3. If `make apk` fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code. 3. If the build fails, inspect the effective `JAVA_HOME`, `ANDROID_SDK_ROOT`, and `ANDROID_NDK_ROOT` before changing code.
4. If the problem is Android-only, avoid desktop-only conclusions from `go test ./...`. 4. If the problem is Android-only, avoid desktop-only conclusions from `go test ./...`.
Typical local build: Typical local build:
@@ -55,6 +55,12 @@ Typical local build:
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
``` ```
Typical local release build:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk-release
```
## Emulator Workflow ## Emulator Workflow
1. Reuse an existing emulator session if one is already running. 1. Reuse an existing emulator session if one is already running.
@@ -79,7 +85,7 @@ adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'
## Validation Checklist ## Validation Checklist
- APK builds successfully with `make apk`. - APK builds successfully with the intended target: `make apk` for debug validation or `make apk-release` for release-signing validation.
- App launches to `org.julianfamily.keepassgo/org.gioui.GioActivity`. - App launches to `org.julianfamily.keepassgo/org.gioui.GioActivity`.
- Screenshot shows the expected screen, not just a black frame. - Screenshot shows the expected screen, not just a black frame.
- `logcat` shows no app crash or Android runtime fatal error. - `logcat` shows no app crash or Android runtime fatal error.
+7 -1
View File
@@ -52,11 +52,17 @@ The installed package version must correspond to the committed source, not a dir
Use the repo's known-good local JDK unless the environment already proves otherwise: Use the repo's known-good local JDK unless the environment already proves otherwise:
```sh ```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk-release
``` ```
If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout. If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout.
- `ship it` must use the dedicated release keystore flow, not Gio's implicit debug or temporary signing path.
- The default local release-signing paths are:
`~/.config/keepassgo/android-release.keystore`
`~/.config/keepassgo/android-release.pass`
- If those files are unavailable, stop and fix signing instead of shipping a differently signed APK.
### 4. Zip The APK ### 4. Zip The APK
- Create the ZIP under the globally required temporary secret-safe directory. - Create the ZIP under the globally required temporary secret-safe directory.
+62 -6
View File
@@ -8,6 +8,9 @@ on:
- "v*" - "v*"
- "release-*" - "release-*"
- "[0-9]+.[0-9]+.[0-9]+*" - "[0-9]+.[0-9]+.[0-9]+*"
pull_request:
branches:
- main
permissions: permissions:
contents: write contents: write
@@ -16,7 +19,6 @@ env:
GO_VERSION: "1.26.1" GO_VERSION: "1.26.1"
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_ROOT: /opt/android-sdk/ndk ANDROID_NDK_ROOT: /opt/android-sdk/ndk
JAVA_HOME: /usr/lib/jvm/java-21-openjdk-amd64
DIST_DIR: dist DIST_DIR: dist
jobs: jobs:
@@ -31,6 +33,17 @@ jobs:
with: with:
go-version: ${{ env.GO_VERSION }} go-version: ${{ env.GO_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "25"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install native build dependencies - name: Install native build dependencies
shell: bash shell: bash
run: | run: |
@@ -39,6 +52,7 @@ jobs:
apt-get update apt-get update
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
zsh \ zsh \
python3 \
pkg-config \ pkg-config \
libx11-dev \ libx11-dev \
libx11-xcb-dev \ libx11-xcb-dev \
@@ -50,13 +64,19 @@ jobs:
libxcursor-dev \ libxcursor-dev \
libxfixes-dev libxfixes-dev
- name: Install web-ext
shell: bash
run: |
set -euo pipefail
npm install -g web-ext
- name: Lint - name: Lint
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
state_dir="$(mktemp -d)" state_dir="$(mktemp -d)"
trap 'rm -rf -- "$state_dir"' EXIT trap 'rm -rf -- "$state_dir"' EXIT
KEEPASSGO_STATE_DIR="$state_dir" go tool golangci-lint run --build-tags nox11,nowayland,novulkan ./... KEEPASSGO_STATE_DIR="$state_dir" go tool golangci-lint run --timeout=10m --build-tags nox11,nowayland,novulkan ./...
- name: Test - name: Test
shell: bash shell: bash
@@ -66,6 +86,12 @@ jobs:
trap 'rm -rf -- "$state_dir"' EXIT trap 'rm -rf -- "$state_dir"' EXIT
KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./... KEEPASSGO_STATE_DIR="$state_dir" go test -tags nox11,nowayland,novulkan ./...
- name: Firefox extension lint
shell: bash
run: |
set -euo pipefail
make browser-extension-firefox-lint
build: build:
needs: lint-test needs: lint-test
runs-on: keepassgo-android runs-on: keepassgo-android
@@ -78,6 +104,17 @@ jobs:
with: with:
go-version: ${{ env.GO_VERSION }} go-version: ${{ env.GO_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "25"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install native build dependencies - name: Install native build dependencies
shell: bash shell: bash
run: | run: |
@@ -86,6 +123,7 @@ jobs:
apt-get update apt-get update
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
zsh \ zsh \
python3 \
pkg-config \ pkg-config \
libx11-dev \ libx11-dev \
libx11-xcb-dev \ libx11-xcb-dev \
@@ -97,6 +135,12 @@ jobs:
libxcursor-dev \ libxcursor-dev \
libxfixes-dev libxfixes-dev
- name: Install web-ext
shell: bash
run: |
set -euo pipefail
npm install -g web-ext
- name: Prepare dist directory - name: Prepare dist directory
shell: bash shell: bash
run: | run: |
@@ -135,13 +179,23 @@ jobs:
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
signkey_path="$(mktemp)" mkdir -p build/ci-signing
trap 'rm -f -- "$signkey_path"' EXIT signkey_path="$(pwd)/build/ci-signing/android-release.keystore"
signpass_path="$(pwd)/build/ci-signing/android-release.pass"
trap 'rm -f -- "$signkey_path" "$signpass_path"' EXIT
printf '%s' '${{ secrets.APK_SIGNKEY_B64 }}' | base64 -d > "$signkey_path" printf '%s' '${{ secrets.APK_SIGNKEY_B64 }}' | base64 -d > "$signkey_path"
printf '%s' '${{ secrets.APK_SIGNPASS }}' > "$signpass_path"
export APP_VERSION="$(git describe --tags --always --dirty)" export APP_VERSION="$(git describe --tags --always --dirty)"
make apk SIGNKEY="$signkey_path" SIGNPASS='${{ secrets.APK_SIGNPASS }}' make apk-release RELEASE_SIGNKEY="$signkey_path" RELEASE_SIGNPASS_FILE="$signpass_path"
cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk" cp build/keepassgo.apk "${DIST_DIR}/keepassgo.apk"
- name: Build Firefox extension
shell: bash
run: |
set -euo pipefail
make browser-extension-firefox-build
cp build/browser-extension/*.zip "${DIST_DIR}/"
- name: Upload CI artifacts - name: Upload CI artifacts
uses: christopherhx/gitea-upload-artifact@v4 uses: christopherhx/gitea-upload-artifact@v4
env: env:
@@ -154,6 +208,7 @@ jobs:
dist/keepassgo-windows-amd64.exe dist/keepassgo-windows-amd64.exe
dist/keepassgo-windows-arm64.exe dist/keepassgo-windows-arm64.exe
dist/keepassgo.apk dist/keepassgo.apk
dist/*.zip
retention-days: 30 retention-days: 30
- name: Publish release artifacts - name: Publish release artifacts
@@ -176,4 +231,5 @@ jobs:
"${DIST_DIR}/keepassgo-linux-amd64" \ "${DIST_DIR}/keepassgo-linux-amd64" \
"${DIST_DIR}/keepassgo-windows-amd64.exe" \ "${DIST_DIR}/keepassgo-windows-amd64.exe" \
"${DIST_DIR}/keepassgo-windows-arm64.exe" \ "${DIST_DIR}/keepassgo-windows-arm64.exe" \
"${DIST_DIR}/keepassgo.apk" "${DIST_DIR}/keepassgo.apk" \
"${DIST_DIR}"/*.zip
+24 -13
View File
@@ -1,19 +1,30 @@
version: "2"
linters: linters:
default: standard
enable: enable:
- errcheck
- gocyclo - gocyclo
- gosimple settings:
- govet
- ineffassign
- staticcheck
- unused
linters-settings:
gocyclo: gocyclo:
min-complexity: 15 min-complexity: 15
exclusions:
issues: generated: lax
exclude-rules: presets:
- path: _test\.go - comments
linters: - common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- gocyclo - gocyclo
path: _test\.go
paths:
- third_party$
- builtin$
- examples$
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+5 -1
View File
@@ -136,6 +136,10 @@ These features are product requirements, not “nice to have” ideas.
## Delivery Discipline ## Delivery Discipline
- Treat bug fixes as the highest-priority items in `TODO.md`. - Treat bug fixes as the highest-priority items in `TODO.md`.
- Do not start a new feature while unrelated tracked or untracked local changes
remain in the repo.
- If previous work leaves unrelated uncommitted changes behind, stop and ask
the user before continuing with the next feature.
- Do not treat this product as complete until the stated requirements in this file are actually satisfied. - Do not treat this product as complete until the stated requirements in this file are actually satisfied.
- Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing. - Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing.
- Continue iterating in test-first slices: - Continue iterating in test-first slices:
@@ -177,7 +181,7 @@ These features are product requirements, not “nice to have” ideas.
local `ANDROID_NDK_ROOT=/opt/android-ndk`, local `ANDROID_NDK_ROOT=/opt/android-ndk`,
CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`, CI `ANDROID_NDK_ROOT=/opt/android-sdk/ndk`,
local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`, local `JAVA_HOME=/usr/lib/jvm/java-25-openjdk`,
CI `JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64`. CI `JAVA_HOME` provided by `actions/setup-java` with Temurin 25.
- Remember the known Android runtime regression: - Remember the known Android runtime regression:
`gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator. `gioui.org v0.9.0` produced a black screen on the `KeepassGoAPI35` emulator, while `gioui.org v0.8.0` rendered correctly. Treat Gio upgrades on Android as regression-sensitive and verify them on-device or in the emulator.
- When validating an APK in the emulator, prefer the known KeePassGO setup: - When validating an APK in the emulator, prefer the known KeePassGO setup:
+36 -6
View File
@@ -6,17 +6,43 @@ Build the APK with:
make apk make apk
``` ```
Build the release-signed APK with:
```sh
make apk-release
```
`make apk` uses a local Java 25 install when `JAVA_HOME` points to one.
If the host does not have a working Java 25 install, it falls back to the
repo-managed Docker image in `packaging/docker/android-apk/`, which also builds
with Java 25. CI provisions Java 25 directly in the build job so release builds
use that same local path instead of nested Docker.
`make apk` remains a developer build path and may use Gio's default debug or
ephemeral signing behavior if no explicit signing key is provided.
`make apk-release` is the production-signing path and fails unless a dedicated
release keystore and password file are present.
Environment: Environment:
- `ANDROID_SDK_ROOT` defaults to `/opt/android-sdk`. - `ANDROID_SDK_ROOT` defaults to `/opt/android-sdk`.
- `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`. - `ANDROID_NDK_ROOT` defaults to `/opt/android-ndk`.
- `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`. - `JAVA_HOME` defaults to `/usr/lib/jvm/java-25-openjdk`.
- `APK_BUILD_IMAGE` overrides the Docker image name used by `make apk-container`.
- `APP_ID` overrides the Android application id. - `APP_ID` overrides the Android application id.
- `APP_VERSION` overrides the version shown inside KeePassGO itself. - `APP_VERSION` overrides the version shown inside KeePassGO itself.
- `APK_OUT` overrides the output path. - `APK_OUT` overrides the output path.
- `APK_VERSION` overrides the packaged app version. - `APK_VERSION` overrides the packaged app version.
- `ANDROID_MIN_SDK` overrides the minimum supported Android SDK. - `ANDROID_MIN_SDK` overrides the minimum supported Android SDK.
- `ANDROID_TARGET_SDK` overrides the target Android SDK. - `ANDROID_TARGET_SDK` overrides the target Android SDK.
- `SIGNPASS_FILE` provides the signing password by file instead of a command-line argument.
- `RELEASE_SIGNKEY` overrides the release keystore path used by `make apk-release`.
- `RELEASE_SIGNPASS_FILE` overrides the password file path used by `make apk-release`.
Default release-signing paths:
- `~/.config/keepassgo/android-release.keystore`
- `~/.config/keepassgo/android-release.pass`
Installed machine prerequisites expected by this repo: Installed machine prerequisites expected by this repo:
@@ -24,24 +50,28 @@ Installed machine prerequisites expected by this repo:
- `android-sdk-build-tools` - `android-sdk-build-tools`
- `android-platform-35` - `android-platform-35`
- `android-sdk-platform-tools` - `android-sdk-platform-tools`
- a working JDK install - a working Java 25 JDK install for `make apk-local`, or Docker for `make apk`
The repo tracks `gogio` as a Go tool, so the build runs through: The repo tracks `gogio` as a Go tool, and the local build runs through:
```sh ```sh
go tool gogio -target android ./cmd/keepassgo ... go tool gogio -target android ./cmd/keepassgo ...
``` ```
The release target wraps `make apk` and injects explicit signing credentials so
local release builds and CI use the same stable key without echoing the release
password in build logs.
The Android build uses the branded icon asset at: The Android build uses the branded icon asset at:
- `internal/assets/keepassgo-icon.png` - `internal/assets/keepassgo-icon.png`
Note: Note:
- Gio's Android doc currently references Java 1.8, but the Android build-tools - KeePassGO's documented Android build uses Java 25 locally and in CI.
installed on this machine (`d8` from build-tools 37) do not run on Java 8. - If that host setup is unavailable, `make apk` falls back to the Docker image
- In this environment, KeePassGO's APK build requires a newer JDK runtime on so the build still runs under Java 25 instead of encoding a newer host JDK as
`PATH`, which is why the repo defaults `JAVA_HOME` to `/usr/lib/jvm/java-25-openjdk`. a requirement.
- Android runtime testing on the `KeepassGoAPI35` emulator showed a black-screen - Android runtime testing on the `KeepassGoAPI35` emulator showed a black-screen
regression with `gioui.org v0.9.0` while a stock Gio example and KeePassGO both regression with `gioui.org v0.9.0` while a stock Gio example and KeePassGO both
rendered correctly with `gioui.org v0.8.0` on the same emulator and SDK/JDK rendered correctly with `gioui.org v0.8.0` on the same emulator and SDK/JDK
+87 -5
View File
@@ -2,20 +2,28 @@ ANDROID_SDK_ROOT ?= /opt/android-sdk
ANDROID_NDK_ROOT ?= /opt/android-ndk ANDROID_NDK_ROOT ?= /opt/android-ndk
JAVA_HOME ?= /usr/lib/jvm/java-25-openjdk JAVA_HOME ?= /usr/lib/jvm/java-25-openjdk
PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_SDK_ROOT)/platform-tools:$(PATH) PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_SDK_ROOT)/platform-tools:$(PATH)
APK_BUILD_IMAGE ?= keepassgo/android-apk-build:java25
APP_ID ?= org.julianfamily.keepassgo APP_ID ?= org.julianfamily.keepassgo
APK_OUT ?= build/keepassgo.apk APK_OUT ?= build/keepassgo.apk
APK_VERSION ?= 0.1.0.1 APK_VERSION ?= 0.8.2.298
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION) GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
APK_ARCH ?= arm64,amd64
ANDROID_MIN_SDK ?= 28 ANDROID_MIN_SDK ?= 28
ANDROID_TARGET_SDK ?= 35 ANDROID_TARGET_SDK ?= 35
SIGNKEY ?= SIGNKEY ?=
SIGNPASS ?= SIGNPASS ?=
SIGNPASS_FILE ?=
RELEASE_SIGNKEY ?= $(HOME)/.config/keepassgo/android-release.keystore
RELEASE_SIGNPASS_FILE ?= $(HOME)/.config/keepassgo/android-release.pass
ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)") ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
ARCH_REPO_DIR ?= $(CURDIR) ARCH_REPO_DIR ?= $(CURDIR)
WEB_EXT ?= web-ext
FIREFOX_EXTENSION_DIR ?= build/firefox-extension
FIREFOX_EXTENSION_ARTIFACT_DIR ?= build/browser-extension
GOGIO_SIGN_FLAGS := GOGIO_SIGN_FLAGS :=
ifneq ($(strip $(SIGNKEY)),) ifneq ($(strip $(SIGNKEY)),)
@@ -25,8 +33,31 @@ ifneq ($(strip $(SIGNPASS)),)
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS) GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
endif endif
.PHONY: apk archlinux-pkgbuild browser-bridge browser-extension-validate CONTAINER_SIGNKEY_MOUNT :=
apk: android/keepassgo-android.jar CONTAINER_SIGNPASSFILE_MOUNT :=
CONTAINER_SIGN_ARGS :=
ifneq ($(strip $(SIGNKEY)),)
CONTAINER_SIGNKEY_MOUNT += -v "$(dir $(abspath $(SIGNKEY))):$(dir $(abspath $(SIGNKEY))):ro"
CONTAINER_SIGN_ARGS += SIGNKEY="$(abspath $(SIGNKEY))"
endif
ifneq ($(strip $(SIGNPASS)),)
CONTAINER_SIGN_ARGS += SIGNPASS="$(SIGNPASS)"
endif
ifneq ($(strip $(SIGNPASS_FILE)),)
CONTAINER_SIGNPASSFILE_MOUNT += -v "$(dir $(abspath $(SIGNPASS_FILE))):$(dir $(abspath $(SIGNPASS_FILE))):ro"
CONTAINER_SIGN_ARGS += SIGNPASS_FILE="$(abspath $(SIGNPASS_FILE))"
endif
.PHONY: apk apk-local apk-release apk-container apk-container-image archlinux-pkgbuild browser-bridge browser-extension-validate browser-extension-firefox-dir browser-extension-firefox-lint browser-extension-firefox-build browser-extension-firefox-run browser-extension-firefox-sign
apk:
@if [ -x "$(JAVA_HOME)/bin/java" ] && "$(JAVA_HOME)/bin/java" -version 2>&1 | grep -q 'version "25'; then \
$(MAKE) apk-local JAVA_HOME="$(JAVA_HOME)"; \
else \
echo "Using Dockerized Java 25 Android build because JAVA_HOME is not a working Java 25 install."; \
$(MAKE) apk-container; \
fi
apk-local: android/keepassgo-android.jar
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; } @test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; } @test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; } @test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
@@ -34,11 +65,18 @@ apk: android/keepassgo-android.jar
@test -d "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; } @test -d "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)/build-tools" || { echo "Android build-tools are missing"; exit 1; } @test -d "$(ANDROID_SDK_ROOT)/build-tools" || { echo "Android build-tools are missing"; exit 1; }
@mkdir -p "$(dir $(APK_OUT))" @mkdir -p "$(dir $(APK_OUT))"
@set -eu; \
if [ -n "$(SIGNPASS_FILE)" ]; then \
test -f "$(SIGNPASS_FILE)" || { echo "SIGNPASS_FILE does not exist: $(SIGNPASS_FILE)"; exit 1; }; \
export GOGIO_SIGNPASS="$$(tr -d '\r\n' < "$(SIGNPASS_FILE)")"; \
test -n "$$GOGIO_SIGNPASS" || { echo "SIGNPASS_FILE is empty: $(SIGNPASS_FILE)"; exit 1; }; \
fi; \
ANDROID_HOME="$(ANDROID_SDK_ROOT)" \ ANDROID_HOME="$(ANDROID_SDK_ROOT)" \
ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \ ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \ ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
JAVA_HOME="$(JAVA_HOME)" \ JAVA_HOME="$(JAVA_HOME)" \
go tool gogio -target android \ go tool gogio -target android \
-arch $(APK_ARCH) \
-buildmode exe \ -buildmode exe \
-appid $(APP_ID) \ -appid $(APP_ID) \
-ldflags "$(GO_LDFLAGS)" \ -ldflags "$(GO_LDFLAGS)" \
@@ -50,12 +88,39 @@ apk: android/keepassgo-android.jar
-icon internal/assets/keepassgo-icon.png \ -icon internal/assets/keepassgo-icon.png \
./cmd/keepassgo ./cmd/keepassgo
apk-release:
@test -f "$(RELEASE_SIGNKEY)" || { echo "Release signing key not found at $(RELEASE_SIGNKEY)"; exit 1; }
@test -f "$(RELEASE_SIGNPASS_FILE)" || { echo "Release signing password file not found at $(RELEASE_SIGNPASS_FILE)"; exit 1; }
@$(MAKE) apk SIGNKEY="$(abspath $(RELEASE_SIGNKEY))" SIGNPASS_FILE="$(abspath $(RELEASE_SIGNPASS_FILE))" JAVA_HOME="$(JAVA_HOME)"
apk-container: apk-container-image
@command -v docker >/dev/null 2>&1 || { echo "docker is required for apk-container"; exit 1; }
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
@test -d "$(ANDROID_NDK_ROOT)" || { echo "ANDROID_NDK_ROOT must point to an Android NDK install"; exit 1; }
docker run --rm \
-u "$$(id -u):$$(id -g)" \
-v "$(CURDIR):$(CURDIR)" \
-w "$(CURDIR)" \
-v "$(ANDROID_SDK_ROOT):$(ANDROID_SDK_ROOT)" \
-v "$(ANDROID_NDK_ROOT):$(ANDROID_NDK_ROOT)" \
$(CONTAINER_SIGNKEY_MOUNT) \
$(CONTAINER_SIGNPASSFILE_MOUNT) \
-e ANDROID_SDK_ROOT="$(ANDROID_SDK_ROOT)" \
-e ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
-e JAVA_HOME=/opt/java/openjdk \
$(APK_BUILD_IMAGE) \
make apk-local JAVA_HOME=/opt/java/openjdk $(CONTAINER_SIGN_ARGS)
apk-container-image:
@command -v docker >/dev/null 2>&1 || { echo "docker is required for apk-container-image"; exit 1; }
docker build --load -t $(APK_BUILD_IMAGE) packaging/docker/android-apk
android/keepassgo-android.jar: $(shell find androidsrc -type f | sort) android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
@test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; } @test -x "$(JAVA_HOME)/bin/javac" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
@test -f "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; } @test -f "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" || { echo "Android platform android-$(ANDROID_TARGET_SDK) is missing"; exit 1; }
@mkdir -p android @mkdir -p android
@zsh -lc 'tmpdir=$$(mktemp -d); \ @sh -ec 'tmpdir=$$(mktemp -d); \
trap '\''python3 -c "import shutil,sys; shutil.rmtree(sys.argv[1], ignore_errors=True)" "$$tmpdir"'\'' EXIT; \ trap "rm -rf $$tmpdir" EXIT; \
"$(JAVA_HOME)/bin/javac" \ "$(JAVA_HOME)/bin/javac" \
-classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \ -classpath "$(ANDROID_SDK_ROOT)/platforms/android-$(ANDROID_TARGET_SDK)/android.jar" \
-d "$$tmpdir" \ -d "$$tmpdir" \
@@ -72,6 +137,23 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
browser-bridge: browser-bridge:
go build ./cmd/keepassgo-browser-bridge go build ./cmd/keepassgo-browser-bridge
browser-extension-firefox-dir:
@mkdir -p "$(dir $(FIREFOX_EXTENSION_DIR))"
@python3 scripts/prepare_firefox_extension.py "$(FIREFOX_EXTENSION_DIR)"
browser-extension-firefox-lint: browser-extension-firefox-dir
$(WEB_EXT) lint --source-dir "$(FIREFOX_EXTENSION_DIR)"
browser-extension-firefox-build: browser-extension-firefox-dir
@mkdir -p "$(FIREFOX_EXTENSION_ARTIFACT_DIR)"
$(WEB_EXT) build --source-dir "$(FIREFOX_EXTENSION_DIR)" --artifacts-dir "$(FIREFOX_EXTENSION_ARTIFACT_DIR)"
browser-extension-firefox-run: browser-extension-firefox-dir
$(WEB_EXT) run --source-dir "$(FIREFOX_EXTENSION_DIR)"
browser-extension-firefox-sign: browser-extension-firefox-dir
$(WEB_EXT) sign --source-dir "$(FIREFOX_EXTENSION_DIR)"
browser-extension-validate: browser-extension-validate:
@command -v xvfb-run >/dev/null 2>&1 || { echo "xvfb-run is required"; exit 1; } @command -v xvfb-run >/dev/null 2>&1 || { echo "xvfb-run is required"; exit 1; }
@command -v firefox >/dev/null 2>&1 || { echo "firefox is required"; exit 1; } @command -v firefox >/dev/null 2>&1 || { echo "firefox is required"; exit 1; }
+17 -2
View File
@@ -90,10 +90,25 @@ go get -tool gioui.org/cmd/gogio@latest
Package: Package:
```bash ```bash
go tool gogio -target android -icon internal/assets/keepassgo-icon.png ./cmd/keepassgo make apk
``` ```
You will need the Android SDK and NDK installed and configured for real device or release packaging. `make apk` prefers a local Java 25 install at `JAVA_HOME`. If that is not
available, it falls back to the repo-managed Docker build image, which also
uses Java 25. CI provisions Java 25 directly in the build job so release
packaging follows that same local path. You still need the Android SDK and NDK
installed and configured for real device or release packaging.
Release package:
```bash
make apk-release
```
`make apk-release` is the production-signing path. It requires a dedicated
release keystore at `~/.config/keepassgo/android-release.keystore` and a
password file at `~/.config/keepassgo/android-release.pass`, unless you
override `RELEASE_SIGNKEY` and `RELEASE_SIGNPASS_FILE`.
## Automation ## Automation
+93 -1
View File
@@ -130,6 +130,98 @@ These are important, but they should likely move behind a dedicated settings gea
- Accessibility preferences: - Accessibility preferences:
future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings. future display-density, contrast, reduced-motion, or keyboard-focus tuning should live under settings.
## Upstream Gap Review
This section tracks explicit feature gaps against the source-level behavior of:
- KeePass 2.57.1
- KeePassHttp
- Keepass2Android
These are not speculative enhancements. They are parity gaps relative to the
stated product requirement to cover the practical feature surface of those
upstream tools where it fits KeePassGO's security model.
### Stage 1
- Android autofill parity/completeness:
close the remaining gaps in Android autofill behavior, including broader
page/app detection coverage, stronger approval and visibility UX, and more
reliable fill behavior across real-world apps and browsers.
- Android fallback fill workflows:
provide a non-autofill fallback comparable in usefulness to KP2A's keyboard
and share-driven workflows for apps and browsers that do not cooperate with
platform autofill.
- Browser extension save/update:
add the browser-side save/update-credential workflow after successful form
submission, not only lookup and fill.
- Search and matching controls:
browser/API result behavior does not yet expose KeePassHttp-style controls
such as best-match-only, scheme matching, and sort preferences as a finished
product surface.
- Unlock-request workflow:
KeePassHttp has an explicit locked-database browser flow; KeePassGO still
needs a polished browser-visible locked/unlock request experience.
- Android share/intents:
browser/app share-driven lookup and open flows comparable to KP2A are not
implemented as a full user workflow.
### Stage 2
- OTP/TOTP:
implement real OTP/TOTP support, including storage conventions compatible
with common KeePass ecosystems and usable display/copy/fill workflows.
- TOTP product surface:
KP2A exposes TOTP directly in entry and list UX; KeePassGO does not.
- Browser-returned field breadth:
KeePassHttp can return string fields for browser consumers; KeePassGO does
not yet have a finished policy and browser UX for rich field return.
- Placeholder and field-reference parity:
KeePass-style placeholder expansion, field references, and related command
and URL override behavior are not implemented as a product surface.
- Offline/work-offline flow:
KP2A has explicit offline/cache-oriented remote-file workflows that are more
mature than KeePassGO's current user-facing remote behavior.
### Stage 3
- Desktop automation:
implement desktop login automation comparable in practical capability to
KeePass auto-type, or replace it with a demonstrably superior workflow that
covers global invocation, selected-entry invocation, window targeting, and
field sequencing.
- Trigger system:
KeePass-style event/condition/action triggers are not implemented.
- Import/export breadth:
KDBX load/save exists, but KeePass-style breadth for CSV/XML/HTML and other
exchange formats is still missing.
- Multi-database lookup:
KeePassHttp can search across all opened databases when configured; KeePassGO
does not yet have an equivalent multi-vault lookup model.
- Remote backend breadth:
KP2A supports far more remote/file backends than KeePassGO currently does;
KeePassGO is still effectively WebDAV-first.
- Plugin/extensibility model:
KeePassGO has integrations, but not a first-class plugin model comparable to
KeePass.
- Plugin ecosystem replacements:
QR transfer, keyboard transport, and related mobile integration equivalents
do not exist in KeePassGO.
- Emergency and recovery utilities:
emergency-sheet/key-file-backup style flows are not implemented.
### Immediate Product Questions
- Desktop automation:
decide whether KeePassGO will implement true auto-type, or a different
security model that still satisfies the practical workflows KeePass users
expect.
- OTP model:
decide the canonical KeePassGO representation for TOTP/HOTP so desktop,
Android, gRPC, and browser workflows can all target the same semantics.
- Remote breadth:
decide which non-WebDAV backends are in product scope after WebDAV.
### Exit Criteria ### Exit Criteria
- The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials. - The main workflow screens prioritize opening, browsing, copying, editing, and synchronizing credentials.
@@ -269,7 +361,7 @@ Exit criteria:
- Tests cover clear/reset transitions. - Tests cover clear/reset transitions.
- `go test ./...` passes. - `go test ./...` passes.
### Segment 10: Template CRUD UI ### Segment 10 (stage 3): Template CRUD UI
Scope: Scope:
- Create template. - Create template.
+19
View File
@@ -22,6 +22,10 @@
android:name="android.accessibilityservice" android:name="android.accessibilityservice"
android:resource="@xml/keepassgo_accessibility_service" /> android:resource="@xml/keepassgo_accessibility_service" />
</service> </service>
<activity
android:name="org.julianfamily.keepassgo.KeePassGOAutofillPickerActivity"
android:exported="false"
android:label="Search KeePassGO" />
<provider <provider
android:name="org.julianfamily.keepassgo.SharedVaultProvider" android:name="org.julianfamily.keepassgo.SharedVaultProvider"
android:authorities="org.julianfamily.keepassgo.share" android:authorities="org.julianfamily.keepassgo.share"
@@ -31,6 +35,11 @@
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity" android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
android:exported="true" android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar"> android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@@ -41,6 +50,16 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/octet-stream" />
<data android:mimeType="application/x-keepass2" />
<data android:mimeType="application/vnd.keepass" />
<data android:scheme="content" android:pathPattern=".*\\.kdbx" /> <data android:scheme="content" android:pathPattern=".*\\.kdbx" />
<data android:scheme="file" android:pathPattern=".*\\.kdbx" /> <data android:scheme="file" android:pathPattern=".*\\.kdbx" />
</intent-filter> </intent-filter>
@@ -0,0 +1,116 @@
package org.julianfamily.keepassgo;
import android.content.Context;
import android.util.JsonReader;
import android.util.JsonWriter;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
final class AutofillBindingStore {
private static final String TAG = "KeePassGOAutofill";
private AutofillBindingStore() {
}
static String entryIDForTarget(Context context, String rawTarget) {
Bindings bindings = read(context);
return bindings.apps.getOrDefault(normalize(rawTarget), "");
}
static void rememberBinding(Context context, String rawTarget, String entryID) {
String target = normalize(rawTarget);
if (target.isEmpty() || entryID == null || entryID.trim().isEmpty()) {
return;
}
Bindings bindings = read(context);
bindings.updatedAt = Instant.now().toString();
bindings.apps.put(target, entryID.trim());
write(context, bindings);
}
private static Bindings read(Context context) {
File path = path(context);
if (!path.isFile()) {
return new Bindings();
}
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {
Bindings bindings = new Bindings();
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
if ("updatedAt".equals(name)) {
bindings.updatedAt = nextString(reader);
continue;
}
if ("apps".equals(name)) {
reader.beginObject();
while (reader.hasNext()) {
bindings.apps.put(normalize(reader.nextName()), nextString(reader));
}
reader.endObject();
continue;
}
reader.skipValue();
}
reader.endObject();
return bindings;
} catch (IOException err) {
Log.e(TAG, "failed to read autofill bindings", err);
return new Bindings();
}
}
private static void write(Context context, Bindings bindings) {
File path = path(context);
File parent = path.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
Log.e(TAG, "failed to create autofill binding directory " + parent.getAbsolutePath());
return;
}
try (JsonWriter writer = new JsonWriter(new OutputStreamWriter(new FileOutputStream(path, false), StandardCharsets.UTF_8))) {
writer.setIndent(" ");
writer.beginObject();
writer.name("updatedAt").value(bindings.updatedAt);
writer.name("apps");
writer.beginObject();
for (Map.Entry<String, String> entry : bindings.apps.entrySet()) {
writer.name(entry.getKey()).value(entry.getValue());
}
writer.endObject();
writer.endObject();
} catch (IOException err) {
Log.e(TAG, "failed to write autofill bindings", err);
}
}
private static String nextString(JsonReader reader) throws IOException {
if (reader.peek() == android.util.JsonToken.NULL) {
reader.nextNull();
return "";
}
return reader.nextString();
}
private static File path(Context context) {
return new File(new File(context.getFilesDir(), "keepassgo"), "autofill-bindings.json");
}
private static String normalize(String rawTarget) {
return rawTarget == null ? "" : rawTarget.trim();
}
private static final class Bindings {
String updatedAt = "";
final Map<String, String> apps = new LinkedHashMap<>();
}
}
@@ -11,7 +11,6 @@ import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
final class AutofillCacheStore { final class AutofillCacheStore {
private static final String TAG = "KeePassGOAutofill"; private static final String TAG = "KeePassGOAutofill";
@@ -20,41 +19,91 @@ final class AutofillCacheStore {
} }
static Entry findBestMatch(Context context, String webDomain) { static Entry findBestMatch(Context context, String webDomain) {
File cacheFile = findCacheFile(context); List<Entry> entries = readEntries(context);
if (cacheFile == null) {
Log.i(TAG, "autofill cache file not found");
return null;
}
List<Entry> entries;
try {
entries = readEntries(cacheFile);
} catch (IOException err) {
Log.e(TAG, "failed to read autofill cache", err);
return null;
}
if (entries.isEmpty()) { if (entries.isEmpty()) {
return null; return null;
} }
NormalizedTarget target = normalizeURL(webDomain); return fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), webDomain));
if (target.host.isEmpty()) { }
static Entry findByID(Context context, String entryID) {
if (entryID == null || entryID.trim().isEmpty()) {
return null; return null;
} }
List<Entry> exactHost = new ArrayList<>(); for (Entry entry : readEntries(context)) {
List<Entry> parentHost = new ArrayList<>(); if (entryID.equals(entry.id)) {
return entry;
}
}
return null;
}
static List<Entry> chooserCandidates(Context context, String rawTarget) {
return chooserCandidatesFromEntries(readEntries(context), rawTarget);
}
static List<Entry> chooserCandidatesFromEntries(List<Entry> entries, String rawTarget) {
if (entries.isEmpty()) {
return entries;
}
return fromMatcherEntries(AutofillTargetMatcher.chooserCandidates(toMatcherEntries(entries), rawTarget));
}
static List<Entry> relevantCandidates(Context context, String rawTarget) {
return relevantCandidatesFromEntries(readEntries(context), rawTarget);
}
static List<Entry> relevantCandidatesFromEntries(List<Entry> entries, String rawTarget) {
if (entries.isEmpty()) {
return entries;
}
return fromMatcherEntries(AutofillTargetMatcher.relevantCandidates(toMatcherEntries(entries), rawTarget));
}
private static List<AutofillTargetMatcher.Entry> toMatcherEntries(List<Entry> entries) {
List<AutofillTargetMatcher.Entry> converted = new ArrayList<>(entries.size());
for (Entry entry : entries) { for (Entry entry : entries) {
if (entryMatchesHost(entry, target.host)) { converted.add(new AutofillTargetMatcher.Entry(
exactHost.add(entry); entry.id,
continue; entry.title,
entry.username,
entry.password,
entry.host,
entry.url,
entry.targets,
entry.path
));
} }
if (entryMatchesParentHost(entry, target.host)) { return converted;
parentHost.add(entry);
} }
private static Entry fromMatcherEntry(AutofillTargetMatcher.Entry entry) {
if (entry == null) {
return null;
} }
Entry matched = chooseEntry(target, exactHost); return new Entry(entry.id, entry.title, entry.username, entry.password, entry.host, entry.url, entry.targets, entry.path);
if (matched != null) { }
return matched;
private static List<Entry> fromMatcherEntries(List<AutofillTargetMatcher.Entry> entries) {
List<Entry> converted = new ArrayList<>(entries.size());
for (AutofillTargetMatcher.Entry entry : entries) {
converted.add(fromMatcherEntry(entry));
}
return converted;
}
private static List<Entry> readEntries(Context context) {
File cacheFile = findCacheFile(context);
if (cacheFile == null) {
Log.i(TAG, "autofill cache file not found");
return new ArrayList<>();
}
try {
return readEntries(cacheFile);
} catch (IOException err) {
Log.e(TAG, "failed to read autofill cache", err);
return new ArrayList<>();
} }
return chooseEntry(target, parentHost);
} }
private static File findCacheFile(Context context) { private static File findCacheFile(Context context) {
@@ -103,16 +152,21 @@ final class AutofillCacheStore {
} }
private static Entry readEntry(JsonReader reader) throws IOException { private static Entry readEntry(JsonReader reader) throws IOException {
String id = "";
String title = ""; String title = "";
String username = ""; String username = "";
String password = ""; String password = "";
String host = ""; String host = "";
String url = ""; String url = "";
List<String> targets = new ArrayList<>(); List<String> targets = new ArrayList<>();
List<String> path = new ArrayList<>();
reader.beginObject(); reader.beginObject();
while (reader.hasNext()) { while (reader.hasNext()) {
String name = reader.nextName(); String name = reader.nextName();
switch (name) { switch (name) {
case "id":
id = nextString(reader);
break;
case "title": case "title":
title = nextString(reader); title = nextString(reader);
break; break;
@@ -135,13 +189,20 @@ final class AutofillCacheStore {
} }
reader.endArray(); reader.endArray();
break; break;
case "path":
reader.beginArray();
while (reader.hasNext()) {
path.add(nextString(reader));
}
reader.endArray();
break;
default: default:
reader.skipValue(); reader.skipValue();
break; break;
} }
} }
reader.endObject(); reader.endObject();
return new Entry(title, username, password, host, url, targets); return new Entry(id, title, username, password, host, url, targets, path);
} }
private static String nextString(JsonReader reader) throws IOException { private static String nextString(JsonReader reader) throws IOException {
@@ -153,182 +214,28 @@ final class AutofillCacheStore {
} }
private static String normalizeHost(String raw) { private static String normalizeHost(String raw) {
return normalizeURL(raw).host; return AutofillTargetMatcher.normalize(raw).host;
}
private static NormalizedTarget normalizeURL(String raw) {
if (raw == null) {
return new NormalizedTarget("", "", "");
}
String value = raw.trim().toLowerCase(Locale.US);
if (value.startsWith("http://")) {
value = value.substring("http://".length());
} else if (value.startsWith("https://")) {
value = value.substring("https://".length());
}
int slash = value.indexOf('/');
if (slash >= 0) {
value = value.substring(0, slash);
}
int colon = value.indexOf(':');
if (colon >= 0) {
value = value.substring(0, colon);
}
String host = value;
String path = "/";
int schemeSep = raw.indexOf("://");
String original = raw.trim();
if (schemeSep < 0) {
original = "https://" + original;
}
try {
java.net.URI uri = java.net.URI.create(original);
if (uri.getHost() != null) {
host = uri.getHost().toLowerCase(Locale.US);
}
path = cleanPath(uri.getPath());
} catch (IllegalArgumentException ignored) {
path = "/";
}
return new NormalizedTarget(host, path, host + path);
}
private static String cleanPath(String raw) {
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
return "/";
}
String value = raw.trim();
while (value.endsWith("/") && value.length() > 1) {
value = value.substring(0, value.length() - 1);
}
if (!value.startsWith("/")) {
value = "/" + value;
}
return value;
}
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
if (entries.isEmpty()) {
return null;
}
if (entries.size() == 1) {
return entries.get(0);
}
List<Entry> exact = new ArrayList<>();
List<Entry> prefix = new ArrayList<>();
int bestPrefixLen = -1;
for (Entry entry : entries) {
MatchQuality quality = bestTargetMatch(entry, target);
if (quality.exact) {
exact.add(entry);
continue;
}
if (quality.prefixLength <= 0) {
continue;
}
if (quality.prefixLength > bestPrefixLen) {
prefix.clear();
prefix.add(entry);
bestPrefixLen = quality.prefixLength;
} else if (quality.prefixLength == bestPrefixLen) {
prefix.add(entry);
}
}
if (exact.size() == 1) {
return exact.get(0);
}
if (exact.size() > 1 || prefix.isEmpty()) {
return null;
}
return prefix.size() == 1 ? prefix.get(0) : null;
}
private static boolean entryMatchesHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (target.host.equals(host)) {
return true;
}
}
return false;
}
private static boolean entryMatchesParentHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
return true;
}
}
return false;
}
private static List<NormalizedTarget> entryTargets(Entry entry) {
List<String> rawTargets = entry.targets;
if (rawTargets.isEmpty()) {
rawTargets = new ArrayList<>();
rawTargets.add(entry.url);
}
List<NormalizedTarget> targets = new ArrayList<>();
for (String rawTarget : rawTargets) {
NormalizedTarget target = normalizeURL(rawTarget);
if (!target.host.isEmpty()) {
targets.add(target);
}
}
return targets;
}
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
int bestPrefixLen = -1;
for (NormalizedTarget entryTarget : entryTargets(entry)) {
if (entryTarget.url.equals(target.url)) {
return new MatchQuality(true, 0);
}
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
}
}
return new MatchQuality(false, bestPrefixLen);
} }
static final class Entry { static final class Entry {
final String id;
final String title; final String title;
final String username; final String username;
final String password; final String password;
final String host; final String host;
final String url; final String url;
final List<String> targets; final List<String> targets;
final List<String> path;
Entry(String title, String username, String password, String host, String url, List<String> targets) { Entry(String id, String title, String username, String password, String host, String url, List<String> targets, List<String> path) {
this.id = id;
this.title = title; this.title = title;
this.username = username; this.username = username;
this.password = password; this.password = password;
this.host = host; this.host = host;
this.url = url; this.url = url;
this.targets = new ArrayList<>(targets); this.targets = new ArrayList<>(targets);
} this.path = new ArrayList<>(path);
}
private static final class MatchQuality {
final boolean exact;
final int prefixLength;
MatchQuality(boolean exact, int prefixLength) {
this.exact = exact;
this.prefixLength = prefixLength;
}
}
private static final class NormalizedTarget {
final String host;
final String path;
final String url;
NormalizedTarget(String host, String path, String url) {
this.host = host;
this.path = path;
this.url = url;
} }
} }
} }
@@ -0,0 +1,24 @@
package org.julianfamily.keepassgo;
final class AutofillFallbackTarget {
private static final String APP_SCHEME = "androidapp://";
private AutofillFallbackTarget() {
}
static String resolve(String packageName, String webDomain) {
String domain = trim(webDomain);
if (!domain.isEmpty()) {
return domain;
}
String pkg = trim(packageName);
if (pkg.isEmpty()) {
return "";
}
return APP_SCHEME + pkg;
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,274 @@
package org.julianfamily.keepassgo;
import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
final class AutofillTargetMatcher {
private AutofillTargetMatcher() {
}
static Entry findBestMatch(List<Entry> entries, String rawTarget) {
if (entries == null || entries.isEmpty()) {
return null;
}
NormalizedTarget target = normalize(rawTarget);
if (target.host.isEmpty()) {
return null;
}
List<Entry> exactHost = new ArrayList<>();
List<Entry> parentHost = new ArrayList<>();
for (Entry entry : entries) {
if (entryMatchesHost(entry, target.host)) {
exactHost.add(entry);
continue;
}
if (entryMatchesParentHost(entry, target.host)) {
parentHost.add(entry);
}
}
Entry matched = chooseEntry(target, exactHost);
if (matched != null) {
return matched;
}
return chooseEntry(target, parentHost);
}
static List<Entry> chooserCandidates(List<Entry> entries, String rawTarget) {
if (entries == null || entries.isEmpty()) {
return new ArrayList<>();
}
List<Entry> related = relevantCandidates(entries, rawTarget);
if (!related.isEmpty()) {
return related;
}
return sortEntries(entries);
}
static List<Entry> relevantCandidates(List<Entry> entries, String rawTarget) {
if (entries == null || entries.isEmpty()) {
return new ArrayList<>();
}
Entry direct = findBestMatch(entries, rawTarget);
if (direct != null) {
List<Entry> resolved = new ArrayList<>();
resolved.add(direct);
return resolved;
}
NormalizedTarget target = normalize(rawTarget);
if (target.host.isEmpty()) {
return new ArrayList<>();
}
List<Entry> exactHost = new ArrayList<>();
List<Entry> parentHost = new ArrayList<>();
for (Entry entry : entries) {
if (entryMatchesHost(entry, target.host)) {
exactHost.add(entry);
continue;
}
if (entryMatchesParentHost(entry, target.host)) {
parentHost.add(entry);
}
}
if (!exactHost.isEmpty()) {
return sortEntries(exactHost);
}
if (!parentHost.isEmpty()) {
return sortEntries(parentHost);
}
return new ArrayList<>();
}
static NormalizedTarget normalize(String raw) {
if (raw == null) {
return new NormalizedTarget("", "", "");
}
String trimmed = raw.trim();
if (trimmed.isEmpty()) {
return new NormalizedTarget("", "", "");
}
String original = trimmed;
String value = trimmed.toLowerCase(Locale.US);
if (value.startsWith("http://")) {
value = value.substring("http://".length());
} else if (value.startsWith("https://")) {
value = value.substring("https://".length());
}
int slash = value.indexOf('/');
if (slash >= 0) {
value = value.substring(0, slash);
}
int colon = value.indexOf(':');
if (colon >= 0) {
value = value.substring(0, colon);
}
String host = value;
String path = "/";
int schemeSep = original.indexOf("://");
if (schemeSep < 0) {
original = "https://" + original;
}
try {
URI uri = URI.create(original);
if (uri.getHost() != null) {
host = uri.getHost().toLowerCase(Locale.US);
}
path = cleanPath(uri.getPath());
} catch (IllegalArgumentException ignored) {
path = "/";
}
return new NormalizedTarget(host, path, host + path);
}
private static String cleanPath(String raw) {
if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) {
return "/";
}
String value = raw.trim();
while (value.endsWith("/") && value.length() > 1) {
value = value.substring(0, value.length() - 1);
}
if (!value.startsWith("/")) {
value = "/" + value;
}
return value;
}
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
if (entries.isEmpty()) {
return null;
}
if (entries.size() == 1) {
return entries.get(0);
}
List<Entry> exact = new ArrayList<>();
List<Entry> prefix = new ArrayList<>();
int bestPrefixLen = -1;
for (Entry entry : entries) {
MatchQuality quality = bestTargetMatch(entry, target);
if (quality.exact) {
exact.add(entry);
continue;
}
if (quality.prefixLength <= 0) {
continue;
}
if (quality.prefixLength > bestPrefixLen) {
prefix.clear();
prefix.add(entry);
bestPrefixLen = quality.prefixLength;
} else if (quality.prefixLength == bestPrefixLen) {
prefix.add(entry);
}
}
if (exact.size() == 1) {
return exact.get(0);
}
if (exact.size() > 1 || prefix.isEmpty()) {
return null;
}
return prefix.size() == 1 ? prefix.get(0) : null;
}
private static boolean entryMatchesHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (target.host.equals(host)) {
return true;
}
}
return false;
}
private static boolean entryMatchesParentHost(Entry entry, String host) {
for (NormalizedTarget target : entryTargets(entry)) {
if (!target.host.isEmpty() && host.endsWith("." + target.host)) {
return true;
}
}
return false;
}
private static List<NormalizedTarget> entryTargets(Entry entry) {
List<String> rawTargets = entry.targets;
if (rawTargets.isEmpty()) {
rawTargets = new ArrayList<>();
rawTargets.add(entry.url);
}
List<NormalizedTarget> targets = new ArrayList<>();
for (String rawTarget : rawTargets) {
NormalizedTarget target = normalize(rawTarget);
if (!target.host.isEmpty()) {
targets.add(target);
}
}
return targets;
}
private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) {
int bestPrefixLen = -1;
for (NormalizedTarget entryTarget : entryTargets(entry)) {
if (entryTarget.url.equals(target.url)) {
return new MatchQuality(true, 0);
}
if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) {
bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length());
}
}
return new MatchQuality(false, bestPrefixLen);
}
private static List<Entry> sortEntries(List<Entry> entries) {
List<Entry> sorted = new ArrayList<>(entries);
sorted.sort(Comparator
.comparing((Entry entry) -> entry.title.toLowerCase(Locale.US))
.thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US))
.thenComparing(entry -> entry.id));
return sorted;
}
static final class NormalizedTarget {
final String host;
final String path;
final String url;
NormalizedTarget(String host, String path, String url) {
this.host = host;
this.path = path;
this.url = url;
}
}
static final class Entry {
final String id;
final String title;
final String username;
final String password;
final String host;
final String url;
final List<String> targets;
final List<String> path;
Entry(String id, String title, String username, String password, String host, String url, List<String> targets, List<String> path) {
this.id = id;
this.title = title;
this.username = username;
this.password = password;
this.host = host;
this.url = url;
this.targets = new ArrayList<>(targets);
this.path = new ArrayList<>(path);
}
}
private static final class MatchQuality {
final boolean exact;
final int prefixLength;
MatchQuality(boolean exact, int prefixLength) {
this.exact = exact;
this.prefixLength = prefixLength;
}
}
}
@@ -12,7 +12,6 @@ import java.util.List;
public final class KeePassGOAccessibilityService extends AccessibilityService { public final class KeePassGOAccessibilityService extends AccessibilityService {
private static final String TAG = "KeePassGOA11y"; private static final String TAG = "KeePassGOA11y";
private String lastFilledSignature = ""; private String lastFilledSignature = "";
@Override @Override
@@ -22,22 +21,17 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
if (root == null) { if (root == null) {
return; return;
} }
CharSequence packageName = root.getPackageName(); ChromeForm form = inspectWindow(root);
if (packageName == null || !"com.android.chrome".contentEquals(packageName)) {
return;
}
ChromeForm form = inspectChrome(root);
if (form == null || form.passwordField == null) { if (form == null || form.passwordField == null) {
return; return;
} }
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.url); AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, form.matchTarget);
if (entry == null) { if (entry == null) {
Log.i(TAG, "no accessibility-fill match for " + form.url); Log.i(TAG, "no accessibility-fill match for " + form.matchTarget);
return; return;
} }
String signature = form.url + "|" + entry.username + "|" + nodeKey(form.passwordField); String signature = form.matchTarget + "|" + entry.username + "|" + nodeKey(form.passwordField);
if (signature.equals(lastFilledSignature)) { if (signature.equals(lastFilledSignature)) {
return; return;
} }
@@ -49,7 +43,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
filled |= setNodeText(form.passwordField, entry.password); filled |= setNodeText(form.passwordField, entry.password);
if (filled) { if (filled) {
lastFilledSignature = signature; lastFilledSignature = signature;
Log.i(TAG, "filled login form for " + form.url + " with " + entry.username); Log.i(TAG, "filled login form for " + form.matchTarget + " with " + entry.username);
} }
} catch (Exception err) { } catch (Exception err) {
Log.e(TAG, "accessibility fill failed", err); Log.e(TAG, "accessibility fill failed", err);
@@ -61,11 +55,11 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
Log.i(TAG, "accessibility service interrupted"); Log.i(TAG, "accessibility service interrupted");
} }
private static ChromeForm inspectChrome(AccessibilityNodeInfo root) { private static ChromeForm inspectWindow(AccessibilityNodeInfo root) {
List<AccessibilityNodeInfo> editables = new ArrayList<>(); List<AccessibilityNodeInfo> editables = new ArrayList<>();
ChromeForm form = new ChromeForm(); ChromeForm form = new ChromeForm();
walk(root, editables, form); walk(root, editables, form);
if (form.url.isEmpty()) { if (form.matchTarget.isEmpty()) {
return null; return null;
} }
if (form.passwordField == null) { if (form.passwordField == null) {
@@ -111,7 +105,13 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
if (viewID != null && viewID.toString().endsWith(":id/url_bar")) { if (viewID != null && viewID.toString().endsWith(":id/url_bar")) {
CharSequence text = node.getText(); CharSequence text = node.getText();
if (text != null) { if (text != null) {
form.url = text.toString().trim(); form.webDomain = text.toString().trim();
}
}
if (form.packageName.isEmpty()) {
CharSequence packageName = node.getPackageName();
if (packageName != null) {
form.packageName = packageName.toString().trim();
} }
} }
if (node.isEditable()) { if (node.isEditable()) {
@@ -128,6 +128,7 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
walk(child, editables, form); walk(child, editables, form);
} }
} }
form.matchTarget = AutofillFallbackTarget.resolve(form.packageName, form.webDomain);
} }
private static boolean isPasswordNode(AccessibilityNodeInfo node) { private static boolean isPasswordNode(AccessibilityNodeInfo node) {
@@ -188,7 +189,9 @@ public final class KeePassGOAccessibilityService extends AccessibilityService {
} }
private static final class ChromeForm { private static final class ChromeForm {
String url = ""; String packageName = "";
String webDomain = "";
String matchTarget = "";
AccessibilityNodeInfo usernameField; AccessibilityNodeInfo usernameField;
AccessibilityNodeInfo passwordField; AccessibilityNodeInfo passwordField;
} }
@@ -0,0 +1,182 @@
package org.julianfamily.keepassgo;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillManager;
import android.view.autofill.AutofillValue;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import android.service.autofill.Dataset;
public final class KeePassGOAutofillPickerActivity extends Activity {
static final String EXTRA_TARGET = "org.julianfamily.keepassgo.extra.AUTOFILL_TARGET";
static final String EXTRA_PACKAGE_NAME = "org.julianfamily.keepassgo.extra.AUTOFILL_PACKAGE";
static final String EXTRA_USERNAME_ID = "org.julianfamily.keepassgo.extra.USERNAME_ID";
static final String EXTRA_PASSWORD_ID = "org.julianfamily.keepassgo.extra.PASSWORD_ID";
private final List<AutofillCacheStore.Entry> allEntries = new ArrayList<>();
private final List<AutofillCacheStore.Entry> visibleEntries = new ArrayList<>();
private ArrayAdapter<String> adapter;
private String matchTarget = "";
private String packageName = "";
private AutofillId usernameID;
private AutofillId passwordID;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
matchTarget = intent.getStringExtra(EXTRA_TARGET);
packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME);
usernameID = intent.getParcelableExtra(EXTRA_USERNAME_ID, AutofillId.class);
passwordID = intent.getParcelableExtra(EXTRA_PASSWORD_ID, AutofillId.class);
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
int padding = dp(16);
root.setPadding(padding, padding, padding, padding);
TextView title = new TextView(this);
title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
title.setGravity(Gravity.START);
title.setPadding(0, 0, 0, dp(8));
title.setText(packageName == null || packageName.trim().isEmpty() ? "Search KeePassGO" : "Search KeePassGO for " + packageName);
root.addView(title, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
EditText search = new EditText(this);
search.setHint("Search entries");
root.addView(search, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
allEntries.clear();
allEntries.addAll(AutofillCacheStore.chooserCandidates(this, matchTarget));
visibleEntries.clear();
visibleEntries.addAll(allEntries);
adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, labelsFor(visibleEntries));
ListView list = new ListView(this);
list.setAdapter(adapter);
list.setOnItemClickListener(this::onEntrySelected);
TextView empty = new TextView(this);
empty.setPadding(0, dp(16), 0, 0);
empty.setText("No KeePassGO entries are available for autofill.");
empty.setVisibility(allEntries.isEmpty() ? View.VISIBLE : View.GONE);
list.setVisibility(allEntries.isEmpty() ? View.GONE : View.VISIBLE);
root.addView(empty, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
LinearLayout.LayoutParams listParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0
);
listParams.weight = 1f;
listParams.topMargin = dp(12);
root.addView(list, listParams);
search.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
filterEntries(s == null ? "" : s.toString());
}
@Override
public void afterTextChanged(Editable s) {
}
});
setContentView(root);
}
private void filterEntries(String rawQuery) {
String query = rawQuery == null ? "" : rawQuery.trim().toLowerCase(Locale.US);
visibleEntries.clear();
if (query.isEmpty()) {
visibleEntries.addAll(allEntries);
} else {
for (AutofillCacheStore.Entry entry : allEntries) {
if (entryLabel(entry).toLowerCase(Locale.US).contains(query)) {
visibleEntries.add(entry);
}
}
}
adapter.clear();
adapter.addAll(labelsFor(visibleEntries));
adapter.notifyDataSetChanged();
}
private void onEntrySelected(AdapterView<?> parent, View view, int position, long id) {
if (position < 0 || position >= visibleEntries.size() || passwordID == null) {
setResult(Activity.RESULT_CANCELED);
finish();
return;
}
AutofillCacheStore.Entry entry = visibleEntries.get(position);
if (matchTarget != null && matchTarget.startsWith("androidapp://")) {
AutofillBindingStore.rememberBinding(this, matchTarget, entry.id);
}
Dataset.Builder dataset = new Dataset.Builder();
dataset.setId(entry.id);
if (usernameID != null && entry.username != null && !entry.username.isEmpty()) {
dataset.setValue(usernameID, AutofillValue.forText(entry.username));
}
dataset.setValue(passwordID, AutofillValue.forText(entry.password));
Intent result = new Intent();
result.putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset.build());
setResult(Activity.RESULT_OK, result);
finish();
}
private static List<String> labelsFor(List<AutofillCacheStore.Entry> entries) {
List<String> labels = new ArrayList<>(entries.size());
for (AutofillCacheStore.Entry entry : entries) {
labels.add(entryLabel(entry));
}
return labels;
}
private static String entryLabel(AutofillCacheStore.Entry entry) {
StringBuilder label = new StringBuilder();
label.append(entry.title == null || entry.title.trim().isEmpty() ? "Untitled entry" : entry.title.trim());
if (entry.username != null && !entry.username.trim().isEmpty()) {
label.append(" (").append(entry.username.trim()).append(")");
}
if (entry.path != null && !entry.path.isEmpty()) {
label.append("").append(String.join(" / ", entry.path));
}
return label.toString();
}
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
}
@@ -1,6 +1,8 @@
package org.julianfamily.keepassgo; package org.julianfamily.keepassgo;
import android.app.PendingIntent;
import android.app.assist.AssistStructure; import android.app.assist.AssistStructure;
import android.content.Intent;
import android.os.CancellationSignal; import android.os.CancellationSignal;
import android.service.autofill.AutofillService; import android.service.autofill.AutofillService;
import android.service.autofill.Dataset; import android.service.autofill.Dataset;
@@ -66,29 +68,35 @@ public final class KeePassGOAutofillService extends AutofillService {
return; return;
} }
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget); AutofillCacheStore.Entry entry = findBoundEntry(target.matchTarget);
if (entry == null) {
List<AutofillCacheStore.Entry> candidates = AutofillCacheStore.relevantCandidates(this, target.matchTarget);
if (candidates.isEmpty()) {
FillResponse chooser = chooserResponse(target, fields);
if (chooser == null) {
Log.i(TAG, "no autofill cache match");
callback.onSuccess(null);
return;
}
Log.i(TAG, "returning searchable chooser dataset for " + target.matchTarget);
callback.onSuccess(chooser);
return;
}
if (candidates.size() > 1) {
Log.i(TAG, "returning " + candidates.size() + " scoped autofill datasets for " + target.matchTarget);
callback.onSuccess(candidatesFillResponse(candidates, fields));
return;
}
entry = candidates.get(0);
if (entry == null) { if (entry == null) {
Log.i(TAG, "no autofill cache match"); Log.i(TAG, "no autofill cache match");
callback.onSuccess(null); callback.onSuccess(null);
return; return;
} }
}
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host); Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1); FillResponse response = directFillResponse(entry, fields);
presentation.setTextViewText(
android.R.id.text1,
entry.title + " (" + entry.username + ")"
);
Dataset.Builder dataset = new Dataset.Builder(presentation);
if (fields.usernameId != null) {
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
}
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
FillResponse response = new FillResponse.Builder()
.addDataset(dataset.build())
.build();
Log.i(TAG, "returning dataset"); Log.i(TAG, "returning dataset");
callback.onSuccess(response); callback.onSuccess(response);
} catch (Exception err) { } catch (Exception err) {
@@ -103,6 +111,84 @@ public final class KeePassGOAutofillService extends AutofillService {
callback.onSuccess(); callback.onSuccess();
} }
private AutofillCacheStore.Entry findBoundEntry(String matchTarget) {
String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget);
if (!entryID.isEmpty()) {
AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID);
if (bound != null) {
return bound;
}
}
return null;
}
private FillResponse directFillResponse(AutofillCacheStore.Entry entry, ParsedFields fields) {
return new FillResponse.Builder()
.addDataset(datasetForEntry(entry, fields))
.build();
}
private FillResponse candidatesFillResponse(List<AutofillCacheStore.Entry> entries, ParsedFields fields) {
FillResponse.Builder response = new FillResponse.Builder();
for (AutofillCacheStore.Entry entry : entries) {
response.addDataset(datasetForEntry(entry, fields));
}
return response.build();
}
private Dataset datasetForEntry(AutofillCacheStore.Entry entry, ParsedFields fields) {
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
String label = entry.title == null || entry.title.trim().isEmpty() ? "KeePassGO entry" : entry.title;
if (entry.username != null && !entry.username.trim().isEmpty()) {
label += " (" + entry.username + ")";
}
presentation.setTextViewText(android.R.id.text1, label);
Dataset.Builder dataset = new Dataset.Builder(presentation);
dataset.setId(entry.id);
if (fields.usernameId != null) {
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
}
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
return dataset.build();
}
private FillResponse chooserResponse(ParsedTarget target, ParsedFields fields) {
if (fields.passwordId == null) {
return null;
}
List<AutofillCacheStore.Entry> candidates = AutofillCacheStore.chooserCandidates(this, target.matchTarget);
if (candidates.isEmpty()) {
return null;
}
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
presentation.setTextViewText(android.R.id.text1, "Search KeePassGO");
Intent intent = new Intent(this, KeePassGOAutofillPickerActivity.class);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_TARGET, target.matchTarget);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PACKAGE_NAME, target.packageName);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_USERNAME_ID, fields.usernameId);
intent.putExtra(KeePassGOAutofillPickerActivity.EXTRA_PASSWORD_ID, fields.passwordId);
PendingIntent pendingIntent = PendingIntent.getActivity(
this,
target.matchTarget.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE
);
AutofillId[] ids;
if (fields.usernameId != null) {
ids = new AutofillId[]{fields.usernameId, fields.passwordId};
} else {
ids = new AutofillId[]{fields.passwordId};
}
return new FillResponse.Builder()
.setAuthentication(ids, pendingIntent.getIntentSender(), presentation)
.build();
}
private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) { private static ParsedTarget parseWindow(AssistStructure structure, ParsedFields fields) {
String domain = ""; String domain = "";
final int windowCount = structure.getWindowNodeCount(); final int windowCount = structure.getWindowNodeCount();
@@ -1,6 +1,7 @@
package org.julianfamily.keepassgo; package org.julianfamily.keepassgo;
import android.app.Activity; import android.app.Activity;
import android.content.ClipData;
import android.content.Intent; import android.content.Intent;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
@@ -10,13 +11,18 @@ import android.util.Log;
import java.io.File; import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
public final class SharedVaultImportActivity extends Activity { public final class SharedVaultImportActivity extends Activity {
private static final String TAG = "KeePassGOImport"; private static final String TAG = "KeePassGOImport";
private static final String DEFAULT_NAME = "shared-vault.kdbx"; private static final String DEFAULT_NAME = "shared-vault.kdbx";
private static final String PENDING_SHARED_VAULT = "pending-shared-vault.kdbx";
private static final String PENDING_SHARED_VAULT_NAME = "pending-shared-vault-name.txt";
private static final String PENDING_SHARED_LOOKUP = "pending-shared-lookup.txt";
@Override @Override
protected void onCreate(Bundle state) { protected void onCreate(Bundle state) {
@@ -36,6 +42,17 @@ public final class SharedVaultImportActivity extends Activity {
} }
private void handleIntent(Intent intent) { private void handleIntent(Intent intent) {
logIntent(intent);
String sharedLookup = resolveSharedLookup(intent);
if (!sharedLookup.isEmpty()) {
try {
persistPendingLookup(sharedLookup);
Log.i(TAG, "queued shared lookup target");
} catch (IOException | RuntimeException err) {
Log.e(TAG, "failed to queue shared lookup target", err);
}
return;
}
Uri uri = resolveSharedUri(intent); Uri uri = resolveSharedUri(intent);
if (uri == null) { if (uri == null) {
Log.i(TAG, "no shared vault URI on intent"); Log.i(TAG, "no shared vault URI on intent");
@@ -55,21 +72,63 @@ public final class SharedVaultImportActivity extends Activity {
} }
String action = intent.getAction(); String action = intent.getAction();
if (Intent.ACTION_SEND.equals(action)) { if (Intent.ACTION_SEND.equals(action)) {
return intent.getParcelableExtra(Intent.EXTRA_STREAM); Uri extraStream = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (extraStream != null) {
return extraStream;
}
}
if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
ArrayList<Uri> streams = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
if (streams != null && !streams.isEmpty()) {
return streams.get(0);
}
} }
if (Intent.ACTION_VIEW.equals(action)) { if (Intent.ACTION_VIEW.equals(action)) {
return intent.getData(); Uri data = intent.getData();
if (data != null) {
return data;
}
}
ClipData clipData = intent.getClipData();
if (clipData != null && clipData.getItemCount() > 0) {
Uri clipUri = clipData.getItemAt(0).getUri();
if (clipUri != null) {
return clipUri;
}
} }
return null; return null;
} }
private String resolveSharedLookup(Intent intent) {
if (intent == null) {
return "";
}
String action = intent.getAction();
if (Intent.ACTION_SEND.equals(action) && "text/plain".equalsIgnoreCase(intent.getType())) {
CharSequence extraText = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
if (extraText != null) {
return extraText.toString().trim();
}
}
if (Intent.ACTION_VIEW.equals(action)) {
Uri data = intent.getData();
if (data != null) {
String scheme = data.getScheme();
if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) {
return data.toString();
}
}
}
return "";
}
private void persistPendingImport(Uri uri) throws IOException { private void persistPendingImport(Uri uri) throws IOException {
File dir = new File(getFilesDir(), "keepassgo"); File dir = new File(getFilesDir(), "keepassgo");
if (!dir.exists() && !dir.mkdirs()) { if (!dir.exists() && !dir.mkdirs()) {
throw new IOException("failed to create " + dir.getAbsolutePath()); throw new IOException("failed to create " + dir.getAbsolutePath());
} }
File pendingFile = new File(dir, "pending-shared-vault.kdbx"); File pendingFile = new File(dir, PENDING_SHARED_VAULT);
try (InputStream in = getContentResolver().openInputStream(uri)) { try (InputStream in = openSharedInputStream(uri)) {
if (in == null) { if (in == null) {
throw new IOException("failed to open shared vault stream"); throw new IOException("failed to open shared vault stream");
} }
@@ -82,12 +141,34 @@ public final class SharedVaultImportActivity extends Activity {
} }
} }
File nameFile = new File(dir, "pending-shared-vault-name.txt"); File nameFile = new File(dir, PENDING_SHARED_VAULT_NAME);
try (FileOutputStream out = new FileOutputStream(nameFile, false)) { try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8)); out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
} }
} }
private void persistPendingLookup(String lookup) throws IOException {
File dir = new File(getFilesDir(), "keepassgo");
if (!dir.exists() && !dir.mkdirs()) {
throw new IOException("failed to create " + dir.getAbsolutePath());
}
File pendingFile = new File(dir, PENDING_SHARED_LOOKUP);
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
out.write(lookup.getBytes(StandardCharsets.UTF_8));
}
}
private InputStream openSharedInputStream(Uri uri) throws IOException {
if ("file".equalsIgnoreCase(uri.getScheme())) {
String path = uri.getPath();
if (path == null || path.trim().isEmpty()) {
throw new IOException("file URI is missing a path");
}
return new FileInputStream(new File(path));
}
return getContentResolver().openInputStream(uri);
}
private String resolveDisplayName(Uri uri) { private String resolveDisplayName(Uri uri) {
String displayName = queryDisplayName(uri); String displayName = queryDisplayName(uri);
if (!displayName.isEmpty()) { if (!displayName.isEmpty()) {
@@ -123,6 +204,20 @@ public final class SharedVaultImportActivity extends Activity {
return ""; return "";
} }
private void logIntent(Intent intent) {
if (intent == null) {
return;
}
Log.i(TAG, "intent action=" + intent.getAction()
+ " type=" + intent.getType()
+ " data=" + intent.getData()
+ " flags=0x" + Integer.toHexString(intent.getFlags()));
ClipData clipData = intent.getClipData();
if (clipData != null) {
Log.i(TAG, "intent clip items=" + clipData.getItemCount());
}
}
private void launchMainActivity() { private void launchMainActivity() {
Intent launch = new Intent(); Intent launch = new Intent();
launch.setClassName(this, "org.gioui.GioActivity"); launch.setClassName(this, "org.gioui.GioActivity");
@@ -0,0 +1,269 @@
package org.julianfamily.keepassgo;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
public final class AutofillCacheStoreBehaviorTest {
public static void main(String[] args) {
testFindBestMatchUsesAndroidAppTargets();
testChooserCandidatesCollapseToExactAndroidAppMatch();
testChooserCandidatesStayScopedToExactHostMatches();
testChooserCandidatesStayScopedToParentHostMatches();
testCacheStoreChooserCandidatesStayScopedToExactHostMatches();
testCacheStoreChooserCandidatesFallBackToAllEntriesOnlyWhenUnrelated();
testCacheStoreRelevantCandidatesDoNotFallBackToAllEntries();
}
private static void testFindBestMatchUsesAndroidAppTargets() {
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
entries.add(new AutofillTargetMatcher.Entry(
"blink-entry",
"Blink",
"linuscaldwell",
"bellagio-stack",
"account.blinknetwork.com",
"https://account.blinknetwork.com",
Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"),
Arrays.asList("Crew", "Apps")
));
AutofillTargetMatcher.Entry got = AutofillTargetMatcher.findBestMatch(entries, "androidapp://com.blinknetwork.mobile2");
if (got == null || !"blink-entry".equals(got.id)) {
throw new AssertionError("findBestMatch(entries, androidapp target) = " + describe(got) + ", want blink-entry");
}
}
private static void testChooserCandidatesCollapseToExactAndroidAppMatch() {
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
entries.add(new AutofillTargetMatcher.Entry(
"blink-entry",
"Blink",
"linuscaldwell",
"bellagio-stack",
"account.blinknetwork.com",
"https://account.blinknetwork.com",
Arrays.asList("https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"),
Arrays.asList("Crew", "Apps")
));
entries.add(new AutofillTargetMatcher.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillTargetMatcher.Entry> got = AutofillTargetMatcher.chooserCandidates(entries, "androidapp://com.blinknetwork.mobile2");
if (got.size() != 1 || !"blink-entry".equals(got.get(0).id)) {
throw new AssertionError("chooserCandidates(entries, androidapp target) = " + describe(got) + ", want [blink-entry]");
}
}
private static void testChooserCandidatesStayScopedToExactHostMatches() {
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
entries.add(new AutofillTargetMatcher.Entry(
"bellagio-primary",
"Bellagio Primary",
"dannyocean",
"vault-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/login",
Arrays.asList("https://bellagio.example.invalid/login"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillTargetMatcher.Entry(
"bellagio-backup",
"Bellagio Backup",
"rustyryan",
"backup-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/admin",
Arrays.asList("https://bellagio.example.invalid/admin"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillTargetMatcher.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillTargetMatcher.Entry> got = AutofillTargetMatcher.chooserCandidates(entries, "https://bellagio.example.invalid/security");
if (got.size() != 2 || !containsIDs(got, "bellagio-primary", "bellagio-backup")) {
throw new AssertionError("chooserCandidates(entries, exact host) = " + describe(got) + ", want only Bellagio entries");
}
}
private static void testChooserCandidatesStayScopedToParentHostMatches() {
List<AutofillTargetMatcher.Entry> entries = new ArrayList<>();
entries.add(new AutofillTargetMatcher.Entry(
"bellagio-parent",
"Bellagio Parent",
"linuscaldwell",
"chip-stack",
"example.invalid",
"https://example.invalid/login",
Arrays.asList("https://example.invalid/login"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillTargetMatcher.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillTargetMatcher.Entry> got = AutofillTargetMatcher.chooserCandidates(entries, "https://bellagio.example.invalid/security");
if (got.size() != 1 || !"bellagio-parent".equals(got.get(0).id)) {
throw new AssertionError("chooserCandidates(entries, parent host) = " + describe(got) + ", want [bellagio-parent]");
}
}
private static void testCacheStoreChooserCandidatesStayScopedToExactHostMatches() {
List<AutofillCacheStore.Entry> entries = new ArrayList<>();
entries.add(new AutofillCacheStore.Entry(
"bellagio-primary",
"Bellagio Primary",
"dannyocean",
"vault-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/login",
Arrays.asList("https://bellagio.example.invalid/login"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillCacheStore.Entry(
"bellagio-backup",
"Bellagio Backup",
"rustyryan",
"backup-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/admin",
Arrays.asList("https://bellagio.example.invalid/admin"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillCacheStore.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillCacheStore.Entry> got = AutofillCacheStore.chooserCandidatesFromEntries(entries, "https://bellagio.example.invalid/security");
if (got.size() != 2 || !containsStoreIDs(got, "bellagio-primary", "bellagio-backup")) {
throw new AssertionError("AutofillCacheStore chooser candidates = " + describeStore(got) + ", want only Bellagio entries");
}
}
private static void testCacheStoreChooserCandidatesFallBackToAllEntriesOnlyWhenUnrelated() {
List<AutofillCacheStore.Entry> entries = new ArrayList<>();
entries.add(new AutofillCacheStore.Entry(
"bellagio-primary",
"Bellagio Primary",
"dannyocean",
"vault-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/login",
Arrays.asList("https://bellagio.example.invalid/login"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillCacheStore.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillCacheStore.Entry> got = AutofillCacheStore.chooserCandidatesFromEntries(entries, "https://tessio.example.invalid/login");
if (got.size() != 2 || !containsStoreIDs(got, "bellagio-primary", "night-fox-entry")) {
throw new AssertionError("AutofillCacheStore unrelated chooser candidates = " + describeStore(got) + ", want full fallback list");
}
}
private static void testCacheStoreRelevantCandidatesDoNotFallBackToAllEntries() {
List<AutofillCacheStore.Entry> entries = new ArrayList<>();
entries.add(new AutofillCacheStore.Entry(
"bellagio-primary",
"Bellagio Primary",
"dannyocean",
"vault-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/login",
Arrays.asList("https://bellagio.example.invalid/login"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillCacheStore.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillCacheStore.Entry> got = AutofillCacheStore.relevantCandidatesFromEntries(entries, "https://tessio.example.invalid/login");
if (!got.isEmpty()) {
throw new AssertionError("AutofillCacheStore relevant unrelated candidates = " + describeStore(got) + ", want []");
}
}
private static String describe(AutofillTargetMatcher.Entry entry) {
if (entry == null) {
return "null";
}
return entry.id;
}
private static String describe(List<AutofillTargetMatcher.Entry> entries) {
List<String> ids = new ArrayList<>();
for (AutofillTargetMatcher.Entry entry : entries) {
ids.add(entry.id);
}
return ids.toString();
}
private static boolean containsIDs(List<AutofillTargetMatcher.Entry> entries, String... wantIDs) {
List<String> ids = new ArrayList<>();
for (AutofillTargetMatcher.Entry entry : entries) {
ids.add(entry.id);
}
return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length;
}
private static String describeStore(List<AutofillCacheStore.Entry> entries) {
List<String> ids = new ArrayList<>();
for (AutofillCacheStore.Entry entry : entries) {
ids.add(entry.id);
}
return ids.toString();
}
private static boolean containsStoreIDs(List<AutofillCacheStore.Entry> entries, String... wantIDs) {
List<String> ids = new ArrayList<>();
for (AutofillCacheStore.Entry entry : entries) {
ids.add(entry.id);
}
return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length;
}
}
@@ -0,0 +1,30 @@
package org.julianfamily.keepassgo;
public final class AutofillFallbackTargetBehaviorTest {
public static void main(String[] args) {
testPrefersWebDomainWhenPresent();
testFallsBackToAndroidAppPackage();
testEmptyWhenNeitherSignalExists();
}
private static void testPrefersWebDomainWhenPresent() {
String got = AutofillFallbackTarget.resolve("com.android.chrome", "gitlab.com");
if (!"gitlab.com".equals(got)) {
throw new AssertionError("resolve(chrome, gitlab.com) = " + got + ", want gitlab.com");
}
}
private static void testFallsBackToAndroidAppPackage() {
String got = AutofillFallbackTarget.resolve("com.blinknetwork.mobile2", "");
if (!"androidapp://com.blinknetwork.mobile2".equals(got)) {
throw new AssertionError("resolve(package-only) = " + got + ", want androidapp://com.blinknetwork.mobile2");
}
}
private static void testEmptyWhenNeitherSignalExists() {
String got = AutofillFallbackTarget.resolve("", "");
if (!"".equals(got)) {
throw new AssertionError("resolve(empty) = " + got + ", want empty");
}
}
}
+239 -8
View File
@@ -3,7 +3,8 @@ const nativeHost = "com.keepassgo.browser";
const isNodeTestEnv = typeof module !== "undefined" && module.exports; const isNodeTestEnv = typeof module !== "undefined" && module.exports;
const usePromiseAPI = typeof globalThis.browser !== "undefined"; const usePromiseAPI = typeof globalThis.browser !== "undefined";
const defaultSettings = { const defaultSettings = {
bearerToken: "" bearerToken: "",
bestMatchOnly: false
}; };
const pageStatePrefix = "keepassgo-page-state:"; const pageStatePrefix = "keepassgo-page-state:";
const matchCacheTTL = 30 * 1000; const matchCacheTTL = 30 * 1000;
@@ -173,9 +174,10 @@ function connectNative(message) {
} }
async function loadSettings() { async function loadSettings() {
const stored = await storageGet(["bearerToken"]); const stored = await storageGet(["bearerToken", "bestMatchOnly"]);
return { return {
bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim() bearerToken: (stored.bearerToken || defaultSettings.bearerToken).trim(),
bestMatchOnly: Boolean(stored.bestMatchOnly ?? defaultSettings.bestMatchOnly)
}; };
} }
@@ -191,6 +193,120 @@ function cloneTarget(target) {
return target && typeof target === "object" ? { ...target } : null; return target && typeof target === "object" ? { ...target } : null;
} }
function cloneSavePlan(plan) {
if (!plan || typeof plan !== "object") {
return null;
}
return {
mode: plan.mode === "update" ? "update" : "save",
entryId: typeof plan.entryId === "string" ? plan.entryId : "",
title: typeof plan.title === "string" ? plan.title : "",
path: Array.isArray(plan.path) ? [...plan.path] : [],
username: typeof plan.username === "string" ? plan.username : "",
password: typeof plan.password === "string" ? plan.password : "",
url: typeof plan.url === "string" ? plan.url : ""
};
}
function normalizeObservedCredential(observed) {
if (!observed || typeof observed !== "object") {
return null;
}
const password = typeof observed.password === "string" ? observed.password.trim() : "";
const url = typeof observed.url === "string" ? observed.url.trim() : "";
if (!password || !url) {
return null;
}
return {
title: typeof observed.title === "string" ? observed.title.trim() : "",
username: typeof observed.username === "string" ? observed.username.trim() : "",
password,
url
};
}
function matchHost(rawURL) {
if (typeof rawURL !== "string") {
return "";
}
const trimmed = rawURL.trim();
if (!trimmed) {
return "";
}
try {
return new URL(trimmed).hostname.toLowerCase();
} catch (_error) {
return trimmed.replace(/^https?:\/\//i, "").replace(/\/.*$/, "").toLowerCase();
}
}
function qualityRank(quality) {
switch (String(quality || "").trim().toLowerCase()) {
case "exact":
return 0;
case "scheme":
return 1;
case "host":
return 2;
default:
return 3;
}
}
function applyBestMatchOnly(matches, enabled) {
if (!Array.isArray(matches) || !enabled) {
return Array.isArray(matches) ? [...matches] : [];
}
if (matches.length === 0) {
return [];
}
const bestRank = Math.min(...matches.map((match) => qualityRank(match?.quality)));
return matches.filter((match) => qualityRank(match?.quality) === bestRank);
}
function defaultObservedTitle(observed) {
if (observed?.title) {
return observed.title;
}
return matchHost(observed?.url) || "Browser Login";
}
function savePlanForObservedLogin(observed, matches) {
const normalized = normalizeObservedCredential(observed);
if (!normalized) {
return null;
}
const targetHost = matchHost(normalized.url);
const exact = Array.isArray(matches) ? matches.find((match) =>
typeof match?.id === "string" &&
String(match?.username || "").trim().toLowerCase() === normalized.username.toLowerCase() &&
matchHost(match?.url || "") === targetHost
) : null;
if (exact) {
return {
mode: "update",
entryId: exact.id,
title: exact.title || defaultObservedTitle(normalized),
path: Array.isArray(exact.path) ? [...exact.path] : [],
username: normalized.username,
password: normalized.password,
url: normalized.url
};
}
const fallbackPath = Array.isArray(matches) && matches.length > 0 && Array.isArray(matches[0]?.path)
? [...matches[0].path]
: [];
return {
mode: "save",
entryId: "",
title: defaultObservedTitle(normalized),
path: fallbackPath,
username: normalized.username,
password: normalized.password,
url: normalized.url
};
}
function normalizePageState(state) { function normalizePageState(state) {
return { return {
tabId: Number.isInteger(state?.tabId) ? state.tabId : null, tabId: Number.isInteger(state?.tabId) ? state.tabId : null,
@@ -207,6 +323,7 @@ function normalizePageState(state) {
pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "", pendingEntryId: typeof state?.pendingEntryId === "string" ? state.pendingEntryId : "",
pendingTarget: cloneTarget(state?.pendingTarget), pendingTarget: cloneTarget(state?.pendingTarget),
pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "", pendingMessage: typeof state?.pendingMessage === "string" ? state.pendingMessage : "",
pendingSave: cloneSavePlan(state?.pendingSave),
lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "", lastFilledEntryId: typeof state?.lastFilledEntryId === "string" ? state.lastFilledEntryId : "",
updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0 updatedAt: Number.isFinite(state?.updatedAt) ? state.updatedAt : 0
}; };
@@ -228,6 +345,7 @@ function defaultPageState(tabId, pageUrl) {
pendingEntryId: "", pendingEntryId: "",
pendingTarget: null, pendingTarget: null,
pendingMessage: "", pendingMessage: "",
pendingSave: null,
lastFilledEntryId: "", lastFilledEntryId: "",
updatedAt: 0 updatedAt: 0
}); });
@@ -292,6 +410,16 @@ function approvalHintForState(state) {
return state.pendingMessage || "Approve or deny the fill request in KeePassGO."; return state.pendingMessage || "Approve or deny the fill request in KeePassGO.";
} }
function shouldContinueWatchingState(state) {
if (!state?.pageHasLoginForm) {
return false;
}
if (state?.pendingFill) {
return true;
}
return Boolean(state?.status?.locked);
}
function schedulePendingPoll(tabId, pageUrl) { function schedulePendingPoll(tabId, pageUrl) {
if (!Number.isInteger(tabId)) { if (!Number.isInteger(tabId)) {
return; return;
@@ -337,6 +465,12 @@ function actionPresentationForState(state) {
badgeText = "!"; badgeText = "!";
color = "#9f5f0e"; color = "#9f5f0e";
title = approvalHintForState(state) || "KeePassGO approval needed for this page"; title = approvalHintForState(state) || "KeePassGO approval needed for this page";
} else if (state.pendingSave) {
badgeText = "S";
color = "#255f4a";
title = state.pendingSave.mode === "update"
? `KeePassGO can update ${state.pendingSave.title || "this login"}`
: "KeePassGO can save the submitted login";
} else if (!state.configured) { } else if (!state.configured) {
title = "Configure KeePassGO Browser in extension settings"; title = "Configure KeePassGO Browser in extension settings";
} else if (!state.success) { } else if (!state.success) {
@@ -492,7 +626,7 @@ async function refreshPageState(tabId, pageUrl, options = {}) {
state.matches = []; state.matches = [];
state.updatedAt = Date.now(); state.updatedAt = Date.now();
const saved = await setPageState(tabId, state); const saved = await setPageState(tabId, state);
if (saved.pendingFill) { if (shouldContinueWatchingState(saved)) {
schedulePendingPoll(tabId, resolvedURL); schedulePendingPoll(tabId, resolvedURL);
} else { } else {
clearPendingPoll(tabId); clearPendingPoll(tabId);
@@ -502,7 +636,7 @@ async function refreshPageState(tabId, pageUrl, options = {}) {
if (shouldReuseMatches(state, force)) { if (shouldReuseMatches(state, force)) {
const saved = await setPageState(tabId, state); const saved = await setPageState(tabId, state);
if (saved.pendingFill) { if (shouldContinueWatchingState(saved)) {
schedulePendingPoll(tabId, resolvedURL); schedulePendingPoll(tabId, resolvedURL);
} else { } else {
clearPendingPoll(tabId); clearPendingPoll(tabId);
@@ -524,12 +658,12 @@ async function refreshPageState(tabId, pageUrl, options = {}) {
pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0 pendingMessage: tokenPendingApprovalCount(matches?.status ?? state.status) > 0
? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO." ? approvalHintForState(state) || "Approve or deny the browser fill request in KeePassGO."
: "", : "",
matches: Array.isArray(matches?.matches) ? matches.matches : [], matches: applyBestMatchOnly(matches?.matches, settings.bestMatchOnly),
error: matches?.error ?? "", error: matches?.error ?? "",
updatedAt: Date.now() updatedAt: Date.now()
}; };
const saved = await setPageState(tabId, state); const saved = await setPageState(tabId, state);
if (saved.pendingFill) { if (shouldContinueWatchingState(saved)) {
schedulePendingPoll(tabId, resolvedURL); schedulePendingPoll(tabId, resolvedURL);
} else { } else {
clearPendingPoll(tabId); clearPendingPoll(tabId);
@@ -653,11 +787,65 @@ async function refreshActivePage(options = {}) {
return refreshPageState(page.tabId, page.url, options); return refreshPageState(page.tabId, page.url, options);
} }
async function saveObservedLogin(tabId, selectedMatch = null) {
if (!Number.isInteger(tabId)) {
throw new Error("No active tab is available.");
}
const tab = await tabsGet(tabId);
const pageUrl = typeof tab?.url === "string" ? tab.url : "";
let state = await getPageState(tabId, pageUrl);
const pendingSave = cloneSavePlan(state.pendingSave);
if (!pendingSave) {
throw new Error("There is no pending login to save.");
}
const request = {
action: "save-login",
title: pendingSave.title,
username: pendingSave.username,
password: pendingSave.password,
url: pendingSave.url
};
if (selectedMatch && typeof selectedMatch === "object") {
if (pendingSave.mode === "update" && typeof selectedMatch.id === "string" && selectedMatch.id.trim()) {
request.entryId = selectedMatch.id.trim();
request.title = String(selectedMatch.title || pendingSave.title || "").trim();
} else if (Array.isArray(selectedMatch.path) && selectedMatch.path.length > 0) {
request.path = [...selectedMatch.path];
}
} else if (pendingSave.mode === "update" && pendingSave.entryId) {
request.entryId = pendingSave.entryId;
} else if (pendingSave.path.length > 0) {
request.path = [...pendingSave.path];
}
const settings = await loadSettings();
if (!settings.bearerToken) {
throw new Error("API token is not configured.");
}
const response = await connectNative({
...request,
bearerToken: settings.bearerToken
});
if (!response?.success) {
throw new Error(response?.error || "KeePassGO did not save the submitted login.");
}
state = await setPageState(tabId, {
...state,
pendingSave: null,
error: "",
updatedAt: Date.now()
});
await refreshPageState(tabId, pageUrl, { force: true });
return { state };
}
const backgroundTestExports = { const backgroundTestExports = {
applyBestMatchOnly,
normalizePageState, normalizePageState,
actionPresentationForState, actionPresentationForState,
shouldReuseMatches, shouldReuseMatches,
shouldContinueWatchingState,
tokenPendingApprovalCount, tokenPendingApprovalCount,
savePlanForObservedLogin,
defaultSettings defaultSettings
}; };
@@ -692,11 +880,54 @@ if (isNodeTestEnv) {
return; return;
case "keepassgo-save-settings": case "keepassgo-save-settings":
await storageSet({ await storageSet({
bearerToken: String(message.settings?.bearerToken || "").trim() bearerToken: String(message.settings?.bearerToken || "").trim(),
bestMatchOnly: Boolean(message.settings?.bestMatchOnly)
}); });
await refreshActivePage({ force: true }).catch(() => null); await refreshActivePage({ force: true }).catch(() => null);
sendResponse({ success: true }); sendResponse({ success: true });
return; return;
case "keepassgo-search-logins": {
const settings = await loadSettings();
const response = await connectNative({
action: "search-logins",
bearerToken: settings.bearerToken,
query: String(message?.query || "").trim()
});
sendResponse({
success: Boolean(response?.success),
error: response?.error || "",
results: applyBestMatchOnly(response?.searchResults, settings.bestMatchOnly),
status: response?.status ?? null
});
return;
}
case "keepassgo-observed-login":
if (Number.isInteger(sender?.tab?.id)) {
const targetState = await getPageState(sender.tab.id, sender.tab.url || "");
const nextSave = savePlanForObservedLogin(message.observed, targetState.matches);
sendResponse(await setPageState(sender.tab.id, {
...targetState,
pendingSave: nextSave,
updatedAt: Date.now()
}));
return;
}
sendResponse({ success: false, error: "No active tab is available." });
return;
case "keepassgo-save-login": {
const targetTabID = Number.isInteger(message?.tabId)
? message.tabId
: (Number.isInteger(sender?.tab?.id) ? sender.tab.id : (await activePageContext()).tabId);
const selectedMatch = message?.selectedMatch && typeof message.selectedMatch === "object"
? {
id: String(message.selectedMatch.id || "").trim(),
title: String(message.selectedMatch.title || "").trim(),
path: Array.isArray(message.selectedMatch.path) ? message.selectedMatch.path : []
}
: null;
sendResponse({ success: true, ...(await saveObservedLogin(targetTabID, selectedMatch)) });
return;
}
case "keepassgo-page-ready": case "keepassgo-page-ready":
if (Number.isInteger(sender?.tab?.id)) { if (Number.isInteger(sender?.tab?.id)) {
sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, { sendResponse(await refreshPageState(sender.tab.id, sender.tab.url, {
+97
View File
@@ -49,6 +49,103 @@ test("tokenPendingApprovalCount reads token-scoped approval state", () => {
assert.equal(background.tokenPendingApprovalCount({}), 0); assert.equal(background.tokenPendingApprovalCount({}), 0);
}); });
test("shouldContinueWatchingState keeps polling locked login pages", () => {
assert.equal(background.shouldContinueWatchingState({
pageHasLoginForm: true,
pendingFill: false,
status: { locked: true }
}), true);
assert.equal(background.shouldContinueWatchingState({
pageHasLoginForm: true,
pendingFill: true,
status: { locked: false }
}), true);
assert.equal(background.shouldContinueWatchingState({
pageHasLoginForm: true,
pendingFill: false,
status: { locked: false }
}), false);
});
test("default settings include a blank bearer token that can be overridden by harness patching", () => { test("default settings include a blank bearer token that can be overridden by harness patching", () => {
assert.equal(background.defaultSettings.bearerToken, ""); assert.equal(background.defaultSettings.bearerToken, "");
assert.equal(background.defaultSettings.bestMatchOnly, false);
});
test("savePlanForObservedLogin prefers updating an exact username match", () => {
const plan = background.savePlanForObservedLogin({
username: "dannyocean",
password: "bellagio-safe",
url: "https://vault.example.invalid/login"
}, [
{
id: "vault-console",
title: "Vault Console",
username: "dannyocean",
url: "vault.example.invalid",
path: ["Crew", "Internet"]
},
{
id: "bellagio-backup",
title: "Bellagio Backup",
username: "rustyryan",
url: "vault.example.invalid",
path: ["Crew", "Internet"]
}
]);
assert.deepEqual(plan, {
mode: "update",
entryId: "vault-console",
title: "Vault Console",
path: ["Crew", "Internet"],
username: "dannyocean",
password: "bellagio-safe",
url: "https://vault.example.invalid/login"
});
});
test("savePlanForObservedLogin falls back to saving into the current page group", () => {
const plan = background.savePlanForObservedLogin({
username: "linuscaldwell",
password: "yellow-chip",
url: "https://vault.example.invalid/login"
}, [
{
id: "vault-console",
title: "Vault Console",
username: "dannyocean",
url: "vault.example.invalid",
path: ["Crew", "Internet"]
}
]);
assert.deepEqual(plan, {
mode: "save",
entryId: "",
title: "vault.example.invalid",
path: ["Crew", "Internet"],
username: "linuscaldwell",
password: "yellow-chip",
url: "https://vault.example.invalid/login"
});
});
test("applyBestMatchOnly keeps only the strongest quality band when enabled", () => {
const filtered = background.applyBestMatchOnly([
{ id: "livingston", title: "Livingston Dell", quality: "exact" },
{ id: "rusty", title: "Rusty Ryan", quality: "host" },
{ id: "linus", title: "Linus Caldwell", quality: "scheme" }
], true);
assert.deepEqual(filtered.map((match) => match.id), ["livingston"]);
});
test("applyBestMatchOnly preserves all matches when disabled", () => {
const filtered = background.applyBestMatchOnly([
{ id: "livingston", title: "Livingston Dell", quality: "exact" },
{ id: "rusty", title: "Rusty Ryan", quality: "host" }
], false);
assert.deepEqual(filtered.map((match) => match.id), ["livingston", "rusty"]);
}); });
+247 -24
View File
@@ -36,21 +36,138 @@ function normalizeRole(rawRole) {
switch (String(rawRole || "").trim().toLowerCase()) { switch (String(rawRole || "").trim().toLowerCase()) {
case "password": case "password":
return "password"; return "password";
default: case "username":
return "username"; return "username";
default:
return "";
} }
} }
function lowerJoined(values) {
return values
.map((value) => String(value || "").trim().toLowerCase())
.filter(Boolean)
.join(" ");
}
function fieldHintText(input) {
if (!input || typeof input !== "object") {
return "";
}
const labels = input.labels ? Array.from(input.labels).map((label) => label.textContent || "") : [];
return lowerJoined([
input.getAttribute?.("type"),
input.getAttribute?.("name"),
input.getAttribute?.("id"),
input.autocomplete,
input.getAttribute?.("autocomplete"),
input.getAttribute?.("placeholder"),
input.getAttribute?.("aria-label"),
...labels
]);
}
function textLikeInputType(type) {
switch (String(type || "").toLowerCase()) {
case "":
case "text":
case "email":
case "tel":
case "number":
return true;
default:
return false;
}
}
function hintMatches(text, patterns) {
return patterns.some((pattern) => pattern.test(text));
}
function scopeHintText(scope) {
const isDocumentScope = typeof document !== "undefined" && scope === document;
if ((!scope || typeof scope !== "object") || (!isDocumentScope && typeof scope.querySelectorAll !== "function" && typeof scope.getAttribute !== "function")) {
return "";
}
const attrText = isDocumentScope ? "" : lowerJoined([
scope.getAttribute?.("id"),
scope.getAttribute?.("name"),
scope.getAttribute?.("class"),
scope.getAttribute?.("action"),
scope.getAttribute?.("aria-label")
]);
const headingText = lowerJoined(Array.from(scope.querySelectorAll?.("button, h1, h2, h3, h4, legend, label, [role='button']") || [])
.slice(0, 8)
.map((element) => element.textContent || ""));
return lowerJoined([attrText, headingText]);
}
function hasAuthFlowSignals(usernameInput, scope) {
if (usernameInput) {
return true;
}
return hintMatches(scopeHintText(scope), authScopePatterns);
}
const usernameHintPatterns = [
/\buser(name|id)?\b/,
/\blog[\s_-]?in\b/,
/\bsign[\s_-]?in\b/,
/\bemail\b/,
/\be-mail\b/,
/\baccount\b/,
/\bmember\b/,
/\bidentifier\b/
];
const nonLoginHintPatterns = [
/\bsearch\b/,
/\bquery\b/,
/\bfilter\b/,
/\bcomment\b/,
/\bmessage\b/,
/\bcontact\b/,
/\bcity\b/,
/\bstate\b/,
/\bpostal\b/,
/\bzip\b/,
/\bcoupon\b/,
/\bpromo\b/,
/\bnewsletter\b/,
/\bsubscribe\b/
];
const authScopePatterns = [
/\blog[\s_-]?in\b/,
/\bsign[\s_-]?in\b/,
/\bauth\b/,
/\bpassword\b/,
/\bpasscode\b/,
/\b2fa\b/,
/\btwo[\s-]?factor\b/,
/\bverify\b/,
/\baccount\b/
];
function describeFieldRole(input) { function describeFieldRole(input) {
const type = String(input?.getAttribute?.("type") || "").toLowerCase(); const type = String(input?.getAttribute?.("type") || "").toLowerCase();
if (type === "password") { if (type === "password") {
return "password"; return "password";
} }
const autocomplete = String(input?.autocomplete || "").toLowerCase(); if (!textLikeInputType(type)) {
if (autocomplete.includes("username") || autocomplete.includes("email")) { return "";
}
const hints = fieldHintText(input);
if (!hints) {
return "";
}
if (hintMatches(hints, nonLoginHintPatterns)) {
return "";
}
if (hintMatches(hints, usernameHintPatterns)) {
return "username"; return "username";
} }
return "username"; return "";
} }
function isUsernameCandidate(input) { function isUsernameCandidate(input) {
@@ -102,6 +219,40 @@ function firstVisibleUsername(scope) {
return visibleInputs(scope).find(isUsernameCandidate) || null; return visibleInputs(scope).find(isUsernameCandidate) || null;
} }
function authFlowCandidate(anchorInput) {
const scope = (typeof HTMLFormElement !== "undefined" && anchorInput?.form instanceof HTMLFormElement ? anchorInput.form : document);
const scopeInputs = resolveFormInputs(anchorInput);
const passwordInput = scopeInputs.find(isPasswordCandidate) || null;
if (!passwordInput) {
return null;
}
const associated = associatedFieldsForAnchor(anchorInput || passwordInput);
if (!hasAuthFlowSignals(associated.usernameInput, scope)) {
return null;
}
return {
usernameInput: associated.usernameInput,
passwordInput,
anchorInput: anchorInput || passwordInput,
scope
};
}
function loginCandidates() {
const candidates = [];
for (const passwordInput of visibleInputs(document).filter(isPasswordCandidate)) {
const candidate = authFlowCandidate(passwordInput);
if (!candidate) {
continue;
}
if (candidates.some((existing) => existing.passwordInput === candidate.passwordInput)) {
continue;
}
candidates.push(candidate);
}
return candidates;
}
function associatedFieldsForAnchor(anchorInput) { function associatedFieldsForAnchor(anchorInput) {
const scopeInputs = resolveFormInputs(anchorInput); const scopeInputs = resolveFormInputs(anchorInput);
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document); const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
@@ -121,11 +272,11 @@ function associatedFieldsForAnchor(anchorInput) {
} }
function buildFieldDescriptor(input, role) { function buildFieldDescriptor(input, role) {
if (!(input instanceof HTMLInputElement)) { if (typeof HTMLInputElement === "undefined" || !(input instanceof HTMLInputElement)) {
return null; return null;
} }
const normalizedRole = normalizeRole(role || describeFieldRole(input)); const normalizedRole = normalizeRole(role || describeFieldRole(input));
const form = input.form instanceof HTMLFormElement ? input.form : null; const form = typeof HTMLFormElement !== "undefined" && input.form instanceof HTMLFormElement ? input.form : null;
const scope = form || document; const scope = form || document;
const inputs = visibleInputs(scope); const inputs = visibleInputs(scope);
const fieldIndex = inputs.indexOf(input); const fieldIndex = inputs.indexOf(input);
@@ -145,6 +296,9 @@ function resolveFieldDescriptor(descriptor) {
return null; return null;
} }
const normalizedRole = normalizeRole(descriptor.role); const normalizedRole = normalizeRole(descriptor.role);
if (!normalizedRole) {
return null;
}
const forms = Array.from(document.forms || []); const forms = Array.from(document.forms || []);
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null; const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
const scope = form || document; const scope = form || document;
@@ -200,20 +354,22 @@ function chooseFillTargets(targetDescriptor) {
function scanLoginFields() { function scanLoginFields() {
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null; const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null; const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
const targets = chooseFillTargets(buildFieldDescriptor(activeUsable, describeFieldRole(activeUsable))); const explicitRole = describeFieldRole(activeUsable);
const anchorInput = activeUsable || targets.passwordInput || targets.usernameInput; const activeTargets = activeUsable ? authFlowCandidate(activeUsable) : null;
const focusTarget = buildFieldDescriptor(anchorInput, describeFieldRole(anchorInput)); const candidates = loginCandidates();
const allVisible = visibleInputs(document); const chosen = activeTargets || candidates[0] || null;
const roles = allVisible const anchorInput = activeUsable || chosen?.passwordInput || chosen?.usernameInput || null;
.filter((input) => isUsernameCandidate(input) || isPasswordCandidate(input)) const focusRole = explicitRole || describeFieldRole(anchorInput);
.map((input) => { const focusTarget = anchorInput ? buildFieldDescriptor(anchorInput, focusRole) : null;
const descriptor = buildFieldDescriptor(input, describeFieldRole(input)); const roles = candidates.map((candidate) => {
return `${descriptor.formIndex}:${descriptor.fieldIndex}:${descriptor.role}`; const passwordDescriptor = buildFieldDescriptor(candidate.passwordInput, "password");
const usernameDescriptor = candidate.usernameInput ? buildFieldDescriptor(candidate.usernameInput, "username") : null;
return `${passwordDescriptor.formIndex}:${passwordDescriptor.fieldIndex}:password:${usernameDescriptor ? `${usernameDescriptor.formIndex}:${usernameDescriptor.fieldIndex}` : "-"}`;
}); });
return { return {
pageHasLoginForm: Boolean(targets.usernameInput || targets.passwordInput), pageHasLoginForm: Boolean(chosen),
usernameInput: targets.usernameInput, usernameInput: chosen?.usernameInput || null,
passwordInput: targets.passwordInput, passwordInput: chosen?.passwordInput || null,
anchorInput, anchorInput,
focusTarget, focusTarget,
signature: roles.join("|") signature: roles.join("|")
@@ -240,6 +396,22 @@ function fillCredential(credential, targetDescriptor) {
return { ok: true }; return { ok: true };
} }
function submittedCredential(candidate, rawURL) {
if (!candidate?.passwordInput) {
return null;
}
const password = String(candidate.passwordInput.value || "").trim();
if (!password) {
return null;
}
return {
title: domainLabel(rawURL),
username: String(candidate.usernameInput?.value || "").trim(),
password,
url: String(rawURL || "").trim()
};
}
function domainLabel(rawURL) { function domainLabel(rawURL) {
try { try {
return new URL(rawURL).host || ""; return new URL(rawURL).host || "";
@@ -265,14 +437,15 @@ function inlineMatchSummary(match) {
return parts.join(" · ") || "No username"; return parts.join(" · ") || "No username";
} }
function shouldShowInlineOverlay(state, hasTarget, suppressed) { function shouldShowInlineOverlay(state, hasTarget, suppressed, idleHidden) {
if (suppressed || !hasTarget) { if (suppressed || idleHidden || !hasTarget) {
return false; return false;
} }
return Boolean( return Boolean(
state?.pageHasLoginForm && state?.pageHasLoginForm &&
( (
state?.pendingFill || state?.pendingFill ||
(state?.configured && state?.success && state?.status?.locked) ||
(state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0) (state?.configured && state?.success && !state?.status?.locked && Array.isArray(state?.matches) && state.matches.length > 0)
) )
); );
@@ -286,7 +459,12 @@ const contentTestExports = {
chooseFillTargets, chooseFillTargets,
inlineMatchSummary, inlineMatchSummary,
domainLabel, domainLabel,
shouldShowInlineOverlay shouldShowInlineOverlay,
fieldHintText,
scopeHintText,
hasAuthFlowSignals,
authFlowCandidate,
submittedCredential
}; };
if (isNodeTestEnv) { if (isNodeTestEnv) {
@@ -303,7 +481,9 @@ if (isNodeTestEnv) {
}; };
let chooserOpen = false; let chooserOpen = false;
let inlineSuppressed = false; let inlineSuppressed = false;
let inlineIdleHidden = false;
let refreshTimer = null; let refreshTimer = null;
let idleHideTimer = null;
let lastReportedSignature = ""; let lastReportedSignature = "";
let lastReportedTarget = ""; let lastReportedTarget = "";
@@ -464,6 +644,24 @@ if (isNodeTestEnv) {
dock.dataset.open = "false"; dock.dataset.open = "false";
} }
function clearIdleHideTimer() {
if (idleHideTimer !== null) {
clearTimeout(idleHideTimer);
idleHideTimer = null;
}
}
function refreshInlineLifetime(shouldShow) {
clearIdleHideTimer();
if (!shouldShow || chooserOpen || pageState.pendingFill) {
return;
}
idleHideTimer = window.setTimeout(() => {
inlineIdleHidden = true;
hideDock();
}, 15000);
}
function positionDock() { function positionDock() {
const anchor = currentTarget(); const anchor = currentTarget();
if (!anchor || dock.style.display === "none") { if (!anchor || dock.style.display === "none") {
@@ -537,19 +735,23 @@ if (isNodeTestEnv) {
function renderInlineState() { function renderInlineState() {
const target = currentTarget(); const target = currentTarget();
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed); const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed, inlineIdleHidden);
if (!shouldShow) { if (!shouldShow) {
clearIdleHideTimer();
hideDock(); hideDock();
return; return;
} }
ensureRootMounted(); ensureRootMounted();
dock.style.display = "block"; dock.style.display = "block";
trigger.dataset.tone = pageState.pendingFill ? "warning" : (pageState.error ? "error" : "ready"); trigger.dataset.tone = pageState.pendingFill || pageState.status?.locked ? "warning" : (pageState.error ? "error" : "ready");
if (pageState.pendingFill) { if (pageState.pendingFill) {
meta.textContent = "Approval needed in KeePassGO"; meta.textContent = "Approval needed in KeePassGO";
panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO."; panelCopy.textContent = pageState.pendingMessage || "Approve or deny the fill request in KeePassGO.";
} else if (pageState.status?.locked) {
meta.textContent = "Unlock KeePassGO";
panelCopy.textContent = "Unlock KeePassGO to turn this field back into live login suggestions.";
} else { } else {
const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0; const count = Array.isArray(pageState.matches) ? pageState.matches.length : 0;
meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`; meta.textContent = count === 1 ? "1 login ready" : `${count} logins ready`;
@@ -558,17 +760,21 @@ if (isNodeTestEnv) {
dock.dataset.open = chooserOpen ? "true" : "false"; dock.dataset.open = chooserOpen ? "true" : "false";
renderMatches(); renderMatches();
positionDock(); positionDock();
refreshInlineLifetime(shouldShow);
} }
function reportFieldState(force) { function reportFieldState(force) {
const scan = scanLoginFields(); const scan = scanLoginFields();
const nextTarget = JSON.stringify(scan.focusTarget || null);
if (scan.signature !== lastReportedSignature || nextTarget !== lastReportedTarget) {
inlineIdleHidden = false;
}
pageState = { pageState = {
...pageState, ...pageState,
pageHasLoginForm: scan.pageHasLoginForm, pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget focusTarget: scan.focusTarget
}; };
renderInlineState(); renderInlineState();
const nextTarget = JSON.stringify(scan.focusTarget || null);
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) { if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
return; return;
} }
@@ -616,6 +822,23 @@ if (isNodeTestEnv) {
scheduleRefresh(false); scheduleRefresh(false);
}, true); }, true);
document.addEventListener("submit", (event) => {
const form = event.target instanceof HTMLFormElement ? event.target : null;
if (!form) {
return;
}
const passwordInput = visibleInputs(form).find(isPasswordCandidate) || null;
const candidate = passwordInput ? authFlowCandidate(passwordInput) : null;
const observed = submittedCredential(candidate, window.location.href);
if (!observed) {
return;
}
void runtimeSend({
type: "keepassgo-observed-login",
observed
}).catch(() => null);
}, true);
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (!root.contains(event.target)) { if (!root.contains(event.target)) {
chooserOpen = false; chooserOpen = false;
+104 -1
View File
@@ -18,6 +18,68 @@ test("domainLabel tolerates invalid URLs", () => {
assert.equal(content.domainLabel("not-a-url"), ""); assert.equal(content.domainLabel("not-a-url"), "");
}); });
test("describeFieldRole only treats explicit account fields as usernames", () => {
const loginField = {
autocomplete: "username",
labels: [],
getAttribute(name) {
const attrs = {
type: "email",
id: "crew-email",
name: "email",
placeholder: "Email address",
"aria-label": "Email address"
};
return attrs[name] || "";
}
};
const searchField = {
autocomplete: "",
labels: [],
getAttribute(name) {
const attrs = {
type: "text",
id: "site-search",
name: "query",
placeholder: "Search casino news",
"aria-label": "Search"
};
return attrs[name] || "";
}
};
assert.equal(content.describeFieldRole(loginField), "username");
assert.equal(content.describeFieldRole(searchField), "");
});
test("hasAuthFlowSignals rejects generic password scopes and accepts sign-in scopes", () => {
const genericScope = {
getAttribute() {
return "";
},
querySelectorAll() {
return [{ textContent: "Confirm shipment" }];
}
};
const signInScope = {
getAttribute(name) {
const attrs = {
id: "signin-panel",
name: "signin",
action: "/session"
};
return attrs[name] || "";
},
querySelectorAll() {
return [{ textContent: "Sign in to the Bellagio vault" }];
}
};
assert.equal(content.hasAuthFlowSignals(null, genericScope), false);
assert.equal(content.hasAuthFlowSignals(null, signInScope), true);
assert.equal(content.hasAuthFlowSignals({ id: "danny-ocean" }, genericScope), true);
});
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => { test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
const state = { const state = {
pageHasLoginForm: true, pageHasLoginForm: true,
@@ -29,5 +91,46 @@ test("shouldShowInlineOverlay hides the page overlay after it is suppressed", ()
}; };
assert.equal(content.shouldShowInlineOverlay(state, true, false), true); assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
assert.equal(content.shouldShowInlineOverlay(state, true, true), false); assert.equal(content.shouldShowInlineOverlay(state, true, true, false), false);
});
test("shouldShowInlineOverlay stays visible for locked login pages", () => {
const state = {
pageHasLoginForm: true,
configured: true,
success: true,
status: { locked: true },
matches: [],
pendingFill: false
};
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
});
test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => {
const state = {
pageHasLoginForm: true,
configured: true,
success: true,
status: { locked: false },
matches: [{ id: "rusty-ryan" }],
pendingFill: false
};
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false);
});
test("submittedCredential captures the pending browser save payload from a login candidate", () => {
const candidate = {
usernameInput: { value: "linuscaldwell" },
passwordInput: { value: "yellow-chip" }
};
assert.deepEqual(content.submittedCredential(candidate, "https://bellagio.example.invalid/login"), {
title: "bellagio.example.invalid",
username: "linuscaldwell",
password: "yellow-chip",
url: "https://bellagio.example.invalid/login"
});
}); });
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "KeePassGO Browser", "name": "KeePassGO Browser",
"version": "0.1.0", "version": "0.8.2",
"description": "Fill credentials from KeePassGO on sign-in pages.", "description": "Fill credentials from KeePassGO on sign-in pages.",
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"], "permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
"host_permissions": ["http://*/*", "https://*/*"], "host_permissions": ["http://*/*", "https://*/*"],
+20 -2
View File
@@ -1,8 +1,15 @@
{ {
"manifest_version": 2, "manifest_version": 2,
"name": "KeePassGO Browser", "name": "KeePassGO Browser",
"version": "0.1.0", "version": "0.8.2",
"description": "Fill credentials from KeePassGO on sign-in pages.", "description": "Fill credentials from KeePassGO on sign-in pages.",
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"96": "icons/icon-96.png",
"128": "icons/icon-128.png"
},
"permissions": [ "permissions": [
"activeTab", "activeTab",
"nativeMessaging", "nativeMessaging",
@@ -16,6 +23,10 @@
}, },
"browser_action": { "browser_action": {
"default_title": "KeePassGO Browser", "default_title": "KeePassGO Browser",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png"
},
"default_popup": "popup.html" "default_popup": "popup.html"
}, },
"options_ui": { "options_ui": {
@@ -31,7 +42,14 @@
], ],
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "browser@keepassgo.com" "id": "browser@keepassgo.com",
"data_collection_permissions": {
"required": ["authenticationInfo", "websiteActivity"]
},
"strict_min_version": "140.0"
},
"gecko_android": {
"strict_min_version": "142.0"
} }
} }
} }
+7
View File
@@ -19,6 +19,13 @@
<span>API token</span> <span>API token</span>
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea> <textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
</label> </label>
<fieldset>
<legend>Browser Matching</legend>
<label class="checkbox-row">
<input id="best-match-only" name="best-match-only" type="checkbox">
<span>Best match only</span>
</label>
</fieldset>
<div class="actions"> <div class="actions">
<button type="submit">Save</button> <button type="submit">Save</button>
</div> </div>
+3 -1
View File
@@ -23,6 +23,7 @@ async function loadSettings() {
throw new Error(response?.error || "Could not load settings."); throw new Error(response?.error || "Could not load settings.");
} }
document.getElementById("bearer-token").value = response.settings.bearerToken || ""; document.getElementById("bearer-token").value = response.settings.bearerToken || "";
document.getElementById("best-match-only").checked = Boolean(response.settings.bestMatchOnly);
} }
async function saveSettings(event) { async function saveSettings(event) {
@@ -33,7 +34,8 @@ async function saveSettings(event) {
const response = await runtimeSend({ const response = await runtimeSend({
type: "keepassgo-save-settings", type: "keepassgo-save-settings",
settings: { settings: {
bearerToken: document.getElementById("bearer-token").value bearerToken: document.getElementById("bearer-token").value,
bestMatchOnly: document.getElementById("best-match-only").checked
} }
}); });
if (!response?.success) { if (!response?.success) {
+15
View File
@@ -20,10 +20,25 @@
<p id="status-message" class="subtle">Checking KeePassGO.</p> <p id="status-message" class="subtle">Checking KeePassGO.</p>
</section> </section>
<p id="page-hint" class="inline-hint subtle">Loading page state.</p> <p id="page-hint" class="inline-hint subtle">Loading page state.</p>
<section id="save-card" class="save-card" hidden>
<div>
<h2>Save Submitted Login</h2>
<p id="save-message" class="subtle">KeePassGO can save this login.</p>
</div>
<button id="save-action" type="button">Save Login</button>
</section>
<section> <section>
<h2>Matches</h2> <h2>Matches</h2>
<div id="matches" class="match-list"></div> <div id="matches" class="match-list"></div>
</section> </section>
<section class="search-section">
<h2>Search Vault</h2>
<form id="search-form" class="search-form">
<input id="search-query" type="search" placeholder="Search entries" autocomplete="off">
<button type="submit">Search</button>
</form>
<div id="search-results" class="match-list"></div>
</section>
</main> </main>
<script src="popup.js"></script> <script src="popup.js"></script>
</body> </body>
+135 -9
View File
@@ -43,21 +43,25 @@ function matchSubtitle(match) {
return parts.join(" · ") || "No username"; return parts.join(" · ") || "No username";
} }
function renderMatches(state) { function saveCardLabel(pendingSave) {
const root = document.getElementById("matches"); return pendingSave?.mode === "update"
? `Update ${pendingSave.title || "Login"}`
: "Save Login";
}
function renderMatchList(root, matches, options = {}) {
const targetTabID = popupTabID(); const targetTabID = popupTabID();
const emptyMessage = options.emptyMessage || "No matching entries.";
root.textContent = ""; root.textContent = "";
if (!Array.isArray(state.matches) || state.matches.length === 0) { if (!Array.isArray(matches) || matches.length === 0) {
const empty = document.createElement("p"); const empty = document.createElement("p");
empty.className = "subtle"; empty.className = "subtle";
empty.textContent = state.pageHasLoginForm empty.textContent = emptyMessage;
? "No matching entries for this page."
: "No login fields detected on this page.";
root.appendChild(empty); root.appendChild(empty);
return; return;
} }
for (const match of state.matches) { for (const match of matches) {
const row = document.createElement("button"); const row = document.createElement("button");
row.type = "button"; row.type = "button";
row.className = "match-row"; row.className = "match-row";
@@ -77,8 +81,11 @@ function renderMatches(state) {
row.appendChild(quality); row.appendChild(quality);
row.addEventListener("click", async () => { row.addEventListener("click", async () => {
row.disabled = true; row.disabled = true;
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
try { try {
if (typeof options.onSelect === "function") {
await options.onSelect(match, targetTabID);
} else {
setStatus("Approval may be required", "KeePassGO will prompt if this token needs approval before fill.", "warning");
const result = await runtimeSend({ const result = await runtimeSend({
type: "keepassgo-fill-entry", type: "keepassgo-fill-entry",
entryId: match.id, entryId: match.id,
@@ -88,8 +95,9 @@ function renderMatches(state) {
throw new Error(result?.error || "Fill failed."); throw new Error(result?.error || "Fill failed.");
} }
setStatus("Filled", `${match.title} was sent to the current page.`, "ready"); setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
}
} catch (error) { } catch (error) {
setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error"); setStatus(options.onSelect ? "Save failed" : "Fill failed", error instanceof Error ? error.message : String(error), "error");
} finally { } finally {
row.disabled = false; row.disabled = false;
} }
@@ -98,6 +106,51 @@ function renderMatches(state) {
} }
} }
function renderMatches(state) {
const emptyMessage = state.pageHasLoginForm
? "No matching entries for this page."
: "No login fields detected on this page.";
const root = document.getElementById("matches");
if (state.pendingSave) {
renderMatchList(root, state.matches, {
emptyMessage,
onSelect: async (match, targetTabID) => {
const result = await runtimeSend({
type: "keepassgo-save-login",
tabId: targetTabID,
selectedMatch: {
id: match.id,
title: match.title,
path: Array.isArray(match.path) ? match.path : []
}
});
if (!result?.success) {
throw new Error(result?.error || "Save failed.");
}
setStatus("Saved", `${state.pendingSave.title || "Login"} is now in KeePassGO.`, "ready");
document.getElementById("save-card").hidden = true;
}
});
return;
}
renderMatchList(root, state.matches, { emptyMessage });
}
function renderSearchResults(results, query) {
const root = document.getElementById("search-results");
if (!query) {
root.textContent = "";
const hint = document.createElement("p");
hint.className = "subtle";
hint.textContent = "Search all entries you can access with this token.";
root.appendChild(hint);
return;
}
renderMatchList(root, results, {
emptyMessage: `No entries matched "${query}".`
});
}
function renderPageHint(state) { function renderPageHint(state) {
const hint = document.getElementById("page-hint"); const hint = document.getElementById("page-hint");
if (state.pendingFill) { if (state.pendingFill) {
@@ -115,6 +168,46 @@ function renderPageHint(state) {
hint.textContent = "Open a sign-in page to see KeePassGO suggestions here."; hint.textContent = "Open a sign-in page to see KeePassGO suggestions here.";
} }
function renderPendingSave(state) {
const card = document.getElementById("save-card");
const message = document.getElementById("save-message");
const action = document.getElementById("save-action");
const pendingSave = state.pendingSave;
if (!pendingSave) {
card.hidden = true;
action.onclick = null;
return;
}
card.hidden = false;
action.textContent = saveCardLabel(pendingSave);
if (pendingSave.mode === "update") {
message.textContent = `KeePassGO can update ${pendingSave.title || "this login"} with the submitted password.`;
} else if (Array.isArray(pendingSave.path) && pendingSave.path.length > 0) {
message.textContent = `KeePassGO can save this login in ${pendingSave.path.join(" / ")}. Search the vault to choose a different group if needed.`;
} else {
message.textContent = "Search the vault below to choose a group for this submitted login.";
}
action.disabled = pendingSave.mode !== "update" && (!Array.isArray(pendingSave.path) || pendingSave.path.length === 0);
action.onclick = async () => {
action.disabled = true;
try {
const result = await runtimeSend({
type: "keepassgo-save-login",
tabId: popupTabID()
});
if (!result?.success) {
throw new Error(result?.error || "Save failed.");
}
setStatus("Saved", `${pendingSave.title || "Login"} is now in KeePassGO.`, "ready");
card.hidden = true;
} catch (error) {
setStatus("Save failed", error instanceof Error ? error.message : String(error), "error");
} finally {
action.disabled = false;
}
};
}
function popupTabID() { function popupTabID() {
const rawValue = new URLSearchParams(window.location.search).get("tabId"); const rawValue = new URLSearchParams(window.location.search).get("tabId");
if (rawValue === null) { if (rawValue === null) {
@@ -124,8 +217,38 @@ function popupTabID() {
return Number.isInteger(parsed) ? parsed : null; return Number.isInteger(parsed) ? parsed : null;
} }
async function searchVault(event) {
event.preventDefault();
const query = document.getElementById("search-query").value.trim();
const resultsRoot = document.getElementById("search-results");
if (!query) {
renderSearchResults([], "");
return;
}
resultsRoot.textContent = "";
const loading = document.createElement("p");
loading.className = "subtle";
loading.textContent = "Searching KeePassGO…";
resultsRoot.appendChild(loading);
try {
const response = await runtimeSend({
type: "keepassgo-search-logins",
query
});
if (!response?.success) {
throw new Error(response?.error || "Search failed.");
}
renderSearchResults(Array.isArray(response.results) ? response.results : [], query);
} catch (error) {
renderSearchResults([], query);
setStatus("Search failed", error instanceof Error ? error.message : String(error), "error");
}
}
async function main() { async function main() {
try { try {
document.getElementById("search-form").addEventListener("submit", searchVault);
renderSearchResults([], "");
const state = await runtimeSend({ const state = await runtimeSend({
type: "keepassgo-popup-state", type: "keepassgo-popup-state",
force: true, force: true,
@@ -133,6 +256,7 @@ async function main() {
}); });
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || ""); document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
renderPageHint(state); renderPageHint(state);
renderPendingSave(state);
if (!state.configured) { if (!state.configured) {
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning"); setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
@@ -158,6 +282,8 @@ async function main() {
const count = Array.isArray(state.matches) ? state.matches.length : 0; const count = Array.isArray(state.matches) ? state.matches.length : 0;
if (!state.pageHasLoginForm) { if (!state.pageHasLoginForm) {
setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready"); setStatus("Ready", "KeePassGO is connected. Open a login form to check for matches.", "ready");
} else if (state.pendingSave) {
setStatus("Save submitted login", state.pendingSave.mode === "update" ? `Update ${state.pendingSave.title || "this login"} or pick a different target below.` : "Save this submitted login or search below to choose a target entry.", "ready");
} else if (count === 0) { } else if (count === 0) {
setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready"); setStatus("Checked this page", "KeePassGO did not find a matching login for this form.", "ready");
} else { } else {
+50
View File
@@ -96,6 +96,29 @@ h2 {
gap: 8px; gap: 8px;
} }
.search-section {
margin-top: 16px;
}
.save-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
margin: 0 0 16px;
border: 1px solid #c5dccf;
border-radius: 12px;
background: var(--accent-soft);
}
.search-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
margin-bottom: 10px;
}
.match-row, .match-row,
button, button,
.link-button { .link-button {
@@ -164,6 +187,33 @@ textarea {
font: inherit; font: inherit;
} }
fieldset {
margin: 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
display: grid;
gap: 12px;
}
legend {
padding: 0 6px;
color: var(--ink-soft);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 10px;
}
.checkbox-row input {
width: auto;
}
button, button,
.link-button { .link-button {
padding: 10px 14px; padding: 10px 14px;
+5 -5
View File
@@ -12,7 +12,7 @@ const (
DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk" DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk"
DefaultAppID = "org.julianfamily.keepassgo" DefaultAppID = "org.julianfamily.keepassgo"
DefaultAPKOut = "build/keepassgo.apk" DefaultAPKOut = "build/keepassgo.apk"
DefaultVersion = "0.1.0.1" DefaultVersion = "0.8.2.298"
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev" DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
DefaultMinSDK = "28" DefaultMinSDK = "28"
DefaultTargetSDK = "35" DefaultTargetSDK = "35"
@@ -73,16 +73,16 @@ func (c Config) Validate() error {
return fmt.Errorf("ANDROID_NDK_ROOT must point to an Android NDK install") return fmt.Errorf("ANDROID_NDK_ROOT must point to an Android NDK install")
} }
if !isExecutable(filepath.Join(c.SDKRoot, "cmdline-tools", "latest", "bin", "sdkmanager")) { if !isExecutable(filepath.Join(c.SDKRoot, "cmdline-tools", "latest", "bin", "sdkmanager")) {
return fmt.Errorf("Android SDK cmdline-tools are missing") return fmt.Errorf("android SDK cmdline-tools are missing")
} }
if !isDir(filepath.Join(c.SDKRoot, "platforms", "android-"+c.TargetSDK)) { if !isDir(filepath.Join(c.SDKRoot, "platforms", "android-"+c.TargetSDK)) {
return fmt.Errorf("Android platform android-%s is missing", c.TargetSDK) return fmt.Errorf("android platform android-%s is missing", c.TargetSDK)
} }
if !isDir(filepath.Join(c.SDKRoot, "build-tools")) { if !isDir(filepath.Join(c.SDKRoot, "build-tools")) {
return fmt.Errorf("Android build-tools are missing") return fmt.Errorf("android build-tools are missing")
} }
if !isFile(c.IconPath) { if !isFile(c.IconPath) {
return fmt.Errorf("Android icon asset is missing: %s", c.IconPath) return fmt.Errorf("android icon asset is missing: %s", c.IconPath)
} }
return nil return nil
} }
+78
View File
@@ -0,0 +1,78 @@
# Android Autofill
## App Target Matching
User story:
- When an entry carries an Android-specific target such as
`androidapp://com.blinknetwork.mobile2`, KeePassGO should treat that as a
first-class autofill target on Android.
- If an exact app target exists, Android autofill should resolve that entry
directly instead of falling back to a generic chooser for the whole cache.
Expected behavior:
- `AndroidApp*` custom fields exported into the autofill cache must match the
Android package target used by the autofill and accessibility services.
- The Android-side matcher must normalize `androidapp://...` targets the same
way the Go cache builder does.
- The chooser path should still collapse to a single direct result when there
is one exact app-target match.
## Accessibility Fallback
User story:
- When Android accessibility fallback is needed, KeePassGO should not be
limited to Chrome-only URL bar parsing.
- Apps with stable package identities should still be fillable when an entry
carries a matching `AndroidApp*` target.
Expected behavior:
- Accessibility fallback derives its match target from the web domain when one
is available.
- If no web domain is available, accessibility fallback uses the active app
package as `androidapp://<package>`.
- The fallback path can therefore fill supported apps that never expose a
browser-style URL bar.
## Share-Driven Lookup
User story:
- When Android shares a login URL or a text snippet containing a login URL into
KeePassGO, the app should open into a credential lookup flow instead of only
supporting shared `.kdbx` imports.
- If the vault is already open, the shared target should immediately narrow the
entries view.
- If the vault is not open yet, the shared target should survive startup and
apply as soon as the vault is unlocked.
Expected behavior:
- Android share intents can queue a pending lookup target in addition to shared
vault file imports.
- KeePassGO normalizes the shared value into a search query that users can
immediately act on.
- The pending lookup is consumed once and does not keep reappearing on later
launches.
## Chooser Relevance
User story:
- When Android autofill cannot resolve a single direct match, KeePassGO should
still keep the chooser focused on entries that are relevant to the current
site or app.
- The picker should not fall back to an alphabetized dump of unrelated vault
entries when KeePassGO already knows the current host or package target.
Expected behavior:
- If multiple entries exactly match the current web host or Android app target,
the chooser shows only those relevant entries.
- If there are no exact matches but there are parent-host matches, the chooser
shows only those related entries.
- KeePassGO falls back to the full chooser list only when it has no related
host or app-target candidates at all.
+71
View File
@@ -93,6 +93,77 @@ Chromium / Chrome:
- Username and password fields get an inline KeePassGO affordance that opens a candidate chooser anchored to the focused field and keeps fills scoped to that field's form when possible. - Username and password fields get an inline KeePassGO affordance that opens a candidate chooser anchored to the focused field and keeps fills scoped to that field's form when possible.
- If a fill request needs user approval, the extension keeps the pending state visible in both the page affordance and the popup until KeePassGO resolves it, using the token-scoped pending-approval count from the local gRPC API. - If a fill request needs user approval, the extension keeps the pending state visible in both the page affordance and the popup until KeePassGO resolves it, using the token-scoped pending-approval count from the local gRPC API.
## Search And Matching
User story:
- When a page has no obvious match, the popup still lets the user search the
vault without leaving the browser.
- Search results must stay scoped to what the current API token can actually
access.
- Browser matching must treat common KeePass data conventions as real browser
targets, not just the primary `URL` field.
- Users who prefer narrow suggestions can ask the extension to show only the
strongest match quality returned by KeePassGO.
Expected behavior:
- The popup exposes a `Search Vault` field that queries KeePassGO directly.
- Search results use the same fill path as page matches.
- Search never leaks entries outside the token's authorized group scope.
- A browser match can come from:
- the primary `URL` field
- scheme-less host values such as `gitlab.com`
- custom URL fields such as `URL1`, `URL2`, and similar KeePass-style URL
slots
- The extension settings page exposes `Best match only`.
- When `Best match only` is enabled, page suggestions and popup search results
only show the strongest quality band returned by KeePassGO.
## Locked Vault Workflow
User story:
- When the current page has a login form but KeePassGO is locked, the browser
must still make that state visible on the page and in the popup.
- Unlocking KeePassGO should not require the user to reopen the popup multiple
times or reload the page before the extension becomes usable again.
Expected behavior:
- The popup shows a locked-state message instead of silently falling back to
"no matches."
- The inline page affordance stays visible on login forms while KeePassGO is
locked and tells the user to unlock the vault.
- After the vault is unlocked, the extension rechecks the page automatically
and turns the locked affordance back into live matches without requiring a
page reload.
## Save And Update Workflow
User story:
- After the user submits a login form, the browser extension should help store
that credential instead of forcing the user back into KeePassGO manually.
- If KeePassGO already has a matching entry for that site and username, the
popup should offer an update.
- If the user is creating a new login, the popup should let the user save it
into a relevant vault group without leaving the browser.
Expected behavior:
- Submitted login forms queue a pending browser save/update state for the
active tab.
- The popup shows that pending save/update state prominently instead of hiding
it behind page matches alone.
- When KeePassGO finds an exact browser match for the submitted username and
site, the popup offers an `Update` action for that entry.
- When there is no exact entry match, the popup offers a `Save` action using a
relevant group path from the current page matches or a user-selected search
result.
- The browser save/update action writes through KeePassGO's existing secure
gRPC mutation API and stays scoped to the browser token's allowed groups.
For extension-side regression checks, run: For extension-side regression checks, run:
```bash ```bash
+207
View File
@@ -0,0 +1,207 @@
# GUI Test Plan
This document splits GUI validation into human-owned and agent-owned coverage.
The intent is that, together, both passes exercise every reachable GUI area
without relying on one side to judge both functional correctness and real
usability.
## Scope
- Desktop GUI
- Android GUI
- Shared workflow surfaces:
lifecycle, unlock, list/detail/editor, settings, sync, API tokens, API audit,
templates, recycle bin, About, and platform-specific integrations
## Ownership Model
- Human testing covers visual correctness, ergonomics, discoverability, focus,
scrolling, tap targets, keyboard feel, and platform integration behavior.
- Agent testing covers deterministic automation:
build, install, launch, focus, test suite, lint, and release-path validation.
## Android Plan
### Coverage Matrix
| Area | Human | Agent |
|---|---|---|
| App launch and first render | Verify real rendering, layout, readability, no black screen | Verify emulator online, app installed, app focused |
| Lifecycle screen | Exercise create/open/open-remote forms visually | Cover lifecycle logic through Go tests and release APK build |
| Unlock flow | Verify password/key-file affordances, focus, error messaging | Covered by automated tests |
| Vault list and navigation | Verify scrolling, selection, section switching, phone layout reachability | Covered by automated tests for list state/search/section behavior |
| Entry detail | Verify readability, action placement, tap targets | Covered by automated tests for state transitions and copy/generate actions |
| Entry editor | Verify field editing usability and save affordance | Covered by automated tests for save/update behavior |
| Template views | Verify template list/editor reachability and copy | Covered by automated tests |
| Recycle bin | Verify deleted-entry browsing/search UX | Covered by automated tests |
| Attachments UI | Verify list/add/remove affordances visually | Covered by automated tests for attachment summaries and save paths |
| Search | Verify placeholder, live filtering feel, clear behavior | Covered by automated tests for search semantics |
| Settings | Verify toggles and summaries are understandable | Covered by automated tests for persistence |
| Remote sync setup/dialog | Verify sheet/dialog layout, field order, source/direction controls | Covered by automated tests and release build |
| Saved remote binding UI | Verify summaries are understandable and discoverable | Covered by automated tests |
| API tokens / API audit | Verify navigation and dense-detail readability | Covered by automated tests |
| About / informational screens | Verify copy, scroll, layout | Covered by automated tests for search disable state and section state |
| Android-only share/import/file picker | Verify system integration actually works | Agent only verifies package/build/focus, not picker UX |
| Android autofill entry point | Verify Android mechanism appears and is usable | Agent only verifies app/package/focus |
### Human Steps
1. Start from the currently installed `org.julianfamily.keepassgo` app on the
running emulator or device.
2. Verify launch lands on a rendered screen, not a black frame, blank frame, or
frozen frame.
3. On the lifecycle screen, inspect every visible action:
`Create vault`, `Open vault`, `Open remote vault`, unlock-related controls,
recent vaults, and any sync summaries.
4. Create a throwaway vault or open a demo vault and verify the full unlock path
is visually clear.
5. After unlock, visit each top-level section you can reach in the Android UI:
vault/group list, entry detail, entry editor, templates, recycle bin,
settings, API tokens, API audit, About.
6. In the main vault UI, verify:
group navigation, entry selection, search, back behavior, scrolling, and any
phone-only toggles or drawers.
7. Open an entry and exercise every visible entry action:
copy username, copy password, copy URL, reveal/hide password, generate
password, save.
8. Exercise one template flow:
open template list, inspect template detail/editor, save or cancel.
9. Exercise recycle-bin browsing and search.
10. Open settings and inspect every visible toggle or summary card. Change at
least one benign toggle and verify it sticks after leaving and returning.
11. Open the sync UI and inspect:
remote setup, saved-binding summary, direction/source choices,
confirm/cancel behavior, and text summaries.
12. Exercise one Android-native integration:
shared-vault import, file picker open, or current-vault share.
13. If Android autofill is in scope for current testing, open a target app/site
and confirm the Android autofill entry point is offered and usable.
14. Record failures with:
exact screen, exact control, expected behavior, actual behavior, and whether
it is Android-only or likely shared with desktop.
### Agent Steps Executed
1. Verified emulator availability with `adb devices -l`.
2. Verified emulator Android version with
`adb shell getprop ro.build.version.release`.
3. Verified app package installed with
`adb shell pm list packages org.julianfamily.keepassgo`.
4. Verified app focus with
`adb shell dumpsys window | rg 'mCurrentFocus|mFocusedApp'`.
5. Ran `go test ./...`.
6. Ran `go tool golangci-lint run ./...`.
7. Verified release APK path through release workflow `v0.6.0`:
PR run `60` succeeded,
post-merge `main` run `61` succeeded,
tag run `62` succeeded.
### Agent Results
- Emulator present: yes
- Android version: `15`
- App installed: yes
- App focused:
`org.julianfamily.keepassgo/org.gioui.GioActivity`
- APK release build path: passed in release CI
- Remaining Android risk:
real visual usability and system-integration behavior
## Desktop Plan
### Coverage Matrix
| Area | Human | Agent |
|---|---|---|
| App launch and window presentation | Verify startup polish, focus, resize behavior, DPI/readability | Verify desktop binaries built in release CI |
| Lifecycle screen | Verify desktop layout density, affordance clarity, recent vault usability | Covered by automated tests |
| Unlock flow | Verify keyboard-first behavior, focus order, errors, lock/unlock loop | Covered by automated tests plus human keyboard feel |
| Group browser and list pane | Verify density, selection clarity, scrolling, split behavior | Covered by automated tests for state/search |
| Entry detail pane | Verify copy actions, reveal/hide, path context, readability | Covered by automated tests |
| Entry editor | Verify field order, keyboard flow, save/cancel usability | Covered by automated tests |
| Templates | Verify templates section is reachable and understandable | Covered by automated tests |
| Recycle bin | Verify browsing, searching, deletion or recovery UX | Covered by automated tests |
| Search | Verify keyboard search flow, placeholder correctness, result context | Covered by automated tests |
| Vault save/save-as/lock | Verify menus, buttons, and shortcuts feel correct | Covered by automated tests and release builds |
| Settings | Verify desktop layout, summaries, toggles, persistence feel | Covered by automated tests |
| Remote sync UI | Verify dialog layout, wording, discoverability, advanced options | Covered by automated tests |
| API tokens | Verify dense-detail presentation and policy editor usability | Covered by automated tests |
| API audit | Verify search/filter/readability | Covered by automated tests |
| Browser-extension-adjacent desktop UX | Verify visible status/help text and extension workflow discoverability | Agent validated release/build path; human should judge usability |
| About and docs entry points | Verify copy and layout | Covered partly by tests; human judges presentation |
### Human Steps
1. Launch KeePassGO on desktop from the shipped binary or normal desktop entry.
2. Inspect the lifecycle/open screen at normal window size and at a narrower
width.
3. Exercise create, open, save-as, lock, unlock, and reopen on a throwaway
vault or demo vault.
4. Use keyboard-first operation for at least one complete pass:
tab order, enter or escape expectations, search focus, unlock focus, and
editor save flow.
5. After unlocking, visit every reachable primary section:
vault list, entry detail, editor, templates, recycle bin, settings,
API tokens, API audit, About.
6. In the vault browser, verify:
nested groups, path context, scrolling, selection state, and search results
with path context.
7. Open an entry and exercise all visible actions:
copy username, password, URL, reveal/hide password, password generation,
and save.
8. Exercise at least one mutation flow:
create entry, edit entry, move/delete entry, view recycle bin, and recover
if available.
9. Open settings and inspect all visible summaries and toggles.
10. Open remote sync UI and inspect every visible mode:
setup, saved binding, advanced sync, source or direction choices.
11. Open API Tokens and API Audit even if you do not issue a real token, just
to assess navigation and readability.
12. If you use browser integration, verify the desktop-side flow is still
understandable from the product UI and extension behavior.
13. Record failures with:
screen, control, expected behavior, actual behavior, and whether it is
presentation-only or functional.
### Agent Steps Executed
1. Verified clean repo state on `main`.
2. Ran `go test ./...`.
3. Ran `go tool golangci-lint run ./...`.
4. Verified post-merge `main` CI run `61` succeeded.
5. Verified release tag run `62` succeeded.
6. Verified release `v0.6.0` published.
7. Verified release artifacts include:
`keepassgo-linux-amd64`,
`keepassgo-windows-amd64.exe`,
`keepassgo-windows-arm64.exe`,
and `keepassgo.apk`.
### Agent Results
- Automated logic/state coverage: pass
- Desktop build coverage: pass
- Release publication: pass
- Remaining desktop risk:
interaction quality, keyboard feel, dense-layout readability, and workflow
discovery
## Reporting Template
Use this format when reporting findings from the human pass:
```md
## Android
- Screen:
- Action:
- Expected:
- Actual:
- Severity:
## Desktop
- Screen:
- Action:
- Expected:
- Actual:
- Severity:
```
+122 -99
View File
@@ -2,8 +2,12 @@ module git.julianfamily.org/keepassgo
go 1.26 go 1.26
replace gioui.org => git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc
replace gioui.org/cmd => git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc
require ( require (
gioui.org v0.8.0 gioui.org v0.9.0
gioui.org/x v0.8.0 gioui.org/x v0.8.0
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/tobischo/gokeepasslib/v3 v3.6.2 github.com/tobischo/gokeepasslib/v3 v3.6.2
@@ -15,54 +19,69 @@ require (
require ( require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect
codeberg.org/chavacava/garif v0.2.0 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
dev.gaijin.team/go/golib v0.6.0 // indirect
gioui.org/cmd v0.8.0 // indirect gioui.org/cmd v0.8.0 // indirect
gioui.org/shader v1.0.8 // indirect gioui.org/shader v1.0.8 // indirect
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect
github.com/4meepo/tagalign v1.4.2 // indirect github.com/4meepo/tagalign v1.4.3 // indirect
github.com/Abirdcfly/dupword v0.1.3 // indirect github.com/Abirdcfly/dupword v0.1.7 // indirect
github.com/Antonboom/errname v1.0.0 // indirect github.com/AdminBenni/iota-mixing v1.0.0 // indirect
github.com/Antonboom/nilnil v1.0.1 // indirect github.com/AlwxSin/noinlineerr v1.0.5 // indirect
github.com/Antonboom/testifylint v1.5.2 // indirect github.com/Antonboom/errname v1.1.1 // indirect
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/Crocmagnon/fatcontext v0.7.1 // indirect github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect github.com/BurntSushi/toml v1.6.0 // indirect
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/akavel/rsrc v0.10.1 // indirect github.com/akavel/rsrc v0.10.1 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.5 // indirect github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.0.0 // indirect github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.1.2 // indirect github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/ashanbrown/forbidigo v1.6.0 // indirect github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect
github.com/ashanbrown/makezero v1.2.0 // indirect github.com/ashanbrown/makezero/v2 v2.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.5.0 // indirect github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/breml/bidichk v0.3.2 // indirect github.com/bombsimon/wsl/v5 v5.6.0 // indirect
github.com/breml/errchkjson v0.4.0 // indirect github.com/breml/bidichk v0.3.3 // indirect
github.com/butuzov/ireturn v0.3.1 // indirect github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.0 // indirect
github.com/butuzov/mirror v1.3.0 // indirect github.com/butuzov/mirror v1.3.0 // indirect
github.com/catenacyber/perfsprint v0.8.2 // indirect github.com/catenacyber/perfsprint v0.10.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.2 // indirect github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.10 // indirect github.com/charithe/durationcheck v0.0.11 // indirect
github.com/chavacava/garif v0.1.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/ckaznocha/intrange v0.3.0 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.5 // indirect github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/ettle/strcase v0.2.0 // indirect github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.5 // indirect github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v0.3.9 // indirect github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/go-critic/go-critic v0.12.0 // indirect github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-text/typesetting v0.3.0 // indirect github.com/go-text/typesetting v0.3.0 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect github.com/go-toolsmith/astcopy v1.1.0 // indirect
@@ -71,149 +90,153 @@ require (
github.com/go-toolsmith/astp v1.1.0 // indirect github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/glob v0.2.3 // indirect
github.com/godbus/dbus/v5 v5.0.6 // indirect github.com/godbus/dbus/v5 v5.0.6 // indirect
github.com/gofrs/flock v0.12.1 // indirect github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.0 // indirect github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint v1.64.8 // indirect github.com/golangci/golangci-lint/v2 v2.11.4 // indirect
github.com/golangci/misspell v0.6.0 // indirect github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/plugin-module-register v0.1.1 // indirect github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/gordonklaus/ineffassign v0.1.0 // indirect github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect github.com/gostaticanalysis/comment v1.5.0 // indirect
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.1 // indirect github.com/gostaticanalysis/nilerr v0.1.2 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.7.1 // indirect github.com/jgautheron/goconst v1.8.2 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jjti/go-spancheck v0.6.4 // indirect github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/julz/importas v0.2.0 // indirect github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect github.com/kisielk/errcheck v1.10.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.6.3 // indirect github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.10 // indirect github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.2 // indirect github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.6.1 // indirect github.com/ldez/gomoddirectives v0.8.0 // indirect
github.com/ldez/grignotin v0.9.0 // indirect github.com/ldez/grignotin v0.10.1 // indirect
github.com/ldez/tagliatelle v0.7.1 // indirect github.com/ldez/structtags v0.6.1 // indirect
github.com/ldez/usetesting v0.4.2 // indirect github.com/ldez/tagliatelle v0.7.2 // indirect
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect github.com/leonklingele/grouper v1.1.2 // indirect
github.com/macabu/inamedparam v0.1.3 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect github.com/magiconair/properties v1.8.6 // indirect
github.com/maratori/testableexamples v1.0.0 // indirect github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/maratori/testpackage v1.1.1 // indirect github.com/manuelarte/funcorder v0.5.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.7.0 // indirect github.com/mgechev/revive v1.15.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.19.1 // indirect github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polyfloyd/go-errorlint v1.7.1 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect github.com/prometheus/procfs v0.7.3 // indirect
github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 // indirect github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/ryancurrah/gomodguard v1.3.5 // indirect github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.22.2 // indirect github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sivchari/tenv v1.12.1 // indirect github.com/sonatard/noctx v0.5.1 // indirect
github.com/sonatard/noctx v0.1.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/afero v1.12.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.5.0 // indirect github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.12.0 // indirect github.com/spf13/viper v1.12.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.4.1 // indirect github.com/subosito/gotenv v1.4.1 // indirect
github.com/tdakkota/asciicheck v0.4.1 // indirect github.com/tetafro/godot v1.5.4 // indirect
github.com/tetafro/godot v1.5.0 // indirect github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 // indirect github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/timonwong/loggercheck v0.10.1 // indirect
github.com/tobischo/argon2 v0.1.0 // indirect github.com/tobischo/argon2 v0.1.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.10.0 // indirect github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect github.com/ultraware/whitespace v0.2.0 // indirect
github.com/uudashr/gocognit v1.2.0 // indirect github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.3.1 // indirect github.com/uudashr/iface v1.4.1 // indirect
github.com/xen0n/gosmopolitan v1.2.2 // indirect github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yagipy/maintidx v1.0.0 // indirect github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.13.0 // indirect go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.9.0 // indirect go-simpler.org/sloglint v0.11.1 // indirect
go.uber.org/atomic v1.7.0 // indirect go.augendre.info/arangolint v0.4.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect go.augendre.info/fatcontext v0.9.0 // indirect
go.uber.org/multierr v1.6.0 // indirect go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.24.0 // indirect go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.48.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/image v0.37.0 // indirect golang.org/x/image v0.37.0 // indirect
golang.org/x/mod v0.33.0 // indirect golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.0 // indirect golang.org/x/tools v0.43.0 // indirect
golang.org/x/tools/go/expect v0.1.1-deprecated // indirect
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.6.1 // indirect honnef.co/go/tools v0.7.0 // indirect
mvdan.cc/gofumpt v0.7.0 // indirect mvdan.cc/gofumpt v0.9.2 // indirect
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect
) )
tool ( tool (
gioui.org/cmd/gogio gioui.org/cmd/gogio
github.com/golangci/golangci-lint/cmd/golangci-lint github.com/golangci/golangci-lint/v2/cmd/golangci-lint
) )
+267 -252
View File
@@ -34,73 +34,87 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY=
codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ=
codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI=
codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8=
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y=
dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI=
dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo=
dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg=
gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc=
gioui.org/cmd v0.8.0 h1:oy5qOlc1UXcglc5HBCMZQELiIzQ2obhT98mw+SuWafQ=
gioui.org/cmd v0.8.0/go.mod h1:wKLAyAgRR25VMYFzGX2Ecia0m0Td562wDcZ3LaPHPTI=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA= gioui.org/x v0.8.0 h1:RhIlQNOFKKn8D8FeaKKaXCo7vB3x+fq4VcD10HW/YpA=
gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE= gioui.org/x v0.8.0/go.mod h1:aXtQb+kyqoUOjDl5/uMqAopjzVzMkeHBbMQOGT5KnSE=
git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc h1:jyfCTx9wk/uLaEMkdKsg491C/kjfbG2EKAVTORhZxHo=
git.julianfamily.org/joejulian/gio-cmd-patched v0.9.1-0.20260417040456-1762d36ddecc/go.mod h1:RBQfFU8JCgMjQ2wKU9DG3zMC38TnY97E5MKoBGhGl3s=
git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc h1:p2AaZUAXa/ExPybNyeB05+GjTSZGA9lCfDpWz49IT5Y=
git.julianfamily.org/joejulian/gio-patched v0.9.1-0.20260416220049-9bfa6bc1c2dc/go.mod h1:BdI7mF5DCa3kxlo3G93XHL7khtZnk1gu4335pExk8gs=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI= git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI=
git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo= git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E= github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8=
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI= github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c=
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE= github.com/Abirdcfly/dupword v0.1.7 h1:2j8sInznrje4I0CMisSL6ipEBkeJUJAmK1/lfoNGWrQ=
github.com/Abirdcfly/dupword v0.1.3/go.mod h1:8VbB2t7e10KRNdwTVoxdBaxla6avbhGzb8sCTygUMhw= github.com/Abirdcfly/dupword v0.1.7/go.mod h1:K0DkBeOebJ4VyOICFdppB23Q0YMOgVafM0zYW0n9lF4=
github.com/Antonboom/errname v1.0.0 h1:oJOOWR07vS1kRusl6YRSlat7HFnb3mSfMl6sDMRoTBA= github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo=
github.com/Antonboom/errname v1.0.0/go.mod h1:gMOBFzK/vrTiXN9Oh+HFs+e6Ndl0eTFbtsRTSRdXyGI= github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY=
github.com/Antonboom/nilnil v1.0.1 h1:C3Tkm0KUxgfO4Duk3PM+ztPncTFlOf0b2qadmS0s4xs= github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY=
github.com/Antonboom/nilnil v1.0.1/go.mod h1:CH7pW2JsRNFgEh8B2UaPZTEPhCMuFowP/e8Udp9Nnb0= github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc=
github.com/Antonboom/testifylint v1.5.2 h1:4s3Xhuv5AvdIgbd8wOOEeo0uZG7PbDKQyKY5lGoQazk= github.com/Antonboom/errname v1.1.1 h1:bllB7mlIbTVzO9jmSWVWLjxTEbGBVQ1Ff/ClQgtPw9Q=
github.com/Antonboom/testifylint v1.5.2/go.mod h1:vxy8VJ0bc6NavlYqjZfmp6EfqXMtBgQ4+mhCojwC1P8= github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ=
github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksufQ=
github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Crocmagnon/fatcontext v0.7.1 h1:SC/VIbRRZQeQWj/TcQBS6JmrXcfA+BU4OGSVUt54PjM= github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g=
github.com/Crocmagnon/fatcontext v0.7.1/go.mod h1:1wMvv3NXEBJucFGfwOJBxSVWcoIO6emV215SMkW9MFU= github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k=
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rWPdisA5ynNEsoARbiCBOyGcJM4/OzsM= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 h1:Sz1JIXEcSfhz7fUi7xHnhpIE0thVASYjvosApmHuD2k= github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ=
github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1/go.mod h1:n/LSCXNuIYqVfBlVXyHfMQkZDdp1/mmxfSjADd3z1Zg= github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o= github.com/akavel/rsrc v0.10.1 h1:hCCPImjmFKVNGpeLZyTDRHEFC283DzyTXTo0cO0Rq9o=
github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/akavel/rsrc v0.10.1/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alexkohler/nakedret/v2 v2.0.5 h1:fP5qLgtwbx9EJE8dGEERT02YwS8En4r9nnZ71RK+EVU= github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
github.com/alexkohler/nakedret/v2 v2.0.5/go.mod h1:bF5i0zF2Wo2o4X4USt9ntUWve6JbFv02Ff4vlkmS/VU= github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw= github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M=
github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc=
github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus=
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
github.com/alingse/nilnesserr v0.1.2 h1:Yf8Iwm3z2hUUrP4muWfW83DF4nE3r1xZ26fGWUKCZlo= github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
github.com/alingse/nilnesserr v0.1.2/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
github.com/ashanbrown/forbidigo v1.6.0 h1:D3aewfM37Yb3pxHujIPSpTf6oQk9sc9WZi8gerOIVIY= github.com/ashanbrown/forbidigo/v2 v2.3.0 h1:OZZDOchCgsX5gvToVtEBoV2UWbFfI6RKQTir2UZzSxo=
github.com/ashanbrown/forbidigo v1.6.0/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU= github.com/ashanbrown/forbidigo/v2 v2.3.0/go.mod h1:5p6VmsG5/1xx3E785W9fouMxIOkvY2rRV9nMdWadd6c=
github.com/ashanbrown/makezero v1.2.0 h1:/2Lp1bypdmK9wDIq7uWBlDF1iMUpIIS4A+pF6C9IEUU= github.com/ashanbrown/makezero/v2 v2.1.0 h1:snuKYMbqosNokUKm+R6/+vOPs8yVAi46La7Ck6QYSaE=
github.com/ashanbrown/makezero v1.2.0/go.mod h1:dxlPhHbDMC6N6xICzFBSK+4njQDdK8euNO0qjQMtGY4= github.com/ashanbrown/makezero/v2 v2.1.0/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY=
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/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -109,77 +123,95 @@ github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5
github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo=
github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M=
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bombsimon/wsl/v4 v4.5.0 h1:iZRsEvDdyhd2La0FVi5k6tYehpOR/R7qIUjmKk7N74A= github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
github.com/bombsimon/wsl/v4 v4.5.0/go.mod h1:NOQ3aLF4nD7N5YPXMruR6ZXDOAqLoM0GEpLwTdvmOSc= github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/breml/bidichk v0.3.2 h1:xV4flJ9V5xWTqxL+/PMFF6dtJPvZLPsyixAoPe8BGJs= github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8=
github.com/breml/bidichk v0.3.2/go.mod h1:VzFLBxuYtT23z5+iVkamXO386OB+/sVwZOpIj6zXGos= github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU=
github.com/breml/errchkjson v0.4.0 h1:gftf6uWZMtIa/Is3XJgibewBm2ksAQSY/kABDNFTAdk= github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE=
github.com/breml/errchkjson v0.4.0/go.mod h1:AuBOSTHyLSaaAFlWsRSuRBIroCh3eh7ZHh5YeelDIk8= github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE=
github.com/butuzov/ireturn v0.3.1 h1:mFgbEI6m+9W8oP/oDdfA34dLisRFCj2G6o/yiI1yZrY= github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg=
github.com/butuzov/ireturn v0.3.1/go.mod h1:ZfRp+E7eJLC0NQmk1Nrm1LOrn/gQlOykv+cVPdiXH5M= github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s=
github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E=
github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70=
github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc=
github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
github.com/catenacyber/perfsprint v0.8.2 h1:+o9zVmCSVa7M4MvabsWvESEhpsMkhfE7k0sHNGL95yw= github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ=
github.com/catenacyber/perfsprint v0.8.2/go.mod h1:q//VWC2fWbcdSLEY1R3l8n0zQCDPdE4IjZwyY1HMunM= github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
github.com/ccojocar/zxcvbn-go v1.0.2 h1:na/czXU8RrhXO4EZme6eQJLR4PzcGsahsBOAwU6I3Vg= github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc=
github.com/ccojocar/zxcvbn-go v1.0.2/go.mod h1:g1qkXtUSvHP8lhHp5GrSmTz6uWALGRMQdw6Qnz/hi60= github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iyoHGPf5w4= github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk=
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ= github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 h1:UZdrvid2JFwnvPlUSEFlE794XZL4Jmrj8fuxfcLECJE=
github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.13.6 h1:xlNunMyzS5bu3r/QKrb3fzX6ow3WBQ6oao+J65PGZxk=
github.com/chromedp/chromedp v0.13.6/go.mod h1:h8GPP6ZtLMLsU8zFbTcb7ZDGCvCy8j/vRoFmRltQx9A=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/ckaznocha/intrange v0.3.0 h1:VqnxtK32pxgkhJgYQEeOArVidIPg+ahLP7WBOXZd5ZY= github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs=
github.com/ckaznocha/intrange v0.3.0/go.mod h1:+I/o2d2A1FBHgGELbGxzIcyd3/9l9DuwjM8FsbSS3Lo= github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs=
github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88=
github.com/daixiang0/gci v0.13.5 h1:kThgmH1yBmZSBCh1EJVxQ7JsHpm5Oms0AMed/0LaH4c= github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ=
github.com/daixiang0/gci v0.13.5/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom5Eo5Yk= github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ=
github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY=
github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc=
github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8=
github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 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.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/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/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
github.com/firefart/nonamedreturns v1.0.5 h1:tM+Me2ZaXs8tfdDw3X6DOX++wMCOqzYUho6tUTYIdRA= github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E=
github.com/firefart/nonamedreturns v1.0.5/go.mod h1:gHJjDqhGM4WyPt639SOZs+G89Ko7QKH5R5BhnO6xJhw= github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo=
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ= github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w= github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4am5mB/VfFK64w= github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1 h1:+VexzzkMLb1tnvpuQdGT/DicIRW7MN8ozsXqBMgp0Hk=
github.com/go-json-experiment/json v0.0.0-20250417205406-170dfdcf87d1/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -218,22 +250,24 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi
github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus=
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro= github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -264,22 +298,28 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
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/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ=
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw= github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw=
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUPPyAKJuzv8pEJU= github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U=
github.com/golangci/go-printf-func-name v0.1.0/go.mod h1:wqhWFH5mUdJQhweRnldEywnR5021wTdZSNgwYceV14s= github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint v1.64.8 h1:y5TdeVidMtBGG32zgSC7ZXTFNHrsJkDnpO4ItB3Am+I= github.com/golangci/golangci-lint/v2 v2.11.4 h1:GK+UlZBN5y7rh2PBnHA93XLSX6RaF7uhzJQ3JwU1wuA=
github.com/golangci/golangci-lint v1.64.8/go.mod h1:5cEsUQBSr6zi8XI8OjmcY2Xmliqc4iYL7YoPrL+zLJ4= github.com/golangci/golangci-lint/v2 v2.11.4/go.mod h1:ODQDCASMA3VqfZYIbbQLpTRTzV7O/vjmIRF6u8NyFwI=
github.com/golangci/misspell v0.6.0 h1:JCle2HUTNWirNlDIAUO44hUsKhOFqGPoC4LZxlaSXDs= github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
github.com/golangci/misspell v0.6.0/go.mod h1:keMNyY6R9isGaSAu+4Q8NMBwMPkh15Gtc8UCVoDtAWo= github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c= github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
github.com/golangci/plugin-module-register v0.1.1/go.mod h1:TTpqoB6KkwOJMV8u7+NyXMrkwwESJLOkfl9TxR1DGFc= github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s= github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed h1:IURFTjxeTfNFP0hTEi1YKjB/ub8zkpaOqFFMApi2EAs= github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM=
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed/go.mod h1:XLXN8bNw4CGRPaqgl3bv/lhz7bsGPh4/xSaMTbo2vkQ= github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s=
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM=
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -292,7 +332,6 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
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.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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=
@@ -306,25 +345,24 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
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/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs=
github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw=
github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk=
github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc=
github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado=
github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM=
github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8= github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8=
github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc= github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc=
github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk= github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk=
github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY= github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY=
github.com/gostaticanalysis/nilerr v0.1.1 h1:ThE+hJP0fEp4zWLkWHWcRyI2Od0p7DlgYG3Uqrmrcpk= github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHMcpu52xQU=
github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW0HU0GPE3+5PWN4A= github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA=
github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M=
github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8=
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
@@ -333,8 +371,8 @@ github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1T
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -346,12 +384,12 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jgautheron/goconst v1.7.1 h1:VpdAG7Ca7yvvJk5n8dMwQhfEZJh95kl/Hl9S1OI5Jkk= github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4=
github.com/jgautheron/goconst v1.7.1/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4= github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako=
github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs=
github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c=
github.com/jjti/go-spancheck v0.6.4 h1:Tl7gQpYf4/TMU7AT84MN83/6PutY21Nb9fuQjFTpRRc= github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8=
github.com/jjti/go-spancheck v0.6.4/go.mod h1:yAEYdKJ2lRkDA8g7X+oKUHXOWVAXSBJRv04OhF+QUjk= github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -363,15 +401,13 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/karamaru-alpha/copyloopvar v1.2.1 h1:wmZaZYIjnJ0b5UoKDjUHrikcV0zuPyyxI4SVplLd2CI= github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.1/go.mod h1:nFmMlFNlClC2BPvNaHMdkirmTJxVCY0lhxBtlfOypMM= github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M= github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE= github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg= github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 h1:V0an7KRw92wmJysvFvtqtKMAPmvS5O0jtB0nYo6t+gs=
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
@@ -382,34 +418,40 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kulti/thelper v0.6.3 h1:ElhKf+AlItIu+xGnI990no4cE2+XaSu1ULymV2Yulxs= github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98=
github.com/kulti/thelper v0.6.3/go.mod h1:DsqKShOvP40epevkFrvIwkCMNYxMeTNjdWL4dqWHZ6I= github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs=
github.com/kunwardeep/paralleltest v1.0.10 h1:wrodoaKYzS2mdNVnc4/w31YaXFtsc21PCTdvWJ/lDDs= github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w=
github.com/kunwardeep/paralleltest v1.0.10/go.mod h1:2C7s65hONVqY7Q5Efj5aLzRCNLjw2h4eMc9EcypGjcY= github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4= github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4=
github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI=
github.com/ldez/exptostd v0.4.2 h1:l5pOzHBz8mFOlbcifTxzfyYbgEmoUqjxLFHZkjlbHXs= github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ=
github.com/ldez/exptostd v0.4.2/go.mod h1:iZBRYaUmcW5jwCR3KROEZ1KivQQp6PHXbDPk9hqJKCQ= github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM=
github.com/ldez/gomoddirectives v0.6.1 h1:Z+PxGAY+217f/bSGjNZr/b2KTXcyYLgiWI6geMBN2Qc= github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk=
github.com/ldez/gomoddirectives v0.6.1/go.mod h1:cVBiu3AHR9V31em9u2kwfMKD43ayN5/XDgr+cdaFaKs= github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q=
github.com/ldez/grignotin v0.9.0 h1:MgOEmjZIVNn6p5wPaGp/0OKWyvq42KnzAt/DAb8O4Ow= github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
github.com/ldez/grignotin v0.9.0/go.mod h1:uaVTr0SoZ1KBii33c47O1M8Jp3OP3YDwhZCmzT9GHEk= github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
github.com/ldez/tagliatelle v0.7.1 h1:bTgKjjc2sQcsgPiT902+aadvMjCeMHrY7ly2XKFORIk= github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk=
github.com/ldez/tagliatelle v0.7.1/go.mod h1:3zjxUpsNB2aEZScWiZTHrAXOl1x25t3cRmzfK1mlo2I= github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY=
github.com/ldez/usetesting v0.4.2 h1:J2WwbrFGk3wx4cZwSMiCQQ00kjGR0+tuuyW0Lqm4lwA= github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk=
github.com/ldez/usetesting v0.4.2/go.mod h1:eEs46T3PpQ+9RgN9VjpY6qWdiw2/QmfiDeWmdZdrjIQ= github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI=
github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc=
github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ=
github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV1Mk= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE=
github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM= github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww=
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM=
github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8=
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= github.com/manuelarte/funcorder v0.5.0/go.mod h1:Yt3CiUQthSBMBxjShjdXMexmzpP8YGvGLjrxJNkO2hA=
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8=
github.com/maratori/testpackage v1.1.1/go.mod h1:s4gRK/ym6AMrqpOa/kEbQTV4Q4jb7WeLZzVhVVVOQMc= github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ=
github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs=
github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc=
github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4=
github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
@@ -418,13 +460,12 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.7.0 h1:JyeQ4yO5K8aZhIKf5rec56u0376h8AlKNQEmjfkjKlY= github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q=
github.com/mgechev/revive v1.7.0/go.mod h1:qZnwcNhoguE58dfi96IJeSTPeZQejNeoMQLUZGi4SW4= github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
@@ -436,6 +477,8 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=
github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
@@ -444,14 +487,12 @@ github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhK
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
github.com/nunnatsa/ginkgolinter v0.19.1 h1:mjwbOlDQxZi9Cal+KfbEJTCz327OLNfwNvoZ70NJ+c4= github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8=
github.com/nunnatsa/ginkgolinter v0.19.1/go.mod h1:jkQ3naZDmxaZMXPWaS9rblH+i+GWXQCaS/JFIWcOH2s= github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
@@ -461,18 +502,13 @@ github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT9
github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/polyfloyd/go-errorlint v1.7.1 h1:RyLVXIbosq1gBdk/pChWA8zWYLsq9UEw7a1L5TVMCnA=
github.com/polyfloyd/go-errorlint v1.7.1/go.mod h1:aXjNb1x2TNhoLsk26iv1yl7a+zTnXPhwEMtEXukiLR8=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
@@ -495,10 +531,10 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 h1:+Wl/0aFp0hpuHM3H//KMft64WQ1yX9LdJY64Qm/gFCo= github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA=
github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1/go.mod h1:GJLgqsLeo4qgavUoL8JeGFNS7qcisx3awV/w9eWTmNI= github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE=
github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY=
github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo=
github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng=
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU=
@@ -514,100 +550,94 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryancurrah/gomodguard v1.3.5 h1:cShyguSwUEeC0jS7ylOiG/idnd1TpJ1LfHGpV3oJmPU= github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
github.com/ryancurrah/gomodguard v1.3.5/go.mod h1:MXlEPQRxgfPQa62O8wzK3Ozbkv9Rkqr+wKjSxTdsNJE= github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I=
github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU= github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg=
github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ= github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU=
github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0=
github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw=
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
github.com/sashamelentyev/usestdlibvars v1.28.0 h1:jZnudE2zKCtYlGzLVreNp5pmCdOxXUzwsMDBkR21cyQ= github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
github.com/sashamelentyev/usestdlibvars v1.28.0/go.mod h1:9nl0jgOfHKWNFS43Ojw0i7aRoS4j6EBye3YBhmAIRF8= github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.22.2 h1:IXbuI7cJninj0nRpZSLCUlotsj8jGusohfONMrHoF6g= github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 h1:AoLtJX4WUtZkhhUUMFy3GgecAALp/Mb4S1iyQOA2s0U=
github.com/securego/gosec/v2 v2.22.2/go.mod h1:UEBGA+dSKb+VqM6TdehR7lnQtIIMorYJ4/9CW1KVQBE= github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08/go.mod h1:+XLCJiRE95ga77XInNELh2M6zQP+PdqiT9Zpm0D9Wpk=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
github.com/sivchari/tenv v1.12.1 h1:+E0QzjktdnExv/wwsnnyk4oqZBUfuh89YMQT1cyuvSY= github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs=
github.com/sivchari/tenv v1.12.1/go.mod h1:1LjSOUCc25snIr5n3DtGGrENhX3LuWefcplwVGC24mw= github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
github.com/sonatard/noctx v0.1.0 h1:JjqOc2WN16ISWAjAk8M5ej0RfExEXtkEyExl2hLW+OM=
github.com/sonatard/noctx v0.1.0/go.mod h1:0RvBxqY8D4j9cTTTWE8ylt2vqj2EPI8fHmrxHdsaZ2c=
github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ=
github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI=
github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0=
github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I=
github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4= github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g=
github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk= github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ=
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.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8=
github.com/tdakkota/asciicheck v0.4.1/go.mod h1:0k7M3rCfRXb0Z6bwgvkEIMleKH3kXNz9UqJ9Xuqopr8=
github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA=
github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
github.com/tetafro/godot v1.5.0 h1:aNwfVI4I3+gdxjMgYPus9eHmoBeJIbnajOyqZYStzuw= github.com/tetafro/godot v1.5.4 h1:u1ww+gqpRLiIA16yF2PV1CV1n/X3zhyezbNXC3E14Sg=
github.com/tetafro/godot v1.5.0/go.mod h1:2oVxTBSftRTh4+MVfUaUXR6bn2GDXCaMcOG4Dk3rfio= github.com/tetafro/godot v1.5.4/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 h1:y4mJRFlM6fUyPhoXuFg/Yu02fg/nIPFMOY8tOqppoFg= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk=
github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460=
github.com/timonwong/loggercheck v0.10.1 h1:uVZYClxQFpw55eh+PIoqM7uAOHMrhVcDoWDery9R8Lg= github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M=
github.com/timonwong/loggercheck v0.10.1/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8= github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8=
github.com/tobischo/argon2 v0.1.0 h1:mwAx/9DK/4rP0xzNifb/XMAf43dU3eG1B3aeF88qu4Y= github.com/tobischo/argon2 v0.1.0 h1:mwAx/9DK/4rP0xzNifb/XMAf43dU3eG1B3aeF88qu4Y=
github.com/tobischo/argon2 v0.1.0/go.mod h1:4NLmLFwhWPbT66nRZNgcktV/mibJ6fESoeEp43h9GRw= github.com/tobischo/argon2 v0.1.0/go.mod h1:4NLmLFwhWPbT66nRZNgcktV/mibJ6fESoeEp43h9GRw=
github.com/tobischo/gokeepasslib/v3 v3.6.2 h1:SJzzllmNe7iZLudLJ3Lzdm3pDb++AJqZlmqG+SR8bVc= github.com/tobischo/gokeepasslib/v3 v3.6.2 h1:SJzzllmNe7iZLudLJ3Lzdm3pDb++AJqZlmqG+SR8bVc=
github.com/tobischo/gokeepasslib/v3 v3.6.2/go.mod h1:ga7HFqG0TZSLNao/QOnV2+yngkrf5186saPxSQ1Xp7o= github.com/tobischo/gokeepasslib/v3 v3.6.2/go.mod h1:ga7HFqG0TZSLNao/QOnV2+yngkrf5186saPxSQ1Xp7o=
github.com/tomarrell/wrapcheck/v2 v2.10.0 h1:SzRCryzy4IrAH7bVGG4cK40tNUhmVmMDuJujy4XwYDg= github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
github.com/tomarrell/wrapcheck/v2 v2.10.0/go.mod h1:g9vNIyhb5/9TQgumxQyOEqDHsmGYcGsVMOx/xGkqdMo= github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI=
github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA= github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA=
github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g= github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g=
github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8= github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8=
github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA= github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
github.com/uudashr/iface v1.3.1 h1:bA51vmVx1UIhiIsQFSNq6GZ6VPTk3WNMZgRiCe9R29U= github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU=
github.com/uudashr/iface v1.3.1/go.mod h1:4QvspiRd3JLPAEXBQ9AiZpLbJlrWWgRChOKDJEuQTdg= github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
github.com/xen0n/gosmopolitan v1.2.2 h1:/p2KTnMzwRexIW8GlKawsTWOxn7UHA+jCMF/V8HHtvU= github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
github.com/xen0n/gosmopolitan v1.2.2/go.mod h1:7XX7Mj61uLYrj0qmeN0zi7XDon9JRAEhYQqAPLVNTeg= github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs= github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs=
@@ -625,10 +655,14 @@ gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8=
go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28=
go-simpler.org/musttag v0.13.0 h1:Q/YAW0AHvaoaIbsPj3bvEI5/QFP7w696IMUpnKXQfCE= go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo=
go-simpler.org/musttag v0.13.0/go.mod h1:FTzIGeK6OkKlUDVpj0iQUXZLUO1Js9+mvykDQy9C5yM= go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
go-simpler.org/sloglint v0.9.0 h1:/40NQtjRx9txvsB/RN022KsUJU+zaaSb/9q9BSefSrE= go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s=
go-simpler.org/sloglint v0.9.0/go.mod h1:G/OrAF6uxj48sHahCzrbarVMptL2kjWTaUeC8+fOGww= go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ=
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE=
go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -646,16 +680,14 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -665,8 +697,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -677,14 +709,14 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs= golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs=
golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c= golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac h1:TSSpLIG4v+p0rPv1pNOQtl1I8knsO4S9trOxNMOLVP4= golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk=
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
@@ -711,13 +743,11 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -752,14 +782,12 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -827,9 +855,7 @@ golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -839,9 +865,7 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
@@ -852,9 +876,7 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
@@ -894,7 +916,6 @@ golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
@@ -904,22 +925,17 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200820010801-b793a1359eac/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20201023174141-c8cfbd0f21e6/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -1027,7 +1043,6 @@ gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1037,12 +1052,12 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU= mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo= mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f h1:lMpcwN6GxNbWtbpI1+xzFLSW8XzX0u72NttUGVFjO3U= mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI=
mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f/go.mod h1:RSLa7mKKCNeTTMHBw5Hsy2rfJmd6O2ivt9Dw9ZqCQpQ= mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+108 -9
View File
@@ -275,7 +275,7 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
var matches []rankedBrowserMatch var matches []rankedBrowserMatch
for _, entry := range displayModel.Entries { for _, entry := range displayModel.Entries {
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL) quality, score := classifyBrowserEntry(pageHost, entry)
if score == 0 { if score == 0 {
continue continue
} }
@@ -390,7 +390,7 @@ func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetB
if err != nil { if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error()) return nil, status.Error(codes.InvalidArgument, err.Error())
} }
if _, score := classifyBrowserEntryMatch(pageHost, entry.URL); score == 0 { if _, score := classifyBrowserEntry(pageHost, entry); score == 0 {
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page") return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
} }
} }
@@ -446,19 +446,22 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
} }
displayModel := visibleModel(model) displayModel := visibleModel(model)
internalPath := expandClientPath(displayModel, req.GetPath()) internalPath := expandClientPath(displayModel, req.GetPath())
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil {
return nil, err
}
model = displayModel model = displayModel
var entries []vault.Entry var entries []vault.Entry
if strings.TrimSpace(req.GetQuery()) != "" { if strings.TrimSpace(req.GetQuery()) != "" {
token, err := s.authenticateRequest(ctx)
if err != nil {
return nil, err
}
results := model.Search(req.GetQuery()) results := model.Search(req.GetQuery())
entries = make([]vault.Entry, 0, len(results)) entries, err = s.authorizedSearchEntries(ctx, model, token, internalPath, results)
for _, result := range results { if err != nil {
entries = append(entries, result.Entry) return nil, err
} }
} else { } else {
if _, err := s.authorizePathRequest(ctx, apitokens.OperationListEntries, internalPath); err != nil {
return nil, err
}
entries = model.EntriesInPath(internalPath) entries = model.EntriesInPath(internalPath)
} }
@@ -472,6 +475,49 @@ func (s *Server) ListEntries(ctx context.Context, req *keepassgov1.ListEntriesRe
return resp, nil return resp, nil
} }
func (s *Server) authorizedSearchEntries(ctx context.Context, model vault.Model, token apitokens.Token, path []string, results []vault.SearchResult) ([]vault.Entry, error) {
entries := make([]vault.Entry, 0, len(results))
var promptResource *apitokens.Resource
for _, result := range results {
entry := result.Entry
if !hasPathPrefix(path, entry.Path) {
continue
}
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: entry.Path}
switch evaluateAuthorization(model, token, apitokens.OperationListEntries, resource) {
case apitokens.DecisionAllow:
entries = append(entries, entry)
case apitokens.DecisionPrompt:
if promptResource == nil {
candidate := resource
promptResource = &candidate
}
}
}
if len(entries) != 0 || promptResource == nil {
return entries, nil
}
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationListEntries, *promptResource); err != nil {
return nil, err
}
return authorizedSearchEntriesWithinPath(path, promptResource.Path, results), nil
}
func authorizedSearchEntriesWithinPath(requestPath, approvedPath []string, results []vault.SearchResult) []vault.Entry {
entries := make([]vault.Entry, 0, len(results))
for _, result := range results {
entry := result.Entry
if !hasPathPrefix(requestPath, entry.Path) {
continue
}
if !hasPathPrefix(approvedPath, entry.Path) {
continue
}
entries = append(entries, entry)
}
return entries
}
func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) { func (s *Server) ListGroups(ctx context.Context, req *keepassgov1.ListGroupsRequest) (*keepassgov1.ListGroupsResponse, error) {
model, locked := s.snapshotModel() model, locked := s.snapshotModel()
if locked { if locked {
@@ -1063,6 +1109,52 @@ func normalizedBrowserEntryHost(raw string) string {
return "" return ""
} }
func browserURLFieldKey(key string) bool {
if len(key) <= len("URL") || !strings.EqualFold(key[:len("URL")], "URL") {
return false
}
for _, r := range key[len("URL"):] {
if r < '0' || r > '9' {
return false
}
}
return true
}
func browserEntryURLs(entry vault.Entry) []string {
urls := make([]string, 0, 1+len(entry.Fields))
if raw := strings.TrimSpace(entry.URL); raw != "" {
urls = append(urls, raw)
}
if len(entry.Fields) == 0 {
return urls
}
keys := slices.Collect(maps.Keys(entry.Fields))
slices.Sort(keys)
for _, key := range keys {
if !browserURLFieldKey(key) {
continue
}
if raw := strings.TrimSpace(entry.Fields[key]); raw != "" {
urls = append(urls, raw)
}
}
return urls
}
func classifyBrowserEntry(pageHost string, entry vault.Entry) (string, int) {
bestQuality := ""
bestScore := 0
for _, rawURL := range browserEntryURLs(entry) {
quality, score := classifyBrowserEntryMatch(pageHost, rawURL)
if score > bestScore {
bestQuality = quality
bestScore = score
}
}
return bestQuality, bestScore
}
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) { func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
entryHost := normalizedBrowserEntryHost(rawEntryURL) entryHost := normalizedBrowserEntryHost(rawEntryURL)
if entryHost == "" { if entryHost == "" {
@@ -1078,6 +1170,13 @@ func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
} }
} }
func hasPathPrefix(prefix, path []string) bool {
if len(prefix) > len(path) {
return false
}
return slices.Equal(prefix, path[:len(prefix)])
}
func visibleModel(model vault.Model) vault.Model { func visibleModel(model vault.Model) vault.Model {
out := model out := model
out.Entries = nil out.Entries = nil
+94
View File
@@ -294,6 +294,55 @@ func TestVaultServiceFindsBrowserLoginsForSchemeLessEntryURLs(t *testing.T) {
} }
} }
func TestVaultServiceFindsBrowserLoginsForCustomURLFields(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "night-fox-gitlab",
Title: "Night Fox GitLab",
Username: "nightfox",
Password: "vault-code",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"URL1": "gitlab.com",
},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
),
},
})
defer cleanup()
resp, err := client.FindBrowserLogins(tokenContext(defaultTestTokenSecret), &keepassgov1.FindBrowserLoginsRequest{
PageUrl: "https://gitlab.com/users/sign_in",
})
if err != nil {
t.Fatalf("FindBrowserLogins() error = %v", err)
}
if len(resp.Matches) != 1 {
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
}
if resp.Matches[0].Id != "night-fox-gitlab" {
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want night-fox-gitlab", resp.Matches[0].Id)
}
credential, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
Id: "night-fox-gitlab",
PageUrl: "https://gitlab.com/users/sign_in",
})
if err != nil {
t.Fatalf("GetBrowserCredential() error = %v", err)
}
if credential.GetId() != "night-fox-gitlab" {
t.Fatalf("GetBrowserCredential().Id = %q, want night-fox-gitlab", credential.GetId())
}
}
func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T) { func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1203,6 +1252,51 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) {
} }
} }
func TestVaultServiceSearchesEntriesWithinAuthorizedScope(t *testing.T) {
t.Parallel()
client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{
{
ID: "turk-codex",
Title: "Turk Codex GitLab",
Username: "basher",
Password: "chip-stack",
URL: "https://gitlab.com",
Path: []string{"keepass", "Joe", "codex"},
},
{
ID: "rusty-internet",
Title: "Rusty Internet GitLab",
Username: "rusty",
Password: "bellagio-stack",
URL: "https://gitlab.com",
Path: []string{"keepass", "Joe", "Internet"},
},
testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
),
},
})
defer cleanup()
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
Query: "GitLab",
})
if err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
if len(resp.Entries) != 1 {
t.Fatalf("len(ListEntries().Entries) = %d, want 1", len(resp.Entries))
}
if got := resp.Entries[0].Id; got != "turk-codex" {
t.Fatalf("ListEntries().Entries[0].Id = %q, want turk-codex", got)
}
if got := resp.Entries[0].Path; !slices.Equal(got, []string{"Joe", "codex"}) {
t.Fatalf("ListEntries().Entries[0].Path = %v, want [Joe codex]", got)
}
}
func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) { func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) {
t.Parallel() t.Parallel()
+142 -10
View File
@@ -140,6 +140,7 @@ type statePaths struct {
AutofillCachePath string AutofillCachePath string
PendingSharedVaultPath string PendingSharedVaultPath string
PendingSharedVaultNamePath string PendingSharedVaultNamePath string
PendingSharedLookupPath string
} }
type recentVaultRecord struct { type recentVaultRecord struct {
@@ -248,6 +249,7 @@ type ui struct {
entryFields widget.Editor entryFields widget.Editor
customFieldKeys []widget.Editor customFieldKeys []widget.Editor
customFieldValues []widget.Editor customFieldValues []widget.Editor
copyCustomFields []widget.Clickable
historyIndex widget.Editor historyIndex widget.Editor
groupName widget.Editor groupName widget.Editor
groupParentPath widget.Editor groupParentPath widget.Editor
@@ -401,6 +403,8 @@ type ui struct {
vaultRemoteCredentialClicks []widget.Clickable vaultRemoteCredentialClicks []widget.Clickable
syncRemoteCredentialClicks []widget.Clickable syncRemoteCredentialClicks []widget.Clickable
removeCustomFields []widget.Clickable removeCustomFields []widget.Clickable
toggleCustomFields []widget.Clickable
revealedCustomFields map[string]bool
state appstate.State state appstate.State
visible []entry visible []entry
currentPath []string currentPath []string
@@ -474,6 +478,8 @@ type ui struct {
autofillCachePath string autofillCachePath string
pendingSharedVaultPath string pendingSharedVaultPath string
pendingSharedVaultNamePath string pendingSharedVaultNamePath string
pendingSharedLookupPath string
pendingSharedLookupQuery string
editingEntry bool editingEntry bool
syncDefaultSourceMode syncSourceMode syncDefaultSourceMode syncSourceMode
syncDefaultDirection syncDirection syncDefaultDirection syncDirection
@@ -557,10 +563,10 @@ func newUIWithSession(mode string, sess appstate.CurrentSession, paths ...stateP
func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) *ui { func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) *ui {
th := material.NewTheme() th := material.NewTheme()
th.Palette.Bg = bgColor th.Bg = bgColor
th.Palette.Fg = color.NRGBA{R: 31, G: 29, B: 27, A: 255} th.Fg = color.NRGBA{R: 31, G: 29, B: 27, A: 255}
th.Palette.ContrastBg = accentColor th.ContrastBg = accentColor
th.Palette.ContrastFg = color.NRGBA{R: 255, G: 252, B: 247, A: 255} th.ContrastFg = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
u := &ui{ u := &ui{
mode: mode, mode: mode,
@@ -656,8 +662,10 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
autofillCachePath: paths.AutofillCachePath, autofillCachePath: paths.AutofillCachePath,
pendingSharedVaultPath: paths.PendingSharedVaultPath, pendingSharedVaultPath: paths.PendingSharedVaultPath,
pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath, pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath,
pendingSharedLookupPath: paths.PendingSharedLookupPath,
recentVaultGroups: map[string][]string{}, recentVaultGroups: map[string][]string{},
recentVaultUsedAt: map[string]time.Time{}, recentVaultUsedAt: map[string]time.Time{},
revealedCustomFields: map[string]bool{},
lifecycleAdvancedHidden: true, lifecycleAdvancedHidden: true,
historyHidden: true, historyHidden: true,
statusBannerTTL: statusBannerDuration, statusBannerTTL: statusBannerDuration,
@@ -704,6 +712,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.showStatusMessage("Some saved remote sign-ins came from an older KeePassGO build. Reopen those remotes and save them in the vault to migrate them.") u.showStatusMessage("Some saved remote sign-ins came from an older KeePassGO build. Reopen those remotes and save them in the vault to migrate them.")
} }
u.consumePendingSharedVaultImport() u.consumePendingSharedVaultImport()
u.consumePendingSharedLookup()
u.restoreStartupLifecycleTarget() u.restoreStartupLifecycleTarget()
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
u.loadUIPreferences() u.loadUIPreferences()
@@ -785,6 +794,7 @@ func defaultStatePaths(stateDir string) statePaths {
AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"), AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"), PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"),
PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"), PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"),
PendingSharedLookupPath: filepath.Join(baseDir, "pending-shared-lookup.txt"),
} }
} }
@@ -934,6 +944,7 @@ func (u *ui) handlePhoneBack() bool {
func (u *ui) resetPasswordPeek() { func (u *ui) resetPasswordPeek() {
u.showPassword = false u.showPassword = false
u.revealedCustomFields = map[string]bool{}
} }
func (u *ui) childGroups() []string { func (u *ui) childGroups() []string {
@@ -1529,7 +1540,7 @@ func approvalFact(theme *material.Theme, title, primary, secondary string) layou
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(theme, unit.Sp(16), strings.TrimSpace(primary)) lbl := material.Label(theme, unit.Sp(16), strings.TrimSpace(primary))
lbl.Color = theme.Palette.Fg lbl.Color = theme.Fg
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -1572,7 +1583,7 @@ func (u *ui) aboutDetailPanel(gtx layout.Context) layout.Dimensions {
}, },
func(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), currentAppVersion()) lbl := material.Label(u.theme, unit.Sp(14), currentAppVersion())
lbl.Color = u.theme.Palette.Fg lbl.Color = u.theme.Fg
return lbl.Layout(gtx) return lbl.Layout(gtx)
}, },
} }
@@ -1594,7 +1605,7 @@ func aboutFact(theme *material.Theme, title, primary, secondary string) layout.W
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(theme, unit.Sp(16), primary) lbl := material.Label(theme, unit.Sp(16), primary)
lbl.Color = theme.Palette.Fg lbl.Color = theme.Fg
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -1684,7 +1695,7 @@ func (u *ui) listPanelSearchRow(gtx layout.Context) layout.Dimensions {
} }
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.search, u.searchPlaceholder()) editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
editor.Color = u.theme.Palette.Fg editor.Color = u.theme.Fg
editor.HintColor = mutedColor editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
}) })
@@ -2147,6 +2158,12 @@ type detailViewMetrics struct {
cardGap unit.Dp cardGap unit.Dp
} }
type extraStringView struct {
Key string
Value string
Revealed bool
}
func (u *ui) detailViewContent(gtx layout.Context, item entry) layout.Dimensions { func (u *ui) detailViewContent(gtx layout.Context, item entry) layout.Dimensions {
rows := u.detailViewRows(item) rows := u.detailViewRows(item)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
@@ -2174,6 +2191,8 @@ func (u *ui) detailViewRows(item entry) []layout.Widget {
layout.Spacer{Height: unit.Dp(8)}.Layout, layout.Spacer{Height: unit.Dp(8)}.Layout,
u.detailNotesCard(item), u.detailNotesCard(item),
layout.Spacer{Height: metrics.cardGap}.Layout, layout.Spacer{Height: metrics.cardGap}.Layout,
u.detailExtraStringsCard,
layout.Spacer{Height: metrics.cardGap}.Layout,
u.attachmentSummaryPanel, u.attachmentSummaryPanel,
layout.Spacer{Height: metrics.cardGap}.Layout, layout.Spacer{Height: metrics.cardGap}.Layout,
u.historyPanel, u.historyPanel,
@@ -2333,6 +2352,115 @@ func (u *ui) detailNotesCard(item entry) layout.Widget {
} }
} }
func (u *ui) detailExtraStringsCard(gtx layout.Context) layout.Dimensions {
fields := u.detailExtraStrings()
u.ensureExtraStringClickables(len(fields))
if len(fields) == 0 {
return layout.Dimensions{}
}
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
children := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "EXTRA STRINGS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
}
for i, field := range fields {
index := i
item := field
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.detailExtraStringRow(gtx, index, item)
}))
if i < len(fields)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
func (u *ui) detailExtraStringRow(gtx layout.Context, index int, field extraStringView) layout.Dimensions {
for u.toggleCustomFields[index].Clicked(gtx) {
u.toggleExtraStringReveal(field.Key)
}
for u.copyCustomFields[index].Clicked(gtx) {
key := field.Key
u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction(key) })
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(field.Key))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), field.Value)
return lbl.Layout(gtx)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.inlinePasswordToggle(gtx, &u.toggleCustomFields[index], field.Revealed)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.copyCustomFields[index], u.copyIcon, "Copy extra string")
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
btn.Color = accentColor
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}),
)
}
func (u *ui) detailExtraStrings() []extraStringView {
item, ok := u.selectedEntry()
if !ok || len(item.Fields) == 0 {
return nil
}
keys := make([]string, 0, len(item.Fields))
for key := range item.Fields {
keys = append(keys, key)
}
slices.Sort(keys)
out := make([]extraStringView, 0, len(keys))
for _, key := range keys {
value := item.Fields[key]
revealed := u.revealedCustomFields[key]
if !revealed {
value = maskedSecretValue(value)
}
out = append(out, extraStringView{Key: key, Value: value, Revealed: revealed})
}
return out
}
func (u *ui) toggleExtraStringReveal(key string) {
if u.revealedCustomFields == nil {
u.revealedCustomFields = map[string]bool{}
}
u.revealedCustomFields[key] = !u.revealedCustomFields[key]
}
func (u *ui) ensureExtraStringClickables(count int) {
if len(u.copyCustomFields) < count {
clicks := make([]widget.Clickable, count)
copy(clicks, u.copyCustomFields)
u.copyCustomFields = clicks
}
if len(u.toggleCustomFields) < count {
clicks := make([]widget.Clickable, count)
copy(clicks, u.toggleCustomFields)
u.toggleCustomFields = clicks
}
}
func (u *ui) detailActionRow(gtx layout.Context) layout.Dimensions { func (u *ui) detailActionRow(gtx layout.Context) layout.Dimensions {
switch u.state.Section { switch u.state.Section {
case appstate.SectionTemplates: case appstate.SectionTemplates:
@@ -2990,7 +3118,11 @@ func (u *ui) detailPasswordValue() string {
if u.showPassword { if u.showPassword {
return item.Password return item.Password
} }
return strings.Repeat("•", max(8, len(item.Password))) return maskedSecretValue(item.Password)
}
func maskedSecretValue(value string) string {
return strings.Repeat("•", max(8, len(value)))
} }
func card(gtx layout.Context, w layout.Widget) layout.Dimensions { func card(gtx layout.Context, w layout.Widget) layout.Dimensions {
@@ -3144,7 +3276,7 @@ func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, purpose syncD
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(14), syncDialogSummaryText(purpose, source, direction)) lbl := material.Label(th, unit.Sp(14), syncDialogSummaryText(purpose, source, direction))
lbl.Color = th.Palette.Fg lbl.Color = th.Fg
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
) )
+44
View File
@@ -82,6 +82,8 @@ func (u *ui) setCustomFieldRows(fields map[string]string) {
u.customFieldKeys = nil u.customFieldKeys = nil
u.customFieldValues = nil u.customFieldValues = nil
u.removeCustomFields = nil u.removeCustomFields = nil
u.copyCustomFields = nil
u.toggleCustomFields = nil
if len(fields) == 0 { if len(fields) == 0 {
u.appendCustomFieldRow("", "") u.appendCustomFieldRow("", "")
return return
@@ -104,21 +106,53 @@ func (u *ui) appendCustomFieldRow(key, value string) {
u.customFieldKeys = append(u.customFieldKeys, keyEditor) u.customFieldKeys = append(u.customFieldKeys, keyEditor)
u.customFieldValues = append(u.customFieldValues, valueEditor) u.customFieldValues = append(u.customFieldValues, valueEditor)
u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{}) u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{})
u.copyCustomFields = append(u.copyCustomFields, widget.Clickable{})
u.toggleCustomFields = append(u.toggleCustomFields, widget.Clickable{})
} }
func (u *ui) removeCustomFieldRow(index int) { func (u *ui) removeCustomFieldRow(index int) {
u.ensureCustomFieldRowControls()
if index < 0 || index >= len(u.customFieldKeys) { if index < 0 || index >= len(u.customFieldKeys) {
return return
} }
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...) u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...) u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...) u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...)
u.copyCustomFields = append(u.copyCustomFields[:index], u.copyCustomFields[index+1:]...)
u.toggleCustomFields = append(u.toggleCustomFields[:index], u.toggleCustomFields[index+1:]...)
if len(u.customFieldKeys) == 0 { if len(u.customFieldKeys) == 0 {
u.appendCustomFieldRow("", "") u.appendCustomFieldRow("", "")
} }
} }
func (u *ui) ensureCustomFieldRowControls() {
if len(u.customFieldValues) < len(u.customFieldKeys) {
values := make([]widget.Editor, len(u.customFieldKeys))
copy(values, u.customFieldValues)
for i := len(u.customFieldValues); i < len(values); i++ {
values[i] = widget.Editor{SingleLine: true, Submit: false}
}
u.customFieldValues = values
}
if len(u.removeCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.removeCustomFields)
u.removeCustomFields = clicks
}
if len(u.copyCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.copyCustomFields)
u.copyCustomFields = clicks
}
if len(u.toggleCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.toggleCustomFields)
u.toggleCustomFields = clicks
}
}
func (u *ui) currentCustomFields() (map[string]string, error) { func (u *ui) currentCustomFields() (map[string]string, error) {
u.ensureCustomFieldRowControls()
fields := map[string]string{} fields := map[string]string{}
for i := range u.customFieldKeys { for i := range u.customFieldKeys {
key := strings.TrimSpace(u.customFieldKeys[i].Text()) key := strings.TrimSpace(u.customFieldKeys[i].Text())
@@ -399,6 +433,16 @@ func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
return service.Copy(model, u.state.SelectedEntryID, target) return service.Copy(model, u.state.SelectedEntryID, target)
} }
func (u *ui) copySelectedCustomFieldAction(key string) error {
model, err := u.state.Session.Current()
if err != nil {
return err
}
service := clipboard.Service{Writer: u.clipboardWriter}
return service.CopyCustomField(model, u.state.SelectedEntryID, key)
}
func (u *ui) generatePasswordAction() error { func (u *ui) generatePasswordAction() error {
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text()) profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
if err != nil { if err != nil {
+49
View File
@@ -4,8 +4,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"strings" "strings"
@@ -17,6 +19,8 @@ import (
"git.julianfamily.org/keepassgo/internal/webdav" "git.julianfamily.org/keepassgo/internal/webdav"
) )
var pendingSharedLookupURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
func (u *ui) createVaultAction() error { func (u *ui) createVaultAction() error {
key, err := u.currentMasterKey() key, err := u.currentMasterKey()
defer u.clearMasterPassword() defer u.clearMasterPassword()
@@ -78,6 +82,7 @@ func (u *ui) openVaultAction() error {
u.loadSecuritySettingsFromSession() u.loadSecuritySettingsFromSession()
u.editingEntry = false u.editingEntry = false
u.filter() u.filter()
u.applyPendingSharedLookup()
u.applyPendingLifecycleOpenIntent() u.applyPendingLifecycleOpenIntent()
return nil return nil
} }
@@ -120,6 +125,7 @@ func (u *ui) startOpenVaultAction() {
u.loadSecuritySettingsFromSession() u.loadSecuritySettingsFromSession()
u.editingEntry = false u.editingEntry = false
u.filter() u.filter()
u.applyPendingSharedLookup()
u.applyPendingLifecycleOpenIntent() u.applyPendingLifecycleOpenIntent()
return nil return nil
}, nil }, nil
@@ -741,6 +747,49 @@ func (u *ui) consumePendingSharedVaultImport() {
} }
} }
func normalizePendingSharedLookupQuery(raw string) string {
value := strings.TrimSpace(raw)
if value == "" {
return ""
}
if match := pendingSharedLookupURLPattern.FindString(value); match != "" {
value = match
}
if parsed, err := url.Parse(value); err == nil && strings.TrimSpace(parsed.Hostname()) != "" {
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
}
return value
}
func (u *ui) consumePendingSharedLookup() {
path := strings.TrimSpace(u.pendingSharedLookupPath)
if path == "" {
return
}
data, err := os.ReadFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
u.state.ErrorMessage = fmt.Sprintf("shared lookup: %v", err)
}
return
}
_ = os.Remove(path)
u.pendingSharedLookupQuery = normalizePendingSharedLookupQuery(string(data))
u.applyPendingSharedLookup()
}
func (u *ui) applyPendingSharedLookup() {
query := strings.TrimSpace(u.pendingSharedLookupQuery)
status, ok := u.state.Session.(sessionStatus)
if query == "" || !ok || !status.HasVault() || status.IsLocked() {
return
}
u.pendingSharedLookupQuery = ""
u.state.Section = appstate.SectionEntries
u.search.SetText(query)
u.filter()
}
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error { func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
target := u.importedVaultDestination(name) target := u.importedVaultDestination(name)
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
+1
View File
@@ -667,6 +667,7 @@ func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions {
if len(u.customFieldKeys) == 0 { if len(u.customFieldKeys) == 0 {
u.setCustomFieldRows(nil) u.setCustomFieldRows(nil)
} }
u.ensureCustomFieldRowControls()
return sectionCard(gtx, u.theme, "CUSTOM FIELDS", "Add key/value pairs. Changes are only saved when you save the entry.", func(gtx layout.Context) layout.Dimensions { return sectionCard(gtx, u.theme, "CUSTOM FIELDS", "Add key/value pairs. Changes are only saved when you save the entry.", func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+177
View File
@@ -3830,6 +3830,21 @@ func TestUILoadSelectedEntryIntoEditorPopulatesStructuredCustomFields(t *testing
} }
} }
func TestUIRemoveCustomFieldRowToleratesMissingClickables(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.customFieldKeys = []widget.Editor{{SingleLine: true}}
u.customFieldValues = []widget.Editor{{SingleLine: true}}
u.removeCustomFields = nil
u.removeCustomFieldRow(0)
if len(u.customFieldKeys) != 1 || len(u.customFieldValues) != 1 || len(u.removeCustomFields) != 1 {
t.Fatalf("custom field rows after remove with missing clickables = %d/%d/%d, want one blank row", len(u.customFieldKeys), len(u.customFieldValues), len(u.removeCustomFields))
}
}
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) { func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
t.Parallel() t.Parallel()
@@ -8390,6 +8405,9 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
if got := paths.PendingSharedVaultNamePath; got != filepath.Join(base, "pending-shared-vault-name.txt") { if got := paths.PendingSharedVaultNamePath; got != filepath.Join(base, "pending-shared-vault-name.txt") {
t.Fatalf("PendingSharedVaultNamePath = %q, want %q", got, filepath.Join(base, "pending-shared-vault-name.txt")) t.Fatalf("PendingSharedVaultNamePath = %q, want %q", got, filepath.Join(base, "pending-shared-vault-name.txt"))
} }
if got := paths.PendingSharedLookupPath; got != filepath.Join(base, "pending-shared-lookup.txt") {
t.Fatalf("PendingSharedLookupPath = %q, want %q", got, filepath.Join(base, "pending-shared-lookup.txt"))
}
} }
func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) { func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) {
@@ -8520,6 +8538,95 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) {
} }
} }
func TestUIConsumesPendingSharedLookupOnStartupWhenVaultIsAlreadyOpen(t *testing.T) {
t.Parallel()
dir := t.TempDir()
paths := statePaths{
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
PendingSharedLookupPath: filepath.Join(dir, "pending-shared-lookup.txt"),
}
if err := os.WriteFile(paths.PendingSharedLookupPath, []byte("https://bellagio.example.invalid/login\n"), 0o600); err != nil {
t.Fatalf("WriteFile(PendingSharedLookupPath) error = %v", err)
}
u := newUIWithSession("phone", &uiSession{model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-login", Title: "Bellagio", URL: "https://bellagio.example.invalid/login", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.example.invalid", Path: []string{"Crew", "Internet"}},
},
}}, paths)
if got := u.search.Text(); got != "bellagio.example.invalid" {
t.Fatalf("search after pending shared lookup = %q, want %q", got, "bellagio.example.invalid")
}
if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
t.Fatalf("filteredTitles() after pending shared lookup = %v, want [Bellagio]", got)
}
if _, err := os.Stat(paths.PendingSharedLookupPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Stat(PendingSharedLookupPath) error = %v, want not exist", err)
}
}
func TestNormalizePendingSharedLookupQueryExtractsURLFromTextSnippet(t *testing.T) {
t.Parallel()
raw := "Meet the crew at https://bellagio.example.invalid/login before the vault opens."
if got := normalizePendingSharedLookupQuery(raw); got != "bellagio.example.invalid" {
t.Fatalf("normalizePendingSharedLookupQuery() = %q, want %q", got, "bellagio.example.invalid")
}
}
func TestUIAppliesPendingSharedLookupAfterOpeningVault(t *testing.T) {
t.Parallel()
dir := t.TempDir()
paths := statePaths{
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
PendingSharedLookupPath: filepath.Join(dir, "pending-shared-lookup.txt"),
}
if err := os.WriteFile(paths.PendingSharedLookupPath, []byte("https://bellagio.example.invalid/login\n"), 0o600); err != nil {
t.Fatalf("WriteFile(PendingSharedLookupPath) error = %v", err)
}
key := vault.MasterKey{Password: "correct horse battery staple"}
vaultPath := filepath.Join(dir, "bellagio.kdbx")
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-login", Title: "Bellagio", URL: "https://bellagio.example.invalid/login", Path: []string{"Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.example.invalid", Path: []string{"Crew", "Internet"}},
},
}, key); err != nil {
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
if err := os.WriteFile(vaultPath, encoded.Bytes(), 0o600); err != nil {
t.Fatalf("WriteFile(vaultPath) error = %v", err)
}
u := newUIWithState("phone", &session.Manager{}, paths)
if got := u.search.Text(); got != "" {
t.Fatalf("search before open with pending shared lookup = %q, want empty", got)
}
u.vaultPath.SetText(vaultPath)
u.masterPassword.SetText(key.Password)
if err := u.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() with pending shared lookup error = %v", err)
}
if got := u.search.Text(); got != "bellagio.example.invalid" {
t.Fatalf("search after open with pending shared lookup = %q, want %q", got, "bellagio.example.invalid")
}
if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
t.Fatalf("filteredTitles() after open with pending shared lookup = %v, want [Bellagio]", got)
}
}
func TestUICurrentShareableVaultPathUsesSelectedVaultPath(t *testing.T) { func TestUICurrentShareableVaultPathUsesSelectedVaultPath(t *testing.T) {
t.Parallel() t.Parallel()
@@ -9034,6 +9141,76 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) {
} }
} }
func TestUIExtraStringValuesAreMaskedUntilRevealed(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
fields := u.detailExtraStrings()
if len(fields) != 1 {
t.Fatalf("len(detailExtraStrings()) = %d, want 1", len(fields))
}
if fields[0].Value != strings.Repeat("•", len("green-light")) {
t.Fatalf("detailExtraStrings()[0].Value hidden = %q, want masked value", fields[0].Value)
}
u.toggleExtraStringReveal("OTPSeed")
fields = u.detailExtraStrings()
if fields[0].Value != "green-light" {
t.Fatalf("detailExtraStrings()[0].Value revealed = %q, want green-light", fields[0].Value)
}
}
func TestUICopyExtraStringWritesClipboardWithoutLeakingStatus(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
})
writer := &memoryClipboardWriter{}
u.clipboardWriter = writer
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction("OTPSeed") })
if writer.content != "green-light" {
t.Fatalf("clipboard content = %q, want green-light", writer.content)
}
if u.state.StatusMessage != "copy extra string complete" {
t.Fatalf("state.StatusMessage = %q, want copy extra string complete", u.state.StatusMessage)
}
if strings.Contains(u.state.StatusMessage, "green-light") {
t.Fatalf("state.StatusMessage = %q, must not contain copied extra string value", u.state.StatusMessage)
}
}
func TestUIPasswordTogglePresentationMatchesVisibility(t *testing.T) { func TestUIPasswordTogglePresentationMatchesVisibility(t *testing.T) {
t.Parallel() t.Parallel()
+6 -6
View File
@@ -802,8 +802,8 @@ func (u *ui) prefillAdvancedSyncRemoteFromSavedBinding() {
} }
func (u *ui) syncDialogTitle() string { func (u *ui) syncDialogTitle() string {
switch { switch u.syncDialogPurpose {
case u.syncDialogPurpose == syncDialogPurposeRemoteSetup: case syncDialogPurposeRemoteSetup:
if _, ok := u.selectedVaultRemoteBinding(); ok { if _, ok := u.selectedVaultRemoteBinding(); ok {
return "Remote Sync Settings" return "Remote Sync Settings"
} }
@@ -814,8 +814,8 @@ func (u *ui) syncDialogTitle() string {
} }
func (u *ui) syncDialogDescription() string { func (u *ui) syncDialogDescription() string {
switch { switch u.syncDialogPurpose {
case u.syncDialogPurpose == syncDialogPurposeRemoteSetup: case syncDialogPurposeRemoteSetup:
if _, ok := u.selectedVaultRemoteBinding(); ok { if _, ok := u.selectedVaultRemoteBinding(); ok {
return "Review or change this vault's saved WebDAV target, credentials, and sync mode." return "Review or change this vault's saved WebDAV target, credentials, and sync mode."
} }
@@ -826,8 +826,8 @@ func (u *ui) syncDialogDescription() string {
} }
func (u *ui) syncDialogConfirmButtonLabel() string { func (u *ui) syncDialogConfirmButtonLabel() string {
switch { switch u.syncDialogPurpose {
case u.syncDialogPurpose == syncDialogPurposeRemoteSetup: case syncDialogPurposeRemoteSetup:
if _, ok := u.selectedVaultRemoteBinding(); ok { if _, ok := u.selectedVaultRemoteBinding(); ok {
return "Save Remote Sync Settings" return "Save Remote Sync Settings"
} }
+1 -1
View File
@@ -372,7 +372,7 @@ func settingsSummaryCard(gtx layout.Context, th *material.Theme, title, body str
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(13), body) lbl := material.Label(th, unit.Sp(13), body)
lbl.Color = th.Palette.Fg lbl.Color = th.Fg
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
) )
+1 -1
View File
@@ -199,7 +199,7 @@ func (u *ui) syncPasswordField(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
field := func(gtx layout.Context) layout.Dimensions { field := func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password") editor := material.Editor(u.theme, &u.syncRemotePassword, "Remote Password")
editor.Color = u.theme.Palette.Fg editor.Color = u.theme.Fg
editor.HintColor = mutedColor editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
} }
+87
View File
@@ -0,0 +1,87 @@
package autofillcache
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
type BindingsFile struct {
UpdatedAt string `json:"updatedAt"`
Apps map[string]string `json:"apps,omitempty"`
}
func ReadBindings(path string) (BindingsFile, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return BindingsFile{}, nil
}
return BindingsFile{}, err
}
var bindings BindingsFile
if err := json.Unmarshal(data, &bindings); err != nil {
return BindingsFile{}, err
}
if bindings.Apps == nil {
bindings.Apps = make(map[string]string)
}
return bindings, nil
}
func RememberBinding(path, rawTarget, entryID string, now time.Time) error {
bindings, err := ReadBindings(path)
if err != nil {
return err
}
if bindings.Apps == nil {
bindings.Apps = make(map[string]string)
}
target := strings.TrimSpace(rawTarget)
id := strings.TrimSpace(entryID)
if target == "" || id == "" {
return nil
}
bindings.Apps[target] = id
bindings.UpdatedAt = now.UTC().Format(time.RFC3339)
data, err := json.MarshalIndent(bindings, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
func ResolveWithBindings(cache File, bindings BindingsFile, rawTarget string) MatchResult {
target := strings.TrimSpace(rawTarget)
if entryID := strings.TrimSpace(bindings.Apps[target]); entryID != "" {
for _, entry := range cache.Entries {
if entry.ID == entryID {
return MatchResult{Status: MatchStatusFound, Entry: entry}
}
}
}
return Resolve(cache, rawTarget)
}
func ChooserCandidates(cache File, rawTarget string) []Entry {
if result := Resolve(cache, rawTarget); result.Status == MatchStatusFound {
return []Entry{result.Entry}
}
candidates := append([]Entry(nil), cache.Entries...)
slices.SortFunc(candidates, func(left, right Entry) int {
if cmp := strings.Compare(strings.ToLower(strings.TrimSpace(left.Title)), strings.ToLower(strings.TrimSpace(right.Title))); cmp != 0 {
return cmp
}
if cmp := strings.Compare(strings.ToLower(strings.Join(left.Path, "/")), strings.ToLower(strings.Join(right.Path, "/"))); cmp != 0 {
return cmp
}
return strings.Compare(left.ID, right.ID)
})
return candidates
}
+102
View File
@@ -0,0 +1,102 @@
package autofillcache
import (
"path/filepath"
"testing"
"time"
)
func TestResolvePrefersLearnedAndroidAppBinding(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "danny-ocean",
Title: "Bellagio Vault",
Username: "danny",
Password: "secret1",
URL: "https://bellagio.example.invalid/login",
Host: "bellagio.example.invalid",
},
{
ID: "rusty-ryan",
Title: "Mirage Crew",
Username: "rusty",
Password: "secret2",
URL: "https://mirage.example.invalid/login",
Host: "mirage.example.invalid",
},
},
}
bindings := BindingsFile{
Apps: map[string]string{
"androidapp://com.samsung.android.shealth": "rusty-ryan",
},
}
got := ResolveWithBindings(cache, bindings, "androidapp://com.samsung.android.shealth")
if got.Status != MatchStatusFound {
t.Fatalf("ResolveWithBindings() status = %q, want found", got.Status)
}
if got.Entry.ID != "rusty-ryan" {
t.Fatalf("ResolveWithBindings() entry = %q, want rusty-ryan", got.Entry.ID)
}
}
func TestChooserCandidatesFallBackToAllEntriesForUnknownAndroidApp(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "basher-tarr",
Title: "Bellagio Vault",
Username: "basher",
Password: "secret1",
URL: "https://bellagio.example.invalid/login",
Host: "bellagio.example.invalid",
Path: []string{"Crew"},
},
{
ID: "linus-caldwell",
Title: "Bank Floor",
Username: "linus",
Password: "secret2",
URL: "https://bank.example.invalid/sign-in",
Host: "bank.example.invalid",
Path: []string{"Operations"},
},
},
}
got := ChooserCandidates(cache, "androidapp://com.samsung.android.shealth")
if len(got) != 2 {
t.Fatalf("len(ChooserCandidates()) = %d, want 2", len(got))
}
if got[0].ID != "linus-caldwell" || got[1].ID != "basher-tarr" {
t.Fatalf("ChooserCandidates() = %#v, want title-sorted fallback candidates", got)
}
}
func TestRememberBindingPersistsAndroidAppSelection(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "autofill-bindings.json")
now := time.Date(2026, time.April, 13, 18, 0, 0, 0, time.UTC)
if err := RememberBinding(path, "androidapp://com.samsung.android.shealth", "saul-bloom", now); err != nil {
t.Fatalf("RememberBinding() error = %v", err)
}
got, err := ReadBindings(path)
if err != nil {
t.Fatalf("ReadBindings() error = %v", err)
}
if got.UpdatedAt != now.UTC().Format(time.RFC3339) {
t.Fatalf("UpdatedAt = %q, want %q", got.UpdatedAt, now.UTC().Format(time.RFC3339))
}
if got.Apps["androidapp://com.samsung.android.shealth"] != "saul-bloom" {
t.Fatalf("binding = %#v, want samsung health -> saul-bloom", got.Apps)
}
}
+182 -26
View File
@@ -2,12 +2,15 @@ package browserbridge
import ( import (
"context" "context"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -32,6 +35,11 @@ type Request struct {
BearerToken string `json:"bearerToken,omitempty"` BearerToken string `json:"bearerToken,omitempty"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
EntryID string `json:"entryId,omitempty"` EntryID string `json:"entryId,omitempty"`
Query string `json:"query,omitempty"`
Title string `json:"title,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Path []string `json:"path,omitempty"`
} }
type Response struct { type Response struct {
@@ -39,6 +47,7 @@ type Response struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Status *Status `json:"status,omitempty"` Status *Status `json:"status,omitempty"`
Matches []Match `json:"matches,omitempty"` Matches []Match `json:"matches,omitempty"`
SearchResults []Match `json:"searchResults,omitempty"`
Credential *Credential `json:"credential,omitempty"` Credential *Credential `json:"credential,omitempty"`
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
} }
@@ -77,11 +86,15 @@ type Connection struct {
type Client interface { type Client interface {
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error)
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
UpsertEntry(context.Context, *keepassgov1.Entry) (*keepassgov1.Entry, error)
} }
type Browser string type Browser string
type actionHandler func(context.Context, Client, Request, string) Response
const ( const (
BrowserFirefox Browser = "firefox" BrowserFirefox Browser = "firefox"
BrowserChrome Browser = "chrome" BrowserChrome Browser = "chrome"
@@ -163,34 +176,70 @@ func HandleRequest(ctx context.Context, req Request, grpcAddr string, client Cli
return Response{Success: false, Error: err.Error()} return Response{Success: false, Error: err.Error()}
} }
action := strings.TrimSpace(req.Action) action := strings.TrimSpace(req.Action)
switch action { handler, ok := actionHandlers[action]
case "status": if !ok {
status, err := statusResponse(ctx, client, conn.GRPCAddress)
if err != nil {
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: status, Version: responseVersion}
case "find-logins":
matches, err := findMatches(ctx, client, req.URL)
if err != nil {
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Matches: matches, Version: responseVersion}
case "get-login":
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
if err != nil {
if status := inferredActionStatus(conn.GRPCAddress, err); status != nil {
return Response{Success: false, Error: err.Error(), Status: status}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
}
return Response{Success: true, Status: availableStatus(conn.GRPCAddress), Credential: credential, Version: responseVersion}
default:
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)} return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
} }
return handler(ctx, client, req, conn.GRPCAddress)
}
var actionHandlers = map[string]actionHandler{
"status": handleStatusAction,
"find-logins": handleFindLoginsAction,
"search-logins": handleSearchLoginsAction,
"get-login": handleGetLoginAction,
"save-login": handleSaveLoginAction,
}
func handleStatusAction(ctx context.Context, client Client, _ Request, grpcAddress string) Response {
status, err := statusResponse(ctx, client, grpcAddress)
if err != nil {
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
}
return Response{Success: true, Status: status, Version: responseVersion}
}
func handleFindLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
matches, err := findMatches(ctx, client, req.URL)
if err != nil {
if status := inferredActionStatus(grpcAddress, err); status != nil {
return Response{Success: true, Status: status, Matches: nil, Version: responseVersion}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
}
return Response{Success: true, Status: availableStatus(grpcAddress), Matches: matches, Version: responseVersion}
}
func handleSearchLoginsAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
results, err := searchEntries(ctx, client, req.Query)
if err != nil {
if status := inferredActionStatus(grpcAddress, err); status != nil {
return Response{Success: true, Status: status, SearchResults: nil, Version: responseVersion}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
}
return Response{Success: true, Status: availableStatus(grpcAddress), SearchResults: results, Version: responseVersion}
}
func handleGetLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
if err != nil {
if status := inferredActionStatus(grpcAddress, err); status != nil {
return Response{Success: false, Error: err.Error(), Status: status}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
}
return Response{Success: true, Status: availableStatus(grpcAddress), Credential: credential, Version: responseVersion}
}
func handleSaveLoginAction(ctx context.Context, client Client, req Request, grpcAddress string) Response {
if err := saveLogin(ctx, client, req); err != nil {
if status := inferredActionStatus(grpcAddress, err); status != nil {
return Response{Success: false, Error: err.Error(), Status: status}
}
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(grpcAddress)}
}
return Response{Success: true, Status: availableStatus(grpcAddress), Version: responseVersion}
} }
func disconnectedStatus(addr string) *Status { func disconnectedStatus(addr string) *Status {
@@ -264,6 +313,113 @@ func loadCredential(ctx context.Context, client Client, entryID, rawURL string)
}, nil }, nil
} }
func saveLogin(ctx context.Context, client Client, req Request) error {
if strings.TrimSpace(req.Password) == "" {
return fmt.Errorf("browser save requires a password")
}
if strings.TrimSpace(req.EntryID) != "" {
entries, err := client.ListEntries(ctx, nil, "")
if err != nil {
return err
}
existing := findEntry(entries, req.EntryID)
if existing == nil {
return fmt.Errorf("entry %q was not found", strings.TrimSpace(req.EntryID))
}
entry := cloneEntry(existing)
entry.Title = coalesceTitle(req.Title, existing.Title, req.URL)
entry.Username = strings.TrimSpace(req.Username)
entry.Password = strings.TrimSpace(req.Password)
entry.Url = strings.TrimSpace(req.URL)
_, err = client.UpsertEntry(ctx, entry)
return err
}
path := append([]string(nil), req.Path...)
if len(path) == 0 {
return fmt.Errorf("browser save requires a target group path")
}
entry := &keepassgov1.Entry{
Id: newBrowserEntryID(),
Title: coalesceTitle(req.Title, "", req.URL),
Username: strings.TrimSpace(req.Username),
Password: strings.TrimSpace(req.Password),
Url: strings.TrimSpace(req.URL),
Path: path,
Fields: map[string]string{},
}
_, err := client.UpsertEntry(ctx, entry)
return err
}
func findEntry(entries []*keepassgov1.Entry, id string) *keepassgov1.Entry {
for _, entry := range entries {
if entry.GetId() == strings.TrimSpace(id) {
return entry
}
}
return nil
}
func cloneEntry(entry *keepassgov1.Entry) *keepassgov1.Entry {
if entry == nil {
return &keepassgov1.Entry{Fields: map[string]string{}}
}
fields := make(map[string]string, len(entry.GetFields()))
for key, value := range entry.GetFields() {
fields[key] = value
}
return &keepassgov1.Entry{
Id: entry.GetId(),
Title: entry.GetTitle(),
Username: entry.GetUsername(),
Password: entry.GetPassword(),
Url: entry.GetUrl(),
Notes: entry.GetNotes(),
Tags: append([]string(nil), entry.GetTags()...),
Path: append([]string(nil), entry.GetPath()...),
Fields: fields,
}
}
func coalesceTitle(title, fallback, rawURL string) string {
if trimmed := strings.TrimSpace(title); trimmed != "" {
return trimmed
}
if trimmed := strings.TrimSpace(fallback); trimmed != "" {
return trimmed
}
if parsed, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && strings.TrimSpace(parsed.Hostname()) != "" {
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
}
return "Browser Login"
}
func newBrowserEntryID() string {
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
return fmt.Sprintf("browser-%d", os.Getpid())
}
return hex.EncodeToString(buf[:])
}
func searchEntries(ctx context.Context, client Client, query string) ([]Match, error) {
resp, err := client.ListEntries(ctx, nil, strings.TrimSpace(query))
if err != nil {
return nil, err
}
out := make([]Match, 0, len(resp))
for _, entry := range resp {
out = append(out, Match{
ID: entry.GetId(),
Title: entry.GetTitle(),
Username: entry.GetUsername(),
URL: entry.GetUrl(),
Path: append([]string(nil), entry.GetPath()...),
})
}
return out, nil
}
func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) { func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) {
path := strings.TrimSpace(binaryPath) path := strings.TrimSpace(binaryPath)
if path == "" { if path == "" {
+126
View File
@@ -149,6 +149,110 @@ func TestHandleRequestGetLogin(t *testing.T) {
} }
} }
func TestHandleRequestSearchLogins(t *testing.T) {
t.Parallel()
client := &fakeClient{
entries: []*keepassgov1.Entry{
{Id: "rusty-gitlab", Title: "Rusty GitLab", Username: "rustyryan", Url: "gitlab.com", Path: []string{"Joe", "Internet"}},
},
}
resp := HandleRequest(context.Background(), Request{
Action: "search-logins",
BearerToken: "secret",
Query: "GitLab",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest(search-logins) success = false, error = %q", resp.Error)
}
if len(resp.SearchResults) != 1 || resp.SearchResults[0].ID != "rusty-gitlab" {
t.Fatalf("HandleRequest(search-logins).SearchResults = %#v, want rusty-gitlab", resp.SearchResults)
}
}
func TestHandleRequestSaveLoginUpdatesExistingEntry(t *testing.T) {
t.Parallel()
client := &fakeClient{
entries: []*keepassgov1.Entry{
{
Id: "vault-console",
Title: "Vault Console",
Username: "dannyocean",
Password: "old-password",
Url: "https://vault.example.invalid/login",
Path: []string{"Crew", "Internet"},
Fields: map[string]string{
"URL1": "vault.example.invalid",
"X-Role": "inside-man",
},
Tags: []string{"vault"},
Notes: "Original notes stay intact.",
},
},
}
resp := HandleRequest(context.Background(), Request{
Action: "save-login",
BearerToken: "secret",
EntryID: "vault-console",
Username: "dannyocean",
Password: "new-password",
URL: "https://vault.example.invalid/login",
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest(save-login update) success = false, error = %q", resp.Error)
}
if client.upserted == nil {
t.Fatal("HandleRequest(save-login update) did not upsert an entry")
}
if got := client.upserted.Id; got != "vault-console" {
t.Fatalf("upserted.Id = %q, want vault-console", got)
}
if got := client.upserted.Password; got != "new-password" {
t.Fatalf("upserted.Password = %q, want new-password", got)
}
if got := client.upserted.Fields["X-Role"]; got != "inside-man" {
t.Fatalf("upserted.Fields[X-Role] = %q, want inside-man", got)
}
if got := client.upserted.Notes; got != "Original notes stay intact." {
t.Fatalf("upserted.Notes = %q, want original notes", got)
}
}
func TestHandleRequestSaveLoginCreatesNewEntryInChosenPath(t *testing.T) {
t.Parallel()
client := &fakeClient{}
resp := HandleRequest(context.Background(), Request{
Action: "save-login",
BearerToken: "secret",
Title: "Bellagio Login",
Username: "linuscaldwell",
Password: "yellow-chip",
URL: "https://bellagio.example.invalid/login",
Path: []string{"Crew", "Internet"},
}, "", client)
if !resp.Success {
t.Fatalf("HandleRequest(save-login create) success = false, error = %q", resp.Error)
}
if client.upserted == nil {
t.Fatal("HandleRequest(save-login create) did not upsert an entry")
}
if got := client.upserted.Title; got != "Bellagio Login" {
t.Fatalf("upserted.Title = %q, want Bellagio Login", got)
}
if got := client.upserted.Username; got != "linuscaldwell" {
t.Fatalf("upserted.Username = %q, want linuscaldwell", got)
}
if got := client.upserted.Path; !slices.Equal(got, []string{"Crew", "Internet"}) {
t.Fatalf("upserted.Path = %v, want [Crew Internet]", got)
}
if got := client.upserted.Id; got == "" {
t.Fatal("upserted.Id = empty, want generated id")
}
}
func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) { func TestHandleRequestFindLoginsInfersLockedStatusFromRPC(t *testing.T) {
t.Parallel() t.Parallel()
@@ -309,10 +413,14 @@ func TestEnsureNativeHostManifestsInstallsFirefoxAndDiscoveredChromium(t *testin
type fakeClient struct { type fakeClient struct {
status *keepassgov1.GetSessionStatusResponse status *keepassgov1.GetSessionStatusResponse
matches []*keepassgov1.BrowserLoginMatch matches []*keepassgov1.BrowserLoginMatch
entries []*keepassgov1.Entry
credential *keepassgov1.GetBrowserCredentialResponse credential *keepassgov1.GetBrowserCredentialResponse
upserted *keepassgov1.Entry
err error err error
matchesErr error matchesErr error
entriesErr error
credentialErr error credentialErr error
upsertErr error
statusCalls int statusCalls int
} }
@@ -382,6 +490,16 @@ func (f *fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.
return f.matches, nil return f.matches, nil
} }
func (f *fakeClient) ListEntries(context.Context, []string, string) ([]*keepassgov1.Entry, error) {
if f.entriesErr != nil {
return nil, f.entriesErr
}
if f.err != nil {
return nil, f.err
}
return f.entries, nil
}
func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) { func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
if f.credentialErr != nil { if f.credentialErr != nil {
return nil, f.credentialErr return nil, f.credentialErr
@@ -394,3 +512,11 @@ func (f *fakeClient) GetBrowserCredential(context.Context, string, string) (*kee
} }
return f.credential, nil return f.credential, nil
} }
func (f *fakeClient) UpsertEntry(_ context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) {
if f.upsertErr != nil {
return nil, f.upsertErr
}
f.upserted = entry
return entry, nil
}
+19
View File
@@ -65,9 +65,28 @@ func (c *GRPCClient) FindBrowserLogins(ctx context.Context, pageURL string) ([]*
return resp.GetMatches(), nil return resp.GetMatches(), nil
} }
func (c *GRPCClient) ListEntries(ctx context.Context, path []string, query string) ([]*keepassgov1.Entry, error) {
resp, err := c.client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{
Path: append([]string(nil), path...),
Query: strings.TrimSpace(query),
})
if err != nil {
return nil, err
}
return resp.GetEntries(), nil
}
func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) { func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) {
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{ return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
Id: strings.TrimSpace(entryID), Id: strings.TrimSpace(entryID),
PageUrl: strings.TrimSpace(pageURL), PageUrl: strings.TrimSpace(pageURL),
}) })
} }
func (c *GRPCClient) UpsertEntry(ctx context.Context, entry *keepassgov1.Entry) (*keepassgov1.Entry, error) {
resp, err := c.client.UpsertEntry(ctx, &keepassgov1.UpsertEntryRequest{Entry: entry})
if err != nil {
return nil, err
}
return resp.GetEntry(), nil
}
+16
View File
@@ -45,6 +45,22 @@ func (s Service) Copy(model vault.Model, entryID string, target Target) error {
return nil return nil
} }
func (s Service) CopyCustomField(model vault.Model, entryID, key string) error {
entry, err := findEntry(model, entryID)
if err != nil {
return err
}
content, ok := entry.Fields[key]
if !ok {
return ErrUnsupportedTarget
}
if err := s.writer().WriteText(content); err != nil {
return writeError{err: err}
}
return nil
}
func (s Service) writer() Writer { func (s Service) writer() Writer {
if s.Writer != nil { if s.Writer != nil {
return s.Writer return s.Writer
+24
View File
@@ -48,6 +48,30 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
} }
} }
func TestServiceCopiesCustomField(t *testing.T) {
t.Parallel()
var writer memoryWriter
service := Service{Writer: &writer}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
}
if err := service.CopyCustomField(model, "vault-console", "OTPSeed"); err != nil {
t.Fatalf("CopyCustomField(vault-console, OTPSeed) error = %v", err)
}
if writer.content != "green-light" {
t.Fatalf("clipboard content = %q, want green-light", writer.content)
}
}
func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) { func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
t.Parallel() t.Parallel()
+6 -3
View File
@@ -5,10 +5,13 @@ import "git.julianfamily.org/keepassgo/internal/vault"
// HiddenRoot returns the single synthetic top-level vault group that should be // HiddenRoot returns the single synthetic top-level vault group that should be
// treated as an internal storage root rather than as a user-visible group. // treated as an internal storage root rather than as a user-visible group.
func HiddenRoot(model vault.Model) string { func HiddenRoot(model vault.Model) string {
if !hasGroup(model.Groups, []string{KeepassRoot}) { if hasGroup(model.Groups, []string{KeepassRoot}) {
return ""
}
return KeepassRoot return KeepassRoot
}
if usesTopLevelRoot(model, KeepassRoot) {
return KeepassRoot
}
return ""
} }
func hasGroup(groups [][]string, path []string) bool { func hasGroup(groups [][]string, path []string) bool {
+17
View File
@@ -24,3 +24,20 @@ func TestHiddenRootIgnoresRecycleBin(t *testing.T) {
t.Fatalf("HiddenRoot() = %q, want %q", got, "keepass") t.Fatalf("HiddenRoot() = %q, want %q", got, "keepass")
} }
} }
func TestHiddenRootFallsBackToEntryPathsWhenGroupsAreSparse(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "rusty", Title: "Rusty GitLab", Path: []string{"keepass", "Joe", "Internet"}},
},
Groups: [][]string{
{"Recycle Bin"},
},
}
if got := HiddenRoot(model); got != "keepass" {
t.Fatalf("HiddenRoot() = %q, want %q", got, "keepass")
}
}
@@ -56,6 +56,16 @@ package() {
"${pkgdir}/usr/share/keepassgo/browser-extension/background.js" "${pkgdir}/usr/share/keepassgo/browser-extension/background.js"
install -Dm644 browser/extension/content.js \ install -Dm644 browser/extension/content.js \
"${pkgdir}/usr/share/keepassgo/browser-extension/content.js" "${pkgdir}/usr/share/keepassgo/browser-extension/content.js"
install -Dm644 browser/extension/icons/icon-16.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-16.png"
install -Dm644 browser/extension/icons/icon-32.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-32.png"
install -Dm644 browser/extension/icons/icon-48.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-48.png"
install -Dm644 browser/extension/icons/icon-96.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-96.png"
install -Dm644 browser/extension/icons/icon-128.png \
"${pkgdir}/usr/share/keepassgo/browser-extension/icons/icon-128.png"
install -Dm644 browser/extension/manifest.chromium.json \ install -Dm644 browser/extension/manifest.chromium.json \
"${pkgdir}/usr/share/keepassgo/browser-extension/manifest.chromium.json" "${pkgdir}/usr/share/keepassgo/browser-extension/manifest.chromium.json"
install -Dm644 browser/extension/manifest.firefox.json \ install -Dm644 browser/extension/manifest.firefox.json \
+16
View File
@@ -0,0 +1,16 @@
FROM golang:1.26-bookworm AS gobase
FROM eclipse-temurin:25-jdk
RUN apt-get update && apt-get install -y --no-install-recommends \
findutils \
git \
make \
&& rm -rf /var/lib/apt/lists/*
COPY --from=gobase /usr/local/go /usr/local/go
ENV JAVA_HOME=/opt/java/openjdk
ENV PATH=/usr/local/go/bin:${PATH}
WORKDIR /workspace
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
import argparse
import json
import shutil
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SOURCE_DIR = REPO_ROOT / "browser" / "extension"
def main() -> int:
parser = argparse.ArgumentParser(description="Prepare a Firefox extension directory for web-ext.")
parser.add_argument("output_dir", help="directory to write the prepared extension into")
args = parser.parse_args()
output_dir = Path(args.output_dir).resolve()
if output_dir.exists():
shutil.rmtree(output_dir)
shutil.copytree(SOURCE_DIR, output_dir)
manifest = json.loads((output_dir / "manifest.firefox.json").read_text(encoding="utf-8"))
(output_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())