diff --git a/android/keepassgo-android.jar b/android/keepassgo-android.jar index 1c3579f..89509e5 100644 Binary files a/android/keepassgo-android.jar and b/android/keepassgo-android.jar differ diff --git a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java index b48cd4d..770f949 100644 --- a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java +++ b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java @@ -42,11 +42,11 @@ final class AutofillCacheStore { List exactHost = new ArrayList<>(); List parentHost = new ArrayList<>(); for (Entry entry : entries) { - if (entry.host.equals(target.host)) { + if (entryMatchesHost(entry, target.host)) { exactHost.add(entry); continue; } - if (!entry.host.isEmpty() && target.host.endsWith("." + entry.host)) { + if (entryMatchesParentHost(entry, target.host)) { parentHost.add(entry); } } @@ -108,6 +108,7 @@ final class AutofillCacheStore { String password = ""; String host = ""; String url = ""; + List targets = new ArrayList<>(); reader.beginObject(); while (reader.hasNext()) { String name = reader.nextName(); @@ -127,13 +128,20 @@ final class AutofillCacheStore { case "host": host = normalizeHost(nextString(reader)); break; + case "targets": + reader.beginArray(); + while (reader.hasNext()) { + targets.add(nextString(reader)); + } + reader.endArray(); + break; default: reader.skipValue(); break; } } reader.endObject(); - return new Entry(title, username, password, host, url); + return new Entry(title, username, password, host, url, targets); } private static String nextString(JsonReader reader) throws IOException { @@ -209,16 +217,21 @@ final class AutofillCacheStore { List exact = new ArrayList<>(); List prefix = new ArrayList<>(); + int bestPrefixLen = -1; for (Entry entry : entries) { - NormalizedTarget entryTarget = normalizeURL(entry.url); - if (entryTarget.host.isEmpty()) { - continue; - } - if (entryTarget.url.equals(target.url)) { + MatchQuality quality = bestTargetMatch(entry, target); + if (quality.exact) { exact.add(entry); continue; } - if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) { + if (quality.prefixLength <= 0) { + continue; + } + if (quality.prefixLength > bestPrefixLen) { + prefix.clear(); + prefix.add(entry); + bestPrefixLen = quality.prefixLength; + } else if (quality.prefixLength == bestPrefixLen) { prefix.add(entry); } } @@ -229,23 +242,54 @@ final class AutofillCacheStore { return null; } - Entry best = null; - int bestLen = -1; - boolean ambiguous = false; - for (Entry entry : prefix) { - int pathLen = normalizeURL(entry.url).path.length(); - if (pathLen > bestLen) { - best = entry; - bestLen = pathLen; - ambiguous = false; - } else if (pathLen == bestLen) { - ambiguous = true; + return prefix.size() == 1 ? prefix.get(0) : null; + } + + private static boolean entryMatchesHost(Entry entry, String host) { + for (NormalizedTarget target : entryTargets(entry)) { + if (target.host.equals(host)) { + return true; } } - if (ambiguous) { - return null; + return false; + } + + private static boolean entryMatchesParentHost(Entry entry, String host) { + for (NormalizedTarget target : entryTargets(entry)) { + if (!target.host.isEmpty() && host.endsWith("." + target.host)) { + return true; + } } - return best; + return false; + } + + private static List entryTargets(Entry entry) { + List rawTargets = entry.targets; + if (rawTargets.isEmpty()) { + rawTargets = new ArrayList<>(); + rawTargets.add(entry.url); + } + List targets = new ArrayList<>(); + for (String rawTarget : rawTargets) { + NormalizedTarget target = normalizeURL(rawTarget); + if (!target.host.isEmpty()) { + targets.add(target); + } + } + return targets; + } + + private static MatchQuality bestTargetMatch(Entry entry, NormalizedTarget target) { + int bestPrefixLen = -1; + for (NormalizedTarget entryTarget : entryTargets(entry)) { + if (entryTarget.url.equals(target.url)) { + return new MatchQuality(true, 0); + } + if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) { + bestPrefixLen = Math.max(bestPrefixLen, entryTarget.path.length()); + } + } + return new MatchQuality(false, bestPrefixLen); } static final class Entry { @@ -254,13 +298,25 @@ final class AutofillCacheStore { final String password; final String host; final String url; + final List targets; - Entry(String title, String username, String password, String host, String url) { + Entry(String title, String username, String password, String host, String url, List targets) { this.title = title; this.username = username; this.password = password; this.host = host; this.url = url; + this.targets = new ArrayList<>(targets); + } + } + + private static final class MatchQuality { + final boolean exact; + final int prefixLength; + + MatchQuality(boolean exact, int prefixLength) { + this.exact = exact; + this.prefixLength = prefixLength; } } diff --git a/autofillcache/cache.go b/autofillcache/cache.go index efd7837..a3f2531 100644 --- a/autofillcache/cache.go +++ b/autofillcache/cache.go @@ -19,6 +19,7 @@ type Entry struct { Password string `json:"password"` URL string `json:"url"` Host string `json:"host"` + Targets []string `json:"targets,omitempty"` Path []string `json:"path,omitempty"` } @@ -36,11 +37,11 @@ func Match(cache File, webURL string) (Entry, bool) { exactHost := make([]Entry, 0) parentHost := make([]Entry, 0) for _, entry := range cache.Entries { - if entry.Host == target.host { + if entryMatchesHost(entry, target.host) { exactHost = append(exactHost, entry) continue } - if entry.Host != "" && strings.HasSuffix(target.host, "."+entry.Host) { + if entryMatchesParentHost(entry, target.host) { parentHost = append(parentHost, entry) } } @@ -54,7 +55,16 @@ func Match(cache File, webURL string) (Entry, bool) { func Build(model vault.Model, now time.Time) File { entries := make([]Entry, 0, len(model.Entries)) for _, item := range model.Entries { + targets := collectTargets(item) host := normalizeHost(item.URL) + if host == "" { + for _, target := range targets { + host = normalizeHost(target) + if host != "" { + break + } + } + } if host == "" { continue } @@ -68,6 +78,7 @@ func Build(model vault.Model, now time.Time) File { Password: item.Password, URL: item.URL, Host: host, + Targets: targets, Path: append([]string(nil), item.Path...), }) } @@ -150,18 +161,23 @@ func chooseEntry(target normalizedTarget, entries []Entry) (Entry, bool) { } exact := make([]Entry, 0) - prefix := make([]Entry, 0) + bestPrefixLen := -1 + bestPrefix := make([]Entry, 0) for _, entry := range entries { - entryTarget := normalizeURL(entry.URL) - if entryTarget.host == "" { - continue - } - if entryTarget.url == target.url { + exactMatch, prefixLen := bestTargetMatch(entry, target) + if exactMatch { exact = append(exact, entry) continue } - if entryTarget.path != "/" && strings.HasPrefix(target.path, entryTarget.path) { - prefix = append(prefix, entry) + if prefixLen <= 0 { + continue + } + switch { + case prefixLen > bestPrefixLen: + bestPrefixLen = prefixLen + bestPrefix = []Entry{entry} + case prefixLen == bestPrefixLen: + bestPrefix = append(bestPrefix, entry) } } if len(exact) == 1 { @@ -170,22 +186,92 @@ func chooseEntry(target normalizedTarget, entries []Entry) (Entry, bool) { if len(exact) > 1 { return Entry{}, false } - if len(prefix) == 0 { + if len(bestPrefix) == 1 { + return bestPrefix[0], true + } + if len(bestPrefix) == 0 { return Entry{}, false } - - sort.Slice(prefix, func(i, j int) bool { - return len(normalizeURL(prefix[i].URL).path) > len(normalizeURL(prefix[j].URL).path) - }) - bestPath := normalizeURL(prefix[0].URL).path - best := make([]Entry, 0, len(prefix)) - for _, entry := range prefix { - if normalizeURL(entry.URL).path == bestPath { - best = append(best, entry) - } - } - if len(best) == 1 { - return best[0], true - } return Entry{}, false } + +func collectTargets(item vault.Entry) []string { + seen := make(map[string]struct{}) + targets := make([]string, 0, 1+len(item.Fields)) + appendTarget := func(raw string) { + value := strings.TrimSpace(raw) + if value == "" { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + targets = append(targets, value) + } + + appendTarget(item.URL) + + keys := make([]string, 0, len(item.Fields)) + for key := range item.Fields { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + upper := strings.ToUpper(strings.TrimSpace(key)) + if strings.HasPrefix(upper, "ANDROIDAPP") || strings.HasPrefix(upper, "KP2A_URL") { + appendTarget(item.Fields[key]) + } + } + + return targets +} + +func entryTargets(entry Entry) []normalizedTarget { + values := entry.Targets + if len(values) == 0 { + values = []string{entry.URL} + } + targets := make([]normalizedTarget, 0, len(values)) + for _, value := range values { + target := normalizeURL(value) + if target.host == "" { + continue + } + targets = append(targets, target) + } + return targets +} + +func entryMatchesHost(entry Entry, host string) bool { + for _, target := range entryTargets(entry) { + if target.host == host { + return true + } + } + return false +} + +func entryMatchesParentHost(entry Entry, host string) bool { + for _, target := range entryTargets(entry) { + if target.host != "" && strings.HasSuffix(host, "."+target.host) { + return true + } + } + return false +} + +func bestTargetMatch(entry Entry, target normalizedTarget) (bool, int) { + bestPrefixLen := -1 + for _, candidate := range entryTargets(entry) { + if candidate.url == target.url { + return true, 0 + } + if candidate.path != "/" && strings.HasPrefix(target.path, candidate.path) { + if pathLen := len(candidate.path); pathLen > bestPrefixLen { + bestPrefixLen = pathLen + } + } + } + return false, bestPrefixLen +} diff --git a/autofillcache/cache_test.go b/autofillcache/cache_test.go index face179..f9762d5 100644 --- a/autofillcache/cache_test.go +++ b/autofillcache/cache_test.go @@ -36,6 +36,10 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) { Username: "user", Password: "pass", URL: "surveillance.crew.example.invalid", + Fields: map[string]string{ + "AndroidApp1": "androidapp://com.lights.mobile", + "KP2A_URL_1": "https://surveillance.crew.example.invalid/account", + }, }, }, }, now) @@ -49,6 +53,9 @@ func TestBuildFiltersAndNormalizesEntries(t *testing.T) { if got.Entries[1].Host != "surveillance.crew.example.invalid" { t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host) } + if len(got.Entries[1].Targets) != 3 { + t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets)) + } if got.UpdatedAt != "2026-03-31T12:00:00Z" { t.Fatalf("updatedAt = %q", got.UpdatedAt) } @@ -235,3 +242,55 @@ func TestMatchRejectsAmbiguousAndroidAppPackageTargets(t *testing.T) { t.Fatalf("Match() unexpectedly resolved ambiguous android app package target") } } + +func TestMatchUsesAndroidAppCustomFieldTarget(t *testing.T) { + t.Parallel() + + cache := File{ + Entries: []Entry{ + { + ID: "one", + Title: "Blink", + Username: "blink-user", + Password: "secret1", + URL: "https://account.blinknetwork.com", + Host: "account.blinknetwork.com", + Targets: []string{"https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"}, + }, + }, + } + + got, ok := Match(cache, "androidapp://com.blinknetwork.mobile2") + if !ok { + t.Fatalf("Match() found no entry") + } + if got.ID != "one" { + t.Fatalf("Match() entry = %q, want one", got.ID) + } +} + +func TestMatchUsesKP2AURLCustomFieldTarget(t *testing.T) { + t.Parallel() + + cache := File{ + Entries: []Entry{ + { + ID: "one", + Title: "Blink", + Username: "blink-user", + Password: "secret1", + URL: "https://blinknetwork.com", + Host: "blinknetwork.com", + Targets: []string{"https://blinknetwork.com", "https://account.blinknetwork.com"}, + }, + }, + } + + got, ok := Match(cache, "https://account.blinknetwork.com") + if !ok { + t.Fatalf("Match() found no entry") + } + if got.ID != "one" { + t.Fatalf("Match() entry = %q, want one", got.ID) + } +}