Compare commits

...

27 Commits

Author SHA1 Message Date
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
Joe Julian a88b8a824b Add explicit templates vault view 2026-04-13 08:50:33 -07:00
Joe Julian eccfb886ee Normalize vault storage root on open and create 2026-04-13 07:29:51 -07:00
Joe Julian 6790399e24 Hide physical keepass paths in token and approval UX 2026-04-13 07:18:33 -07:00
Joe Julian 9882d3fc04 Authorize logical root API paths against vault storage 2026-04-13 07:15:16 -07:00
Joe Julian 59cd01f8e7 Use vault views for entry and recycle-bin state 2026-04-13 07:12:32 -07:00
Joe Julian ea30775eb7 Add explicit vault view factories 2026-04-13 07:02:44 -07:00
Joe Julian 0ce25a9712 Add failing vault view behavior tests 2026-04-13 07:00:51 -07:00
Joe Julian 32e6fc6c90 Break vault root bug into commit-sized todo items 2026-04-13 06:59:55 -07:00
Joe Julian e8a48fb7aa Track vault root view bugfix 2026-04-13 06:58:11 -07:00
37 changed files with 2580 additions and 243 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.
+21 -4
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,12 @@ 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: Install native build dependencies - name: Install native build dependencies
shell: bash shell: bash
run: | run: |
@@ -78,6 +86,12 @@ 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: Install native build dependencies - name: Install native build dependencies
shell: bash shell: bash
run: | run: |
@@ -135,11 +149,14 @@ 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: Upload CI artifacts - name: Upload CI artifacts
+2 -1
View File
@@ -135,6 +135,7 @@ 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`.
- 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:
@@ -176,7 +177,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
+64 -4
View File
@@ -2,6 +2,7 @@ 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.1.0.1
@@ -11,6 +12,9 @@ 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
@@ -25,8 +29,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
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,6 +61,12 @@ 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)" \
@@ -50,12 +83,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" \
+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
+7
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"
@@ -41,6 +45,9 @@
<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" />
<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<>();
}
}
@@ -10,6 +10,7 @@ import java.io.IOException;
import java.io.InputStreamReader; 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.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -20,18 +21,7 @@ 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;
} }
@@ -57,6 +47,50 @@ final class AutofillCacheStore {
return chooseEntry(target, parentHost); return chooseEntry(target, parentHost);
} }
static Entry findByID(Context context, String entryID) {
if (entryID == null || entryID.trim().isEmpty()) {
return null;
}
for (Entry entry : readEntries(context)) {
if (entryID.equals(entry.id)) {
return entry;
}
}
return null;
}
static List<Entry> chooserCandidates(Context context, String rawTarget) {
List<Entry> entries = readEntries(context);
if (entries.isEmpty()) {
return entries;
}
Entry direct = findBestMatch(context, rawTarget);
if (direct != null) {
List<Entry> resolved = new ArrayList<>();
resolved.add(direct);
return resolved;
}
entries.sort(Comparator
.comparing((Entry entry) -> entry.title.toLowerCase(Locale.US))
.thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US))
.thenComparing(entry -> entry.id));
return entries;
}
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<>();
}
}
private static File findCacheFile(Context context) { private static File findCacheFile(Context context) {
List<File> candidates = new ArrayList<>(); List<File> candidates = new ArrayList<>();
File filesDir = context.getFilesDir(); File filesDir = context.getFilesDir();
@@ -103,16 +137,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 +174,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 {
@@ -293,20 +339,24 @@ final class AutofillCacheStore {
} }
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);
} }
} }
@@ -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,21 @@ public final class KeePassGOAutofillService extends AutofillService {
return; return;
} }
AutofillCacheStore.Entry entry = AutofillCacheStore.findBestMatch(this, target.matchTarget); AutofillCacheStore.Entry entry = findBoundOrBestMatch(target.matchTarget);
if (entry == null) { if (entry == null) {
FillResponse chooser = chooserResponse(target, fields);
if (chooser == 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, "returning chooser dataset for " + target.matchTarget);
callback.onSuccess(chooser);
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 +97,72 @@ public final class KeePassGOAutofillService extends AutofillService {
callback.onSuccess(); callback.onSuccess();
} }
private AutofillCacheStore.Entry findBoundOrBestMatch(String matchTarget) {
String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget);
if (!entryID.isEmpty()) {
AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID);
if (bound != null) {
return bound;
}
}
return AutofillCacheStore.findBestMatch(this, matchTarget);
}
private FillResponse directFillResponse(AutofillCacheStore.Entry entry, ParsedFields fields) {
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
presentation.setTextViewText(
android.R.id.text1,
entry.title + " (" + entry.username + ")"
);
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 new FillResponse.Builder()
.addDataset(dataset.build())
.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,9 +11,11 @@ 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";
@@ -36,6 +39,7 @@ public final class SharedVaultImportActivity extends Activity {
} }
private void handleIntent(Intent intent) { private void handleIntent(Intent intent) {
logIntent(intent);
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,10 +59,29 @@ 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;
} }
@@ -69,7 +92,7 @@ public final class SharedVaultImportActivity extends Activity {
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.kdbx");
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");
} }
@@ -88,6 +111,17 @@ public final class SharedVaultImportActivity extends Activity {
} }
} }
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 +157,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");
+208 -23
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("|")
@@ -265,8 +421,8 @@ 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(
@@ -286,7 +442,11 @@ const contentTestExports = {
chooseFillTargets, chooseFillTargets,
inlineMatchSummary, inlineMatchSummary,
domainLabel, domainLabel,
shouldShowInlineOverlay shouldShowInlineOverlay,
fieldHintText,
scopeHintText,
hasAuthFlowSignals,
authFlowCandidate
}; };
if (isNodeTestEnv) { if (isNodeTestEnv) {
@@ -303,7 +463,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 +626,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,9 +717,10 @@ 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;
} }
@@ -558,17 +739,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;
} }
+77 -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,19 @@ 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 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);
}); });
+5 -2
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
@@ -193,7 +197,6 @@ require (
go.uber.org/multierr v1.6.0 // indirect go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect go.uber.org/zap v1.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // 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.33.0 // indirect
+18 -18
View File
@@ -37,15 +37,15 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
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.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
@@ -132,10 +132,12 @@ github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iy
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ= github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ=
github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc= github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc=
github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww= github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4 h1:QD3KxSJ59L2lxG6MXBjNHxiQO2RmxTQ3XcK+wO44WOg= github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 h1:UZdrvid2JFwnvPlUSEFlE794XZL4Jmrj8fuxfcLECJE=
github.com/chromedp/cdproto v0.0.0-20191114225735-6626966fbae4/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g= github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.5.2 h1:W8xBXQuUnd2dZK0SN/lyVwsQM7KgW+kY5HGnntms194= github.com/chromedp/chromedp v0.13.6 h1:xlNunMyzS5bu3r/QKrb3fzX6ow3WBQ6oao+J65PGZxk=
github.com/chromedp/chromedp v0.5.2/go.mod h1:rsTo/xRo23KZZwFmWk2Ui79rBaVRRATCjLzNQlOFSiA= 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=
@@ -180,6 +182,8 @@ github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4a
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=
@@ -224,12 +228,12 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW
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/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
@@ -370,8 +374,6 @@ github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0
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=
@@ -404,8 +406,6 @@ github.com/macabu/inamedparam v0.1.3 h1:2tk/phHkMlEL/1GNe/Yf6kkR/hkcUdAEY3L0hjYV
github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I= github.com/macabu/inamedparam v0.1.3/go.mod h1:93FLICAIk/quk7eaPPQvbzihUdn/QkGDwIZEoLtpH6I=
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/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
+64 -8
View File
@@ -291,7 +291,7 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
}, },
score: score, score: score,
resource: resource, resource: resource,
decision: apitokens.Evaluate(token, apitokens.OperationListEntries, resource), decision: evaluateAuthorization(model, token, apitokens.OperationListEntries, resource),
}) })
} }
slices.SortFunc(matches, func(a, b rankedBrowserMatch) int { slices.SortFunc(matches, func(a, b rankedBrowserMatch) int {
@@ -1220,7 +1220,9 @@ func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Oper
} }
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) { func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
switch apitokens.Evaluate(token, op, resource) { model, _ := s.snapshotModel()
displayResource := displayAuthorizationResource(resource)
switch evaluateAuthorization(model, token, op, resource) {
case apitokens.DecisionAllow: case apitokens.DecisionAllow:
return token, nil return token, nil
case apitokens.DecisionDeny: case apitokens.DecisionDeny:
@@ -1232,9 +1234,9 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
result, err := s.approvals.Request(ctx, token, op, resource) result, err := s.approvals.Request(ctx, token, op, displayResource)
if result.Rule != nil { if result.Rule != nil {
if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil { if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil {
return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr) return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr)
@@ -1248,7 +1250,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
return token, nil return token, nil
case errors.Is(err, apiapproval.ErrRequestDenied): case errors.Is(err, apiapproval.ErrRequestDenied):
@@ -1258,7 +1260,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval") return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval")
case errors.Is(err, apiapproval.ErrRequestCanceled): case errors.Is(err, apiapproval.ErrRequestCanceled):
@@ -1268,7 +1270,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled") return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled")
case errors.Is(err, apiapproval.ErrRequestTimedOut): case errors.Is(err, apiapproval.ErrRequestTimedOut):
@@ -1278,7 +1280,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out") return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
case errors.Is(err, context.Canceled): case errors.Is(err, context.Canceled):
@@ -1337,6 +1339,60 @@ func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bo
return false return false
} }
func evaluateAuthorization(model vault.Model, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) apitokens.Decision {
return apitokens.Evaluate(canonicalizeTokenForAuthorization(model, token), op, canonicalizeAuthorizationResource(model, resource))
}
func canonicalizeTokenForAuthorization(model vault.Model, token apitokens.Token) apitokens.Token {
token.Policies = append([]apitokens.PolicyRule(nil), token.Policies...)
for i := range token.Policies {
token.Policies[i].Resource = canonicalizeAuthorizationResource(model, token.Policies[i].Resource)
}
return token
}
func canonicalizeAuthorizationResource(model vault.Model, resource apitokens.Resource) apitokens.Resource {
resource.Path = canonicalAuthorizationPath(model, resource.Path)
return resource
}
func displayAuthorizationResource(resource apitokens.Resource) apitokens.Resource {
resource.Path = displayAuthorizationPath(resource.Path)
return resource
}
func canonicalAuthorizationPath(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == vaultview.KeepassRoot {
return append([]string(nil), path...)
}
if path[0] == "Root" {
if len(path) > 1 && (path[1] == "Templates" || path[1] == "API Tokens") {
return append([]string(nil), path...)
}
return vaultview.VaultRoot(model).ToPhysicalPath(path[1:])
}
if path[0] == "Templates" || path[0] == "API Tokens" {
return append([]string(nil), path...)
}
return vaultview.VaultRoot(model).ToPhysicalPath(path)
}
func displayAuthorizationPath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == vaultview.KeepassRoot {
return append([]string{"Root"}, append([]string(nil), path[1:]...)...)
}
if path[0] == "Root" {
return append([]string(nil), path...)
}
return append([]string(nil), path...)
}
func copyOperation(target string) apitokens.Operation { func copyOperation(target string) apitokens.Operation {
switch clipboard.Target(target) { switch clipboard.Target(target) {
case clipboard.TargetUsername: case clipboard.TargetUsername:
+59 -7
View File
@@ -316,7 +316,7 @@ func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T)
Path: []string{"keepass", "Joe", "Internet"}, Path: []string{"keepass", "Joe", "Internet"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
), ),
}, },
}) })
@@ -396,6 +396,58 @@ func TestVaultServiceFindsBrowserLoginsRechecksChildPoliciesAfterPrompt(t *testi
} }
} }
func TestVaultServiceApprovalRequestsUseLogicalRootPathForPhysicalVault(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "codex-nextcloud",
Title: "Nextcloud (codex)",
Username: "jjulian",
Password: "secret-1",
URL: "https://nextcloud.example.invalid",
Path: []string{"keepass", "Joe", "codex"},
},
testAPITokenEntry(t),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Joe", "codex"},
},
}
client, _, service, cleanup := newTestHarnessForModel(t, model)
defer cleanup()
service.approvals = apiapproval.NewBroker(time.Minute)
respCh := make(chan *keepassgov1.ListEntriesResponse, 1)
errCh := make(chan error, 1)
go func() {
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
Path: []string{"Joe", "codex"},
})
respCh <- resp
errCh <- err
}()
pending := waitForServerPendingApproval(t, service, 1)[0]
if got := pending.Resource.Path; !slices.Equal(got, []string{"Root", "Joe", "codex"}) {
t.Fatalf("pending.Resource.Path = %v, want [Root Joe codex]", got)
}
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
t.Fatalf("ResolveApproval(allow once) error = %v", err)
}
if err := <-errCh; err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
resp := <-respCh
if len(resp.GetEntries()) != 1 || resp.GetEntries()[0].GetId() != "codex-nextcloud" {
t.Fatalf("ListEntries().Entries = %#v, want codex-nextcloud after approval", resp.GetEntries())
}
}
func TestVaultServiceDoesNotMatchSpecificBrowserEntryToParentDomain(t *testing.T) { func TestVaultServiceDoesNotMatchSpecificBrowserEntryToParentDomain(t *testing.T) {
t.Parallel() t.Parallel()
@@ -452,7 +504,7 @@ func TestVaultServiceListEntriesHidesSingleInternalVaultRoot(t *testing.T) {
Path: []string{"keepass", "Joe", "codex"}, Path: []string{"keepass", "Joe", "codex"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
@@ -491,7 +543,7 @@ func TestVaultServiceListEntriesHidesSingleInternalVaultRootWhenRecycleBinExists
Path: []string{"keepass", "Joe", "codex"}, Path: []string{"keepass", "Joe", "codex"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
@@ -523,7 +575,7 @@ func TestVaultServiceListGroupsHidesSingleInternalVaultRoot(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
@@ -549,7 +601,7 @@ func TestVaultServiceListGroupsHidesSingleInternalVaultRootWhenRecycleBinExists(
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
@@ -1387,8 +1439,8 @@ func TestVaultServiceUpsertsNewEntryWithinAuthorizedGroupScope(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
+234 -24
View File
@@ -11,6 +11,7 @@ import (
"git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav" "git.julianfamily.org/keepassgo/internal/webdav"
) )
@@ -30,6 +31,9 @@ const (
SectionAbout Section = "about" SectionAbout Section = "about"
) )
const entriesRootLabel = "Root"
const templatesRootLabel = "Templates"
type CurrentSession interface { type CurrentSession interface {
Current() (vault.Model, error) Current() (vault.Model, error)
} }
@@ -98,6 +102,10 @@ type RemoteOpenableSession interface {
OpenRemote(webdav.Client, string, vault.MasterKey) error OpenRemote(webdav.Client, string, vault.MasterKey) error
} }
type WarningSession interface {
ConsumeWarning() string
}
type SecurityConfigurableSession interface { type SecurityConfigurableSession interface {
ConfigureSecurity(vault.SecuritySettings) error ConfigureSecurity(vault.SecuritySettings) error
SecuritySettings() vault.SecuritySettings SecuritySettings() vault.SecuritySettings
@@ -375,7 +383,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
} }
if s.Section == SectionEntries { if s.Section == SectionEntries {
return entriesInPath(model.Entries, s.CurrentPath), nil return entriesInPath(entries, logicalEntriesPathForModel(model, s.CurrentPath)), nil
} }
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 { if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil return entries, nil
@@ -395,13 +403,13 @@ func (s *State) ChildGroups() ([]string, error) {
} }
if s.Section != SectionEntries { if s.Section != SectionEntries {
if s.Section == SectionTemplates && len(s.CurrentPath) == 0 { if s.Section == SectionTemplates {
return childGroups(s.entriesForSection(model), []string{"Templates"}), nil return vaultview.VaultTemplates(model).ChildGroups(templatesViewPath(s.CurrentPath)), nil
} }
return childGroups(s.entriesForSection(model), s.CurrentPath), nil return childGroups(s.entriesForSection(model), s.CurrentPath), nil
} }
return model.ChildGroups(s.CurrentPath), nil return vaultview.VaultRoot(model).ChildGroups(entriesViewPathForModel(model, s.CurrentPath)), nil
} }
func (s *State) SelectVisibleIndex(index int) error { func (s *State) SelectVisibleIndex(index int) error {
@@ -445,13 +453,13 @@ func (s *State) currentModel() (vault.Model, error) {
func (s *State) entriesForSection(model vault.Model) []vault.Entry { func (s *State) entriesForSection(model vault.Model) []vault.Entry {
switch s.Section { switch s.Section {
case SectionTemplates: case SectionTemplates:
return slices.Clone(model.Templates) return logicalTemplateEntries(vaultview.VaultTemplates(model).EntriesUnderPath(nil))
case SectionRecycleBin: case SectionRecycleBin:
return slices.Clone(model.RecycleBin) return logicalEntries(vaultview.VaultRecycleBin(model).EntriesUnderPath(nil))
case SectionAPITokens, SectionAPIAudit, SectionAbout: case SectionAPITokens, SectionAPIAudit, SectionAbout:
return nil return nil
default: default:
return slices.Clone(model.Entries) return logicalEntries(vaultview.VaultRoot(model).EntriesUnderPath(nil))
} }
} }
@@ -459,11 +467,11 @@ func (s State) SearchPathContext(entry vault.Entry) string {
path := slices.Clone(entry.Path) path := slices.Clone(entry.Path)
switch s.Section { switch s.Section {
case SectionTemplates: case SectionTemplates:
if len(path) == 0 || path[0] != "Templates" { path = logicalTemplatePath(path)
path = append([]string{"Templates"}, path...)
}
case SectionRecycleBin: case SectionRecycleBin:
path = append([]string{"Recycle Bin"}, path...) path = append([]string{"Recycle Bin"}, logicalEntriesPath(path)...)
case SectionEntries:
path = logicalEntriesPath(path)
} }
return strings.Join(path, " / ") return strings.Join(path, " / ")
} }
@@ -520,6 +528,163 @@ func filterEntries(entries []vault.Entry, query string) []vault.Entry {
return out return out
} }
func logicalEntriesPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return []string{entriesRootLabel}
}
if path[0] == entriesRootLabel {
return append([]string(nil), path...)
}
if usesPhysicalEntriesRoot(model) && path[0] == vaultview.KeepassRoot {
path = path[1:]
}
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
}
func logicalEntriesPath(path []string) []string {
if len(path) == 0 {
return []string{entriesRootLabel}
}
if path[0] == entriesRootLabel {
return append([]string(nil), path...)
}
if path[0] == vaultview.KeepassRoot {
path = path[1:]
}
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
}
func logicalTemplatePath(path []string) []string {
if len(path) == 0 {
return []string{templatesRootLabel}
}
if path[0] == templatesRootLabel {
return append([]string(nil), path...)
}
return append([]string{templatesRootLabel}, append([]string(nil), path...)...)
}
func templatesViewPath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == templatesRootLabel {
return append([]string(nil), path[1:]...)
}
return append([]string(nil), path...)
}
func entriesViewPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
switch {
case usesPhysicalEntriesRoot(model) && path[0] == entriesRootLabel:
return append([]string(nil), path[1:]...)
case usesLogicalEntriesRoot(model):
return append([]string(nil), path...)
case path[0] == entriesRootLabel:
return append([]string(nil), path[1:]...)
default:
return append([]string(nil), path...)
}
}
func logicalEntry(entry vault.Entry) vault.Entry {
entry.Path = logicalEntriesPath(entry.Path)
for i := range entry.History {
entry.History[i] = logicalEntry(entry.History[i])
}
return entry
}
func logicalEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = logicalEntry(entries[i])
}
return out
}
func logicalTemplateEntry(entry vault.Entry) vault.Entry {
entry.Path = logicalTemplatePath(entry.Path)
for i := range entry.History {
entry.History[i] = logicalTemplateEntry(entry.History[i])
}
return entry
}
func logicalTemplateEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = logicalTemplateEntry(entries[i])
}
return out
}
func entryForModel(model vault.Model, entry vault.Entry) vault.Entry {
entry.Path = entriesViewPathForModel(model, entry.Path)
for i := range entry.History {
entry.History[i] = entryForModel(model, entry.History[i])
}
return entry
}
func templateEntryForModel(entry vault.Entry) vault.Entry {
entry.Path = templatesViewPath(entry.Path)
for i := range entry.History {
entry.History[i] = templateEntryForModel(entry.History[i])
}
return entry
}
func usesPhysicalEntriesRoot(model vault.Model) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return true
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func usesLogicalEntriesRoot(model vault.Model) bool {
for _, group := range model.Groups {
if len(group) > 0 && group[0] == entriesRootLabel {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
return true
}
}
return false
}
func childGroups(entries []vault.Entry, path []string) []string { func childGroups(entries []vault.Entry, path []string) []string {
seen := map[string]bool{} seen := map[string]bool{}
var groups []string var groups []string
@@ -544,6 +709,33 @@ func childGroups(entries []vault.Entry, path []string) []string {
return groups return groups
} }
func sectionGroupView(model vault.Model, section Section) vaultview.View {
switch section {
case SectionTemplates:
return vaultview.VaultTemplates(model)
default:
return vaultview.VaultRoot(model)
}
}
func sectionGroupViewPath(model vault.Model, section Section, path []string) []string {
switch section {
case SectionTemplates:
return templatesViewPath(path)
default:
return entriesViewPathForModel(model, path)
}
}
func sectionGroupLogicalPath(model vault.Model, section Section, path []string) []string {
switch section {
case SectionTemplates:
return logicalTemplatePath(path)
default:
return logicalEntriesPathForModel(model, path)
}
}
func (s *State) DeleteSelectedEntry() error { func (s *State) DeleteSelectedEntry() error {
session, ok := s.Session.(MutableSession) session, ok := s.Session.(MutableSession)
if !ok { if !ok {
@@ -594,7 +786,7 @@ func (s *State) UpsertEntry(entry vault.Entry) error {
return err return err
} }
model.UpsertEntry(entry) model.UpsertEntry(vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, entry)))
session.Replace(model) session.Replace(model)
s.SelectedEntryID = entry.ID s.SelectedEntryID = entry.ID
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
@@ -611,7 +803,7 @@ func (s *State) UpsertTemplate(entry vault.Entry) error {
return err return err
} }
model.UpsertTemplate(entry) model.UpsertTemplate(vaultview.VaultTemplates(model).ToPhysicalEntry(templateEntryForModel(entry)))
session.Replace(model) session.Replace(model)
s.SelectedEntryID = entry.ID s.SelectedEntryID = entry.ID
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
@@ -628,7 +820,7 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
return vault.Entry{}, err return vault.Entry{}, err
} }
entry, err := model.InstantiateTemplate(templateID, overrides) entry, err := model.InstantiateTemplate(templateID, vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, overrides)))
if err != nil { if err != nil {
return vault.Entry{}, err return vault.Entry{}, err
} }
@@ -638,7 +830,7 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
if err := s.markDirtyAndAutoSave(); err != nil { if err := s.markDirtyAndAutoSave(); err != nil {
return vault.Entry{}, err return vault.Entry{}, err
} }
return entry, nil return logicalEntry(entry), nil
} }
func (s *State) DeleteTemplate(id string) error { func (s *State) DeleteTemplate(id string) error {
@@ -726,7 +918,13 @@ func (s *State) Unlock(key vault.MasterKey) error {
return fmt.Errorf("session is not lockable") return fmt.Errorf("session is not lockable")
} }
return session.Unlock(key) if err := session.Unlock(key); err != nil {
return err
}
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil
} }
func (s *State) ChangeMasterKey(key vault.MasterKey) error { func (s *State) ChangeMasterKey(key vault.MasterKey) error {
@@ -888,6 +1086,9 @@ func (s *State) OpenVault(path string, key vault.MasterKey) error {
s.CurrentPath = nil s.CurrentPath = nil
s.SelectedEntryID = "" s.SelectedEntryID = ""
s.Dirty = false s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil return nil
} }
@@ -918,6 +1119,9 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
s.CurrentPath = nil s.CurrentPath = nil
s.SelectedEntryID = "" s.SelectedEntryID = ""
s.Dirty = false s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil return nil
} }
@@ -993,7 +1197,8 @@ func (s *State) CreateGroup(name string) error {
return err return err
} }
model.CreateGroup(s.CurrentPath, name) view := sectionGroupView(model, s.Section)
model.CreateGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), name)
session.Replace(model) session.Replace(model)
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
} }
@@ -1007,13 +1212,16 @@ func (s *State) MoveCurrentGroup(parent []string) error {
if err != nil { if err != nil {
return err return err
} }
current := append([]string(nil), s.CurrentPath...) view := sectionGroupView(model, s.Section)
if err := model.MoveGroup(current, parent); err != nil { current := sectionGroupLogicalPath(model, s.Section, s.CurrentPath)
currentViewPath := sectionGroupViewPath(model, s.Section, current)
parentViewPath := sectionGroupViewPath(model, s.Section, parent)
if err := model.MoveGroup(view.ToPhysicalPath(currentViewPath), view.ToPhysicalPath(parentViewPath)); err != nil {
return err return err
} }
session.Replace(model) session.Replace(model)
if len(current) > 0 { if len(currentViewPath) > 0 {
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1]) s.CurrentPath = sectionGroupLogicalPath(model, s.Section, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1]))
} }
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
} }
@@ -1029,7 +1237,8 @@ func (s *State) RenameCurrentGroup(newName string) error {
return err return err
} }
if err := model.RenameGroup(s.CurrentPath, newName); err != nil { view := sectionGroupView(model, s.Section)
if err := model.RenameGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), newName); err != nil {
return err return err
} }
@@ -1051,7 +1260,7 @@ func (s *State) MoveSelectedEntry(path []string) error {
return err return err
} }
if err := model.MoveEntry(s.SelectedEntryID, path); err != nil { if err := model.MoveEntry(s.SelectedEntryID, vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, path))); err != nil {
return err return err
} }
@@ -1070,7 +1279,8 @@ func (s *State) DeleteCurrentGroup() error {
return err return err
} }
if err := model.DeleteGroup(s.CurrentPath); err != nil { view := sectionGroupView(model, s.Section)
if err := model.DeleteGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath))); err != nil {
return err return err
} }
+74 -5
View File
@@ -27,7 +27,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
}, },
}, },
}, },
CurrentPath: []string{"Crew", "Internet"}, CurrentPath: []string{"Root", "Crew", "Internet"},
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -583,6 +583,75 @@ func TestSearchPathContextIncludesSectionRoots(t *testing.T) {
} }
} }
func TestVisibleEntriesUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security Office"},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
titles := make([]string, 0, len(got))
for _, entry := range got {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("VisibleEntries() titles = %v, want [Bellagio Vault Console]", titles)
}
if !slices.Equal(got[0].Path, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("VisibleEntries()[0].Path = %v, want [Root Crew Internet]", got[0].Path)
}
}
func TestChildGroupsUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security Office"},
},
},
},
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("ChildGroups() = %v, want [Crew]", got)
}
}
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) { func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1634,11 +1703,11 @@ func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
t.Fatalf("CreateGroup() error = %v", err) t.Fatalf("CreateGroup() error = %v", err)
} }
if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) { if got := session.model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got) t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
} }
if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { if got := session.model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got) t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
} }
} }
+15 -1
View File
@@ -71,7 +71,7 @@ func AuditEventSearchTerms(event apiaudit.Event) string {
event.ClientName, event.ClientName,
string(event.Operation), string(event.Operation),
AuditOperationLabel(event.Operation), AuditOperationLabel(event.Operation),
strings.Join(event.Resource.Path, " / "), FormatResourcePath(event.Resource.Path),
event.Resource.EntryID, event.Resource.EntryID,
event.Message, event.Message,
} }
@@ -91,3 +91,17 @@ func AuditEventSearchTerms(event apiaudit.Event) string {
} }
return strings.ToLower(strings.Join(parts, " ")) return strings.ToLower(strings.Join(parts, " "))
} }
func DisplayResourcePath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == "keepass" {
return append([]string{"Root"}, append([]string(nil), path[1:]...)...)
}
return append([]string(nil), path...)
}
func FormatResourcePath(path []string) string {
return strings.Join(DisplayResourcePath(path), " / ")
}
+3 -3
View File
@@ -336,7 +336,7 @@ func (u *ui) editAPIPolicyRuleAction(index int) error {
} }
u.apiPolicyGroupScope = true u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true u.apiPolicyGroupScopeW.Value = true
u.apiPolicyPath.SetText(strings.Join(rule.Resource.Path, " / ")) u.apiPolicyPath.SetText(apiui.FormatResourcePath(rule.Resource.Path))
u.apiPolicyEntryID.SetText("") u.apiPolicyEntryID.SetText("")
return nil return nil
} }
@@ -476,7 +476,7 @@ func policyRuleParts(rule apitokens.PolicyRule) (string, string, string) {
if rule.Resource.Kind == apitokens.ResourceEntry { if rule.Resource.Kind == apitokens.ResourceEntry {
resource = "Entry: " + rule.Resource.EntryID resource = "Entry: " + rule.Resource.EntryID
} else if len(rule.Resource.Path) > 0 { } else if len(rule.Resource.Path) > 0 {
resource = strings.Join(rule.Resource.Path, " / ") resource = apiui.FormatResourcePath(rule.Resource.Path)
} }
return effect, operation, resource return effect, operation, resource
} }
@@ -1211,5 +1211,5 @@ func formatAuditResource(resource apitokens.Resource) string {
if len(resource.Path) == 0 { if len(resource.Path) == 0 {
return "/" return "/"
} }
return strings.Join(resource.Path, " / ") return apiui.FormatResourcePath(resource.Path)
} }
+2 -1
View File
@@ -27,6 +27,7 @@ import (
"git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/appstate" "git.julianfamily.org/keepassgo/internal/appstate"
apiui "git.julianfamily.org/keepassgo/internal/appui/api"
detailmodel "git.julianfamily.org/keepassgo/internal/appui/detail" detailmodel "git.julianfamily.org/keepassgo/internal/appui/detail"
detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout" detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout"
lifecyclemodel "git.julianfamily.org/keepassgo/internal/appui/lifecycle" lifecyclemodel "git.julianfamily.org/keepassgo/internal/appui/lifecycle"
@@ -1511,7 +1512,7 @@ func approvalResourceText(request apiapproval.Request) string {
} }
case apitokens.ResourceGroup: case apitokens.ResourceGroup:
if len(request.Resource.Path) > 0 { if len(request.Resource.Path) > 0 {
return strings.Join(request.Resource.Path, " / ") return apiui.FormatResourcePath(request.Resource.Path)
} }
} }
return "Vault root" return "Vault root"
+11 -2
View File
@@ -24,6 +24,7 @@ import (
"git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
) )
func (u *ui) bannerSurface() uiBanner { func (u *ui) bannerSurface() uiBanner {
@@ -558,6 +559,11 @@ func copyPath(path []string) []string {
} }
func pathExistsInModel(model vault.Model, path []string) bool { func pathExistsInModel(model vault.Model, path []string) bool {
if len(path) > 0 && path[0] == "Root" {
view := vaultview.VaultRoot(model)
viewPath := entriesViewPathForModel(model, path)
return len(view.EntriesInPath(viewPath)) > 0 || len(view.ChildGroups(viewPath)) > 0 || hasExactGroup(model, view.ToPhysicalPath(viewPath))
}
return len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) return len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path)
} }
@@ -569,9 +575,12 @@ func normalizeEntriesPathWithoutModel(path []string, root string) []string {
return []string{root} return []string{root}
} }
if path[0] == "Root" { if path[0] == "Root" {
return copyPath(path)
}
if path[0] == vaultview.KeepassRoot {
return append([]string{root}, path[1:]...) return append([]string{root}, path[1:]...)
} }
return copyPath(path) return append([]string{root}, copyPath(path)...)
} }
func (u *ui) normalizedEntriesPath(path []string) []string { func (u *ui) normalizedEntriesPath(path []string) []string {
@@ -590,7 +599,7 @@ func (u *ui) normalizedEntriesPath(path []string) []string {
return []string{root} return []string{root}
} }
if path[0] == "Root" && root != "" { if path[0] == "Root" && root != "" {
candidate := append([]string{root}, path[1:]...) candidate := copyPath(path)
if pathExistsInModel(model, candidate) { if pathExistsInModel(model, candidate) {
return candidate return candidate
} }
+108 -32
View File
@@ -2441,8 +2441,8 @@ func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Session.Current() error = %v", err) t.Fatalf("Session.Current() error = %v", err)
} }
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
} }
} }
@@ -2675,8 +2675,8 @@ func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Session.Current() error = %v", err) t.Fatalf("Session.Current() error = %v", err)
} }
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
} }
} }
@@ -3180,8 +3180,8 @@ func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
} }
} }
@@ -3241,8 +3241,8 @@ func TestUIAdvancedSynchronizeFromImportedLocalVaultMergesIntoCurrentVault(t *te
if err != nil { if err != nil {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
} }
} }
@@ -3406,8 +3406,8 @@ func TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
} }
} }
@@ -3606,11 +3606,11 @@ func TestUICreateGroupActionSupportsNestedSubgroups(t *testing.T) {
t.Fatalf("createGroupAction() error = %v", err) t.Fatalf("createGroupAction() error = %v", err)
} }
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) { if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got) t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
} }
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got) t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
} }
} }
@@ -5125,8 +5125,8 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
t.Fatalf("openVaultAction() error = %v", err) t.Fatalf("openVaultAction() error = %v", err)
} }
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath = %v, want [keepass]", got) t.Fatalf("currentPath = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() = %v, want root slash path", got) t.Fatalf("displayPath() = %v, want root slash path", got)
@@ -5136,6 +5136,39 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
} }
} }
func TestUIOpenVaultShowsLegacyRootNormalizationWarning(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
var encoded bytes.Buffer
if err := vault.SaveKDBX(&encoded, vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Crew", "Internet"}},
},
Groups: [][]string{
{"Root"},
{"Root", "Crew"},
{"Root", "Crew", "Internet"},
},
}, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
t.Fatalf("WriteFile(legacy-root.kdbx) error = %v", err)
}
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.vaultPath.SetText(path)
if err := u.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
if got := u.state.StatusMessage; !strings.Contains(got, "legacy vault root") {
t.Fatalf("StatusMessage = %q, want legacy vault root normalization warning", got)
}
}
func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) { func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) {
t.Parallel() t.Parallel()
@@ -5152,8 +5185,8 @@ func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) {
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath = %v, want [keepass]", got) t.Fatalf("currentPath = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() = %v, want root slash path", got) t.Fatalf("displayPath() = %v, want root slash path", got)
@@ -5174,15 +5207,15 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T)
}) })
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after initial entries section = %v, want [keepass]", got) t.Fatalf("currentPath after initial entries section = %v, want [Root]", got)
} }
u.showAPITokensSection() u.showAPITokensSection()
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after returning to entries = %v, want [keepass]", got) t.Fatalf("currentPath after returning to entries = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got) t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got)
@@ -5215,8 +5248,8 @@ func TestUISyncCurrentPathNormalizesHiddenRootAfterSectionSwitch(t *testing.T) {
u.syncCurrentPath() u.syncCurrentPath()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after syncCurrentPath() = %v, want [keepass]", got) t.Fatalf("currentPath after syncCurrentPath() = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() after syncCurrentPath() = %v, want root slash path", got) t.Fatalf("displayPath() after syncCurrentPath() = %v, want root slash path", got)
@@ -5235,7 +5268,7 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
}) })
u.showEntriesSection() u.showEntriesSection()
u.setCurrentPath([]string{"keepass", "Crew", "Internet"}) u.setCurrentPath([]string{"Root", "Crew", "Internet"})
u.search.SetText("amazon") u.search.SetText("amazon")
u.filter() u.filter()
u.state.SelectedEntryID = "amazon" u.state.SelectedEntryID = "amazon"
@@ -5245,8 +5278,8 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
u.showAPITokensSection() u.showAPITokensSection()
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("currentPath after returning to entries = %v, want [keepass Crew Internet]", got) t.Fatalf("currentPath after returning to entries = %v, want [Root Crew Internet]", got)
} }
if got := u.search.Text(); got != "amazon" { if got := u.search.Text(); got != "amazon" {
t.Fatalf("search text after returning to entries = %q, want amazon", got) t.Fatalf("search text after returning to entries = %q, want amazon", got)
@@ -8073,7 +8106,7 @@ func TestUISelectedRemoteCardUsesLocalCacheSummaryForBoundRemote(t *testing.T) {
wantDetails := []string{ wantDetails := []string{
"/vaults/cache", "/vaults/cache",
"Sync target: home.kdbx · dav.example.invalid", "Sync target: home.kdbx · dav.example.invalid",
"Last group: Root / Internet", "Last group: Internet",
} }
if !slices.Equal(gotDetails, wantDetails) { if !slices.Equal(gotDetails, wantDetails) {
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
@@ -8105,7 +8138,7 @@ func TestUISelectedRemoteCardUsesConnectionSummaryWithoutLocalCache(t *testing.T
wantDetails := []string{ wantDetails := []string{
"Path: vaults/home.kdbx", "Path: vaults/home.kdbx",
"Server: https://dav.example.invalid", "Server: https://dav.example.invalid",
"Last group: Root / Internet", "Last group: Internet",
} }
if !slices.Equal(gotDetails, wantDetails) { if !slices.Equal(gotDetails, wantDetails) {
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
@@ -8480,7 +8513,7 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) {
if err := reopened.openVaultAction(); err != nil { if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction(imported) error = %v", err) t.Fatalf("openVaultAction(imported) error = %v", err)
} }
reopened.state.NavigateToPath([]string{"Crew", "Internet"}) reopened.state.NavigateToPath([]string{"Root", "Crew", "Internet"})
reopened.filter() reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
t.Fatalf("filteredTitles() = %v, want [Bellagio]", got) t.Fatalf("filteredTitles() = %v, want [Bellagio]", got)
@@ -9327,8 +9360,8 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
if err := u.useCurrentGroupForPolicyAction(); err != nil { if err := u.useCurrentGroupForPolicyAction(); err != nil {
t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err) t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err)
} }
if got := u.apiPolicyPath.Text(); got != "bashertarr" { if got := u.apiPolicyPath.Text(); got != "Crew / bashertarr" {
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "bashertarr") t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "Crew / bashertarr")
} }
if !u.apiPolicyGroupScopeW.Value { if !u.apiPolicyGroupScopeW.Value {
t.Fatal("apiPolicyGroupScopeW.Value = false, want true") t.Fatal("apiPolicyGroupScopeW.Value = false, want true")
@@ -9355,6 +9388,49 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
} }
} }
func TestUIEditAPIPolicyRuleHidesPhysicalKeepassRoot(t *testing.T) {
t.Parallel()
token := apitokens.Token{
ID: "token-1",
Name: "Crew Browser",
Policies: []apitokens.PolicyRule{{
Effect: apitokens.EffectAllow,
Operation: apitokens.OperationListEntries,
Resource: apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: []string{"keepass", "Crew", "bashertarr"},
},
}},
}
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
token.Entry(apitokens.EntryPath),
},
})
u.showAPITokensSection()
u.state.SelectedEntryID = "token-1"
if err := u.editAPIPolicyRuleAction(0); err != nil {
t.Fatalf("editAPIPolicyRuleAction() error = %v", err)
}
if got := u.apiPolicyPath.Text(); got != "Root / Crew / bashertarr" {
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "Root / Crew / bashertarr")
}
}
func TestUIAuditAndApprovalFormattingHidePhysicalKeepassRoot(t *testing.T) {
t.Parallel()
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Crew", "bashertarr"}}
if got := formatAuditResource(resource); got != "Root / Crew / bashertarr" {
t.Fatalf("formatAuditResource() = %q, want %q", got, "Root / Crew / bashertarr")
}
if got := approvalResourceText(apiapproval.Request{Resource: resource}); got != "Root / Crew / bashertarr" {
t.Fatalf("approvalResourceText() = %q, want %q", got, "Root / Crew / bashertarr")
}
}
func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) { func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) {
t.Parallel() t.Parallel()
+67 -12
View File
@@ -1260,15 +1260,11 @@ func (u *ui) recentVaultGroup(path string) []string {
} }
func (u *ui) hiddenVaultRoot() string { func (u *ui) hiddenVaultRoot() string {
if u.state.Section != appstate.SectionEntries { if u.state.Section == appstate.SectionEntries {
return "" return "Root"
} }
model, err := u.state.Session.Current()
if err != nil {
return "" return ""
} }
return vaultview.HiddenRoot(model)
}
func (u *ui) enterHiddenVaultRoot() { func (u *ui) enterHiddenVaultRoot() {
root := u.hiddenVaultRoot() root := u.hiddenVaultRoot()
@@ -1294,7 +1290,7 @@ func (u *ui) restoreRecentVaultGroup(path string) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { if pathExistsInModel(model, saved) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
@@ -1317,7 +1313,7 @@ func (u *ui) restoreRecentRemoteGroup(baseURL, path string) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { if pathExistsInModel(model, saved) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
@@ -1339,7 +1335,7 @@ func (u *ui) restoreEntriesPath(path []string) {
u.setCurrentPath(path) u.setCurrentPath(path)
return return
} }
if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) { if pathExistsInModel(model, path) {
u.setCurrentPath(path) u.setCurrentPath(path)
return return
} }
@@ -1415,6 +1411,22 @@ func pathHasPrefix(path, prefix []string) bool {
return slices.Equal(path[:len(prefix)], prefix) return slices.Equal(path[:len(prefix)], prefix)
} }
func entriesViewPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
switch {
case usesPhysicalEntriesRoot(model) && path[0] == "Root":
return append([]string(nil), path[1:]...)
case usesLogicalEntriesRoot(model):
return append([]string(nil), path...)
case path[0] == "Root":
return append([]string(nil), path[1:]...)
default:
return append([]string(nil), path...)
}
}
func hasExactGroup(model vault.Model, path []string) bool { func hasExactGroup(model vault.Model, path []string) bool {
for _, group := range model.Groups { for _, group := range model.Groups {
if slices.Equal(group, path) { if slices.Equal(group, path) {
@@ -1433,12 +1445,14 @@ func (u *ui) currentGroupDeletionState() (bool, string) {
if err != nil { if err != nil {
return false, "" return false, ""
} }
path := append([]string(nil), u.currentPath...) view := vaultview.VaultRoot(model)
if len(model.ChildGroups(path)) > 0 { path := entriesViewPathForModel(model, u.currentPath)
physicalPath := view.ToPhysicalPath(path)
if len(model.ChildGroups(physicalPath)) > 0 {
return false, "This group contains child groups. Move or delete them before removing the group." return false, "This group contains child groups. Move or delete them before removing the group."
} }
for _, item := range model.Entries { for _, item := range model.Entries {
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) { if slices.Equal(item.Path, physicalPath) || pathHasPrefix(item.Path, physicalPath) {
return false, "This group contains entries. Move or delete them before removing the group." return false, "This group contains entries. Move or delete them before removing the group."
} }
} }
@@ -1450,6 +1464,47 @@ func (u *ui) currentGroupDeletionState() (bool, string) {
return true, "Deleting this empty group will not remove any entries." return true, "Deleting this empty group will not remove any entries."
} }
func usesPhysicalEntriesRoot(model vault.Model) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return true
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func usesLogicalEntriesRoot(model vault.Model) bool {
for _, group := range model.Groups {
if len(group) > 0 && group[0] == "Root" {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == "Root" {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == "Root" {
return true
}
}
return false
}
func (u *ui) deleteGroupPendingConfirmation() bool { func (u *ui) deleteGroupPendingConfirmation() bool {
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath) return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
} }
+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)
}
}
+72 -25
View File
@@ -12,6 +12,7 @@ import (
"strings" "strings"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav" "git.julianfamily.org/keepassgo/internal/webdav"
) )
@@ -31,6 +32,7 @@ type Manager struct {
remoteClient *webdav.Client remoteClient *webdav.Client
remotePath string remotePath string
remoteVersion webdav.Version remoteVersion webdav.Version
warning string
} }
type PreparedLocalOpen struct { type PreparedLocalOpen struct {
@@ -40,6 +42,7 @@ type PreparedLocalOpen struct {
Key vault.MasterKey Key vault.MasterKey
Encoded []byte Encoded []byte
VaultRoot string VaultRoot string
Warning string
} }
type PreparedRemoteOpen struct { type PreparedRemoteOpen struct {
@@ -51,6 +54,7 @@ type PreparedRemoteOpen struct {
Encoded []byte Encoded []byte
VaultRoot string VaultRoot string
RemoteVersion webdav.Version RemoteVersion webdav.Version
Warning string
} }
type PreparedUnlock struct { type PreparedUnlock struct {
@@ -58,6 +62,7 @@ type PreparedUnlock struct {
Config *vault.KDBXConfig Config *vault.KDBXConfig
Key vault.MasterKey Key vault.MasterKey
VaultRoot string VaultRoot string
Warning string
} }
func (m *Manager) SecuritySettings() vault.SecuritySettings { func (m *Manager) SecuritySettings() vault.SecuritySettings {
@@ -74,7 +79,7 @@ func (m *Manager) ConfigureSecurity(settings vault.SecuritySettings) error {
} }
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error { func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
root := detectSingleVaultRoot(model) root := vaultview.KeepassRoot
model = normalizeUnderRoot(model, root) model = normalizeUnderRoot(model, root)
var encoded bytes.Buffer var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil { if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
@@ -86,6 +91,7 @@ func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
m.vaultRoot = root m.vaultRoot = root
m.encoded = encoded.Bytes() m.encoded = encoded.Bytes()
m.locked = false m.locked = false
m.warning = ""
return nil return nil
} }
@@ -118,6 +124,12 @@ func (m *Manager) Open(path string, key vault.MasterKey) error {
return nil return nil
} }
func (m *Manager) ConsumeWarning() string {
warning := strings.TrimSpace(m.warning)
m.warning = ""
return warning
}
func (m *Manager) Save() error { func (m *Manager) Save() error {
if m.remoteClient != nil && m.remotePath != "" { if m.remoteClient != nil && m.remotePath != "" {
return m.SaveRemote() return m.SaveRemote()
@@ -254,7 +266,7 @@ func (m *Manager) SaveAs(path string) error {
func (m *Manager) Replace(model vault.Model) { func (m *Manager) Replace(model vault.Model) {
root := m.vaultRoot root := m.vaultRoot
if root == "" { if root == "" {
root = detectSingleVaultRoot(model) root = vaultview.KeepassRoot
} }
m.model = normalizeUnderRoot(model, root) m.model = normalizeUnderRoot(model, root)
m.vaultRoot = root m.vaultRoot = root
@@ -305,12 +317,13 @@ func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, erro
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err) return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
} }
return PreparedLocalOpen{ return PreparedLocalOpen{
Model: model, Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config, Config: config,
Path: path, Path: path,
Key: key, Key: key,
Encoded: content, Encoded: content,
VaultRoot: detectSingleVaultRoot(model), VaultRoot: vaultview.KeepassRoot,
Warning: normalizationWarning(model),
}, nil }, nil
} }
@@ -324,14 +337,15 @@ func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err) return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
} }
return PreparedRemoteOpen{ return PreparedRemoteOpen{
Model: model, Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config, Config: config,
Client: client, Client: client,
Path: path, Path: path,
Key: key, Key: key,
Encoded: content, Encoded: content,
VaultRoot: detectSingleVaultRoot(model), VaultRoot: vaultview.KeepassRoot,
RemoteVersion: version, RemoteVersion: version,
Warning: normalizationWarning(model),
}, nil }, nil
} }
@@ -341,10 +355,11 @@ func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error)
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err) return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
} }
return PreparedUnlock{ return PreparedUnlock{
Model: model, Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config, Config: config,
Key: key, Key: key,
VaultRoot: detectSingleVaultRoot(model), VaultRoot: vaultview.KeepassRoot,
Warning: normalizationWarning(model),
}, nil }, nil
} }
@@ -359,6 +374,7 @@ func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
m.remoteClient = nil m.remoteClient = nil
m.remotePath = "" m.remotePath = ""
m.remoteVersion = webdav.Version{} m.remoteVersion = webdav.Version{}
m.warning = prepared.Warning
} }
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) { func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
@@ -372,6 +388,7 @@ func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
m.remotePath = prepared.Path m.remotePath = prepared.Path
m.remoteVersion = prepared.RemoteVersion m.remoteVersion = prepared.RemoteVersion
m.path = "" m.path = ""
m.warning = prepared.Warning
} }
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) { func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
@@ -380,6 +397,7 @@ func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
m.key = prepared.Key m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot m.vaultRoot = prepared.VaultRoot
m.locked = false m.locked = false
m.warning = prepared.Warning
} }
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error { func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
@@ -584,9 +602,7 @@ func (m *Manager) reloadCurrentLocal(merged vault.Model) error {
return err return err
} }
m.model = merged m.model = merged
if root := detectSingleVaultRoot(merged); root != "" { m.vaultRoot = vaultview.KeepassRoot
m.vaultRoot = root
}
m.encoded = encoded m.encoded = encoded
m.locked = false m.locked = false
return nil return nil
@@ -603,9 +619,7 @@ func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err) return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err)
} }
m.model = merged m.model = merged
if root := detectSingleVaultRoot(merged); root != "" { m.vaultRoot = vaultview.KeepassRoot
m.vaultRoot = root
}
m.encoded = encoded m.encoded = encoded
m.remoteVersion = version m.remoteVersion = version
m.locked = false m.locked = false
@@ -867,17 +881,6 @@ func mergePeerGroups(primary, secondary [][]string) [][]string {
return out return out
} }
func detectSingleVaultRoot(model vault.Model) string {
if len(model.EntriesInPath(nil)) != 0 {
return ""
}
groups := model.ChildGroups(nil)
if len(groups) != 1 {
return ""
}
return groups[0]
}
func normalizeUnderRoot(model vault.Model, root string) vault.Model { func normalizeUnderRoot(model vault.Model, root string) vault.Model {
if root == "" { if root == "" {
return model return model
@@ -888,8 +891,15 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
switch { switch {
case len(path) == 0: case len(path) == 0:
return []string{root} return []string{root}
case path[0] == "Root":
if len(path) == 1 {
return []string{root}
}
return append([]string{root}, path[1:]...)
case path[0] == root: case path[0] == root:
return path return path
case path[0] == "Templates":
return path
default: default:
return append([]string{root}, path...) return append([]string{root}, path...)
} }
@@ -907,12 +917,49 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path) out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path)
} }
} }
for i := range out.Templates {
out.Templates[i].Path = normalizePath(out.Templates[i].Path)
for j := range out.Templates[i].History {
out.Templates[i].History[j].Path = normalizePath(out.Templates[i].History[j].Path)
}
}
for i := range out.Groups { for i := range out.Groups {
out.Groups[i] = normalizePath(out.Groups[i]) out.Groups[i] = normalizePath(out.Groups[i])
} }
return out return out
} }
func normalizationWarning(model vault.Model) string {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return ""
}
if usesKeepassStorageRoot(model) {
return ""
}
return "Opened legacy vault root layout and normalized it under keepass."
}
func usesKeepassStorageRoot(model vault.Model) bool {
if len(model.Entries) != 0 || len(model.RecycleBin) != 0 {
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) { func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
if err != nil { if err != nil {
+63 -12
View File
@@ -64,7 +64,7 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
t.Fatalf("Current() after Unlock() error = %v", err) t.Fatalf("Current() after Unlock() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" { if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got) t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
} }
@@ -110,12 +110,63 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Home Assistant"}) got := current.EntriesInPath([]string{"keepass", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("Current() entries = %#v, want Home Assistant entry", got) t.Fatalf("Current() entries = %#v, want Home Assistant entry", got)
} }
} }
func TestOpenNormalizesLegacyVaultRootToKeepassAndReportsWarning(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
Groups: [][]string{
{"Root"},
{"Root", "Home Assistant"},
},
}
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
file, err := os.Create(path)
if err != nil {
t.Fatalf("Create(legacy path) error = %v", err)
}
if err := vault.SaveKDBXWithKey(file, model, key); err != nil {
file.Close()
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("Close(legacy path) error = %v", err)
}
var sess Manager
if err := sess.Open(path, key); err != nil {
t.Fatalf("Open() error = %v", err)
}
current, err := sess.Current()
if err != nil {
t.Fatalf("Current() error = %v", err)
}
if got := current.EntriesInPath([]string{"keepass", "Home Assistant"}); len(got) != 1 || got[0].ID != "entry-1" {
t.Fatalf("Current().EntriesInPath([keepass Home Assistant]) = %#v, want normalized legacy entry", got)
}
if got := sess.ConsumeWarning(); got == "" {
t.Fatal("ConsumeWarning() = empty, want legacy root normalization warning")
}
}
func TestSavePersistsEditsBackToCurrentPath(t *testing.T) { func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
t.Parallel() t.Parallel()
@@ -169,7 +220,7 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := loaded.EntriesInPath([]string{"Root", "Internet"}) got := loaded.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded entries = %#v, want updated password token-2", got) t.Fatalf("loaded entries = %#v, want updated password token-2", got)
} }
@@ -307,7 +358,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" { if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got) t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
} }
@@ -392,7 +443,7 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
t.Fatalf("LoadKDBXWithKey(savedBytes) error = %v", err) t.Fatalf("LoadKDBXWithKey(savedBytes) error = %v", err)
} }
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) got := loaded.EntriesInPath([]string{"keepass", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got) t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got)
} }
@@ -513,7 +564,7 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" { if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got) t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
} }
@@ -720,7 +771,7 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
t.Fatalf("Current() after reopen error = %v", err) t.Fatalf("Current() after reopen error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got))
} }
@@ -879,7 +930,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
} }
@@ -947,7 +998,7 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
@@ -1004,7 +1055,7 @@ func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
@@ -1063,7 +1114,7 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
@@ -1148,7 +1199,7 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
+5
View File
@@ -26,6 +26,7 @@ var ErrInvalidMasterKey = errors.New("invalid master key")
const ( const (
templatesRoot = "Templates" templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin" recycleBinRoot = "Recycle Bin"
keepassRoot = "keepass"
keepassGOIDField = "KeePassGO-ID" keepassGOIDField = "KeePassGO-ID"
remoteProfilesKey = "keepassgo.remoteProfiles" remoteProfilesKey = "keepassgo.remoteProfiles"
) )
@@ -502,6 +503,10 @@ func compareGroupNames(a, b string) int {
return -1 return -1
case b == "Root": case b == "Root":
return 1 return 1
case a == keepassRoot:
return -1
case b == keepassRoot:
return 1
case a == templatesRoot: case a == templatesRoot:
return -1 return -1
case b == templatesRoot: case b == templatesRoot:
+51
View File
@@ -755,6 +755,57 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
} }
} }
func TestKDBXKeepassRootEntriesPreserveAttachmentsWithTemplates(t *testing.T) {
t.Parallel()
model := Model{
Entries: []Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "bellagio-pass-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
},
},
},
Templates: []Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
},
},
Groups: [][]string{
{"keepass", "Internet"},
{"Templates", "Web"},
},
}
var encoded bytes.Buffer
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
if err != nil {
t.Fatalf("LoadKDBX() error = %v", err)
}
got := loaded.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
}
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
t.Fatalf("attachment contents = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
}
}
func mustGroup(name string, children ...any) gokeepasslib.Group { func mustGroup(name string, children ...any) gokeepasslib.Group {
group := gokeepasslib.NewGroup() group := gokeepasslib.NewGroup()
group.Name = name group.Name = name
+16 -8
View File
@@ -5,19 +5,27 @@ 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 len(model.EntriesInPath(nil)) != 0 { if !hasGroup(model.Groups, []string{KeepassRoot}) {
return "" return ""
} }
groups := model.ChildGroups(nil) return KeepassRoot
roots := make([]string, 0, len(groups)) }
func hasGroup(groups [][]string, path []string) bool {
for _, group := range groups { for _, group := range groups {
if group == "Recycle Bin" { if len(group) != len(path) {
continue continue
} }
roots = append(roots, group) match := true
for i := range group {
if group[i] != path[i] {
match = false
break
} }
if len(roots) != 1 {
return ""
} }
return roots[0] if match {
return true
}
}
return false
} }
+427
View File
@@ -0,0 +1,427 @@
package vaultview
import (
"slices"
"git.julianfamily.org/keepassgo/internal/vault"
)
const KeepassRoot = "keepass"
const TemplatesRoot = "Templates"
// View projects the physical vault model into a logical tree for a specific
// product surface.
type View interface {
ChildGroups(path []string) []string
EntriesInPath(path []string) []vault.Entry
EntriesUnderPath(path []string) []vault.Entry
ToPhysicalPath(path []string) []string
FromPhysicalPath(path []string) []string
ToPhysicalEntry(entry vault.Entry) vault.Entry
FromPhysicalEntry(entry vault.Entry) vault.Entry
}
// Vault returns the physical datastore view.
func Vault(model vault.Model) View {
return physicalView{model: model}
}
// VaultRoot returns the logical main-vault view rooted at the physical
// keepass storage group.
func VaultRoot(model vault.Model) View {
return prefixedView{model: model, root: KeepassRoot, rooted: usesTopLevelRoot(model, KeepassRoot)}
}
// VaultTemplates returns the logical templates view rooted at the physical
// Templates storage group.
func VaultTemplates(model vault.Model) View {
return templatesView{model: model}
}
// VaultRecycleBin returns the logical recycle-bin view.
func VaultRecycleBin(model vault.Model) View {
return recycleBinView{model: model}
}
type physicalView struct {
model vault.Model
}
func (v physicalView) ChildGroups(path []string) []string {
return v.model.ChildGroups(path)
}
func (v physicalView) EntriesInPath(path []string) []vault.Entry {
return cloneEntries(v.model.EntriesInPath(path))
}
func (v physicalView) EntriesUnderPath(path []string) []vault.Entry {
return cloneEntries(v.model.EntriesUnderPath(path))
}
func (v physicalView) ToPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v physicalView) FromPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v physicalView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func (v physicalView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
type prefixedView struct {
model vault.Model
root string
rooted bool
}
func (v prefixedView) ChildGroups(path []string) []string {
return v.model.ChildGroups(v.ToPhysicalPath(path))
}
func (v prefixedView) EntriesInPath(path []string) []vault.Entry {
return v.mapEntries(v.model.EntriesInPath(v.ToPhysicalPath(path)))
}
func (v prefixedView) EntriesUnderPath(path []string) []vault.Entry {
return v.mapEntries(v.model.EntriesUnderPath(v.ToPhysicalPath(path)))
}
func (v prefixedView) ToPhysicalPath(path []string) []string {
if !v.rooted {
return clonePath(path)
}
if len(path) == 0 {
return []string{v.root}
}
return append([]string{v.root}, clonePath(path)...)
}
func (v prefixedView) FromPhysicalPath(path []string) []string {
if !v.rooted {
return clonePath(path)
}
if len(path) == 0 {
return nil
}
if path[0] != v.root {
return clonePath(path)
}
return clonePath(path[1:])
}
func (v prefixedView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.ToPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v prefixedView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.FromPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v prefixedView) mapEntries(entries []vault.Entry) []vault.Entry {
out := make([]vault.Entry, 0, len(entries))
for _, entry := range entries {
out = append(out, v.FromPhysicalEntry(entry))
}
return out
}
type recycleBinView struct {
model vault.Model
}
type templatesView struct {
model vault.Model
}
func (v templatesView) ChildGroups(path []string) []string {
return groupChildren(templateGroupPaths(v.model), v.EntriesUnderPath(nil), path)
}
func (v templatesView) EntriesInPath(path []string) []vault.Entry {
return entriesInPath(v.EntriesUnderPath(nil), path)
}
func (v templatesView) EntriesUnderPath(path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range v.model.Templates {
if len(path) > len(entry.Path) {
continue
}
physical := entry.Path
if len(physical) > 0 && physical[0] == TemplatesRoot {
physical = physical[1:]
}
if len(path) > len(physical) {
continue
}
if !slices.Equal(physical[:len(path)], path) {
continue
}
item := cloneEntry(entry)
item.Path = clonePath(physical)
for i := range item.History {
item.History[i].Path = v.FromPhysicalPath(item.History[i].Path)
}
out = append(out, item)
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func (v templatesView) ToPhysicalPath(path []string) []string {
if len(path) == 0 {
return []string{TemplatesRoot}
}
return append([]string{TemplatesRoot}, clonePath(path)...)
}
func (v templatesView) FromPhysicalPath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] != TemplatesRoot {
return clonePath(path)
}
return clonePath(path[1:])
}
func (v templatesView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.ToPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v templatesView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.FromPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v recycleBinView) ChildGroups(path []string) []string {
return childGroups(v.model.RecycleBin, path)
}
func (v recycleBinView) EntriesInPath(path []string) []vault.Entry {
return entriesInPath(v.model.RecycleBin, path)
}
func (v recycleBinView) EntriesUnderPath(path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range v.model.RecycleBin {
if len(path) > len(entry.Path) {
continue
}
if !slices.Equal(entry.Path[:len(path)], path) {
continue
}
out = append(out, cloneEntry(entry))
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func (v recycleBinView) ToPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v recycleBinView) FromPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v recycleBinView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func (v recycleBinView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func childGroups(entries []vault.Entry, path []string) []string {
return groupChildren(nil, entries, path)
}
func groupChildren(groupPaths [][]string, entries []vault.Entry, path []string) []string {
seen := map[string]bool{}
var groups []string
for _, entry := range entries {
if len(path) > len(entry.Path) {
continue
}
if !slices.Equal(entry.Path[:len(path)], path) {
continue
}
if len(entry.Path) == len(path) {
continue
}
group := entry.Path[len(path)]
if seen[group] {
continue
}
seen[group] = true
groups = append(groups, group)
}
for _, groupPath := range groupPaths {
if len(path) > len(groupPath) {
continue
}
if !slices.Equal(groupPath[:len(path)], path) {
continue
}
if len(groupPath) == len(path) {
continue
}
group := groupPath[len(path)]
if seen[group] {
continue
}
seen[group] = true
groups = append(groups, group)
}
slices.Sort(groups)
return groups
}
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range entries {
if slices.Equal(entry.Path, path) {
out = append(out, cloneEntry(entry))
}
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func cloneEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = cloneEntry(entries[i])
}
return out
}
func cloneEntry(entry vault.Entry) vault.Entry {
entry.Path = clonePath(entry.Path)
entry.Tags = slices.Clone(entry.Tags)
if entry.Fields != nil {
fields := make(map[string]string, len(entry.Fields))
for key, value := range entry.Fields {
fields[key] = value
}
entry.Fields = fields
}
if entry.Attachments != nil {
attachments := make(map[string][]byte, len(entry.Attachments))
for key, value := range entry.Attachments {
attachments[key] = slices.Clone(value)
}
entry.Attachments = attachments
}
if len(entry.History) != 0 {
history := make([]vault.Entry, len(entry.History))
for i := range entry.History {
history[i] = cloneEntry(entry.History[i])
}
entry.History = history
}
return entry
}
func clonePath(path []string) []string {
if len(path) == 0 {
return nil
}
return slices.Clone(path)
}
func templateGroupPaths(model vault.Model) [][]string {
var out [][]string
for _, group := range model.Groups {
if len(group) == 0 || group[0] != TemplatesRoot {
continue
}
out = append(out, clonePath(group[1:]))
}
return out
}
func usesTopLevelRoot(model vault.Model, root string) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return root == KeepassRoot
}
return groupsUseRoot(model.Groups, root) ||
entriesUseRoot(model.Entries, root) ||
entriesUseRoot(model.Templates, root) ||
entriesUseRoot(model.RecycleBin, root)
}
func groupsUseRoot(groups [][]string, root string) bool {
for _, group := range groups {
if len(group) > 0 && group[0] == root {
return true
}
}
return false
}
func entriesUseRoot(entries []vault.Entry, root string) bool {
for _, entry := range entries {
if len(entry.Path) > 0 && entry.Path[0] == root {
return true
}
}
return false
}
+139
View File
@@ -0,0 +1,139 @@
package vaultview
import (
"slices"
"testing"
"git.julianfamily.org/keepassgo/internal/vault"
)
func TestVaultRootProjectsKeepassStorageRoot(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "fountain-cameras", Title: "Fountain Cameras", Path: []string{"keepass", "Crew", "Security"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security"},
{"Recycle Bin"},
},
}
view := VaultRoot(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("VaultRoot(model).ChildGroups(nil) = %v, want [Crew]", got)
}
if got := view.ChildGroups([]string{"Crew"}); !slices.Equal(got, []string{"Internet", "Security"}) {
t.Fatalf("VaultRoot(model).ChildGroups([Crew]) = %v, want [Internet Security]", got)
}
gotEntries := view.EntriesInPath([]string{"Crew", "Internet"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).EntriesInPath([Crew Internet]) = %#v, want logical path [Crew Internet]", gotEntries)
}
if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("VaultRoot(model).ToPhysicalPath(nil) = %v, want [keepass]", got)
}
if got := view.ToPhysicalPath([]string{"Crew", "Internet"}); !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).ToPhysicalPath([Crew Internet]) = %v, want [keepass Crew Internet]", got)
}
if got := view.FromPhysicalPath([]string{"keepass", "Crew", "Internet"}); !slices.Equal(got, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).FromPhysicalPath([keepass Crew Internet]) = %v, want [Crew Internet]", got)
}
}
func TestVaultRecycleBinProjectsRecycleTree(t *testing.T) {
t.Parallel()
model := vault.Model{
RecycleBin: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"Crew", "Internet"}},
{ID: "fountain-cameras", Title: "Fountain Cameras", Path: []string{"Crew", "Security"}},
},
}
view := VaultRecycleBin(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("VaultRecycleBin(model).ChildGroups(nil) = %v, want [Crew]", got)
}
if got := view.ChildGroups([]string{"Crew"}); !slices.Equal(got, []string{"Internet", "Security"}) {
t.Fatalf("VaultRecycleBin(model).ChildGroups([Crew]) = %v, want [Internet Security]", got)
}
gotEntries := view.EntriesInPath([]string{"Crew", "Internet"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRecycleBin(model).EntriesInPath([Crew Internet]) = %#v, want logical recycle-bin path [Crew Internet]", gotEntries)
}
if got := view.ToPhysicalPath([]string{"Crew", "Internet"}); !slices.Equal(got, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRecycleBin(model).ToPhysicalPath([Crew Internet]) = %v, want [Crew Internet]", got)
}
}
func TestVaultTemplatesProjectsTemplatesStorageRoot(t *testing.T) {
t.Parallel()
model := vault.Model{
Templates: []vault.Entry{
{ID: "website-login", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "ssh-login", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
Groups: [][]string{
{"Templates"},
{"Templates", "Infra"},
{"Templates", "Web"},
{"keepass"},
},
}
view := VaultTemplates(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Infra", "Web"}) {
t.Fatalf("VaultTemplates(model).ChildGroups(nil) = %v, want [Infra Web]", got)
}
gotEntries := view.EntriesInPath([]string{"Web"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Web"}) {
t.Fatalf("VaultTemplates(model).EntriesInPath([Web]) = %#v, want logical path [Web]", gotEntries)
}
if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"Templates"}) {
t.Fatalf("VaultTemplates(model).ToPhysicalPath(nil) = %v, want [Templates]", got)
}
if got := view.ToPhysicalPath([]string{"Web"}); !slices.Equal(got, []string{"Templates", "Web"}) {
t.Fatalf("VaultTemplates(model).ToPhysicalPath([Web]) = %v, want [Templates Web]", got)
}
if got := view.FromPhysicalPath([]string{"Templates", "Web"}); !slices.Equal(got, []string{"Web"}) {
t.Fatalf("VaultTemplates(model).FromPhysicalPath([Templates Web]) = %v, want [Web]", got)
}
}
func TestVaultReturnsPhysicalPathsUnchanged(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"keepass", "Crew", "Internet"}},
},
}
view := Vault(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("Vault(model).ChildGroups(nil) = %v, want [keepass]", got)
}
if got := view.ToPhysicalPath([]string{"keepass", "Crew"}); !slices.Equal(got, []string{"keepass", "Crew"}) {
t.Fatalf("Vault(model).ToPhysicalPath([keepass Crew]) = %v, want [keepass Crew]", got)
}
if got := view.FromPhysicalEntry(model.Entries[0]); !slices.Equal(got.Path, []string{"keepass", "Crew", "Internet"}) {
t.Fatalf("Vault(model).FromPhysicalEntry(entry).Path = %v, want [keepass Crew Internet]", got.Path)
}
}
+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