Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fea1a75cdf | |||
| 0dfaeef7bf | |||
| 92a7853258 | |||
| 14f22b4ebf | |||
| 4d972bfab0 | |||
| e005a42a3f | |||
| 58d6d510f9 | |||
| bb114cee16 | |||
| 2431467aa7 | |||
| c302c29d4f | |||
| 361d6dbe03 | |||
| a41e842a65 | |||
| 54398837e6 | |||
| 989b41735f | |||
| a88b8a824b | |||
| eccfb886ee | |||
| 6790399e24 | |||
| 9882d3fc04 | |||
| 59cd01f8e7 | |||
| ea30775eb7 | |||
| 0ce25a9712 | |||
| 32e6fc6c90 | |||
| e8a48fb7aa |
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -135,11 +135,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -6,17 +6,42 @@ 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.
|
||||||
|
|
||||||
|
`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 +49,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.
|
||||||
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
|
||||||
|
|||||||
@@ -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))"
|
||||||
|
|
||||||
|
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" \
|
||||||
|
|||||||
@@ -90,10 +90,24 @@ 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. 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
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
Log.i(TAG, "no autofill cache match");
|
FillResponse chooser = chooserResponse(target, fields);
|
||||||
callback.onSuccess(null);
|
if (chooser == null) {
|
||||||
|
Log.i(TAG, "no autofill cache match");
|
||||||
|
callback.onSuccess(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.i(TAG, "returning chooser dataset for " + target.matchTarget);
|
||||||
|
callback.onSuccess(chooser);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
|
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
|
||||||
|
|
||||||
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
|
FillResponse response = directFillResponse(entry, fields);
|
||||||
presentation.setTextViewText(
|
|
||||||
android.R.id.text1,
|
|
||||||
entry.title + " (" + entry.username + ")"
|
|
||||||
);
|
|
||||||
|
|
||||||
Dataset.Builder dataset = new Dataset.Builder(presentation);
|
|
||||||
if (fields.usernameId != null) {
|
|
||||||
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
|
|
||||||
}
|
|
||||||
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
|
|
||||||
|
|
||||||
FillResponse response = new FillResponse.Builder()
|
|
||||||
.addDataset(dataset.build())
|
|
||||||
.build();
|
|
||||||
Log.i(TAG, "returning dataset");
|
Log.i(TAG, "returning dataset");
|
||||||
callback.onSuccess(response);
|
callback.onSuccess(response);
|
||||||
} catch (Exception err) {
|
} catch (Exception err) {
|
||||||
@@ -103,6 +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");
|
||||||
|
|||||||
+209
-24
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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), " / ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -1260,14 +1260,10 @@ 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()
|
return ""
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return vaultview.HiddenRoot(model)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) enterHiddenVaultRoot() {
|
func (u *ui) enterHiddenVaultRoot() {
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 match {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(roots) != 1 {
|
return false
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return roots[0]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user