From d9d1cf134d29034118610a179536a6af571dc89a Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 1 Apr 2026 05:21:15 -0700 Subject: [PATCH] Disambiguate same-host Android fill matches --- TODO.md | 15 ++ android/keepassgo-android.jar | Bin 12395 -> 14014 bytes .../keepassgo/AutofillCacheStore.java | 131 ++++++++++++++++-- autofillcache/cache.go | 110 ++++++++++++++- autofillcache/cache_test.go | 95 +++++++++++++ 5 files changed, 336 insertions(+), 15 deletions(-) diff --git a/TODO.md b/TODO.md index 71c9656..1a6ddc0 100644 --- a/TODO.md +++ b/TODO.md @@ -449,6 +449,21 @@ Exit criteria: - Focus and accessibility states are visible and intentional. - `go test ./...` passes. +### Segment 21: Accessibility Fill Generalization + +Scope: +- Extend Android accessibility-based fill beyond the current Chrome demo path. +- Support package-specific rules so apps with stable package identities can have tailored matching behavior. +- Support view-id matching so custom login forms can be identified more reliably than by generic hints alone. +- Support app allowlists so only approved apps/packages are eligible for accessibility-based credential fill. +- Require an approval step before filling into a newly seen app/package unless the user has already made a persistent decision. + +Exit criteria: +- The design for package-specific rules, view-id matching, app allowlists, and first-fill approval is implemented or broken into executable sub-slices. +- Accessibility fill no longer depends solely on Chrome-style generic username/password heuristics. +- New app/package fill attempts can be allowed, denied, or made persistent by the user. +- `go test ./...` passes. + ### Segment 17: UI Completion And Error Surfaces Scope: diff --git a/android/keepassgo-android.jar b/android/keepassgo-android.jar index b2345ae044ab01ba7c36dd60f47769b4899e80d0..1c3579ff6d78522b3d8227bb08f0c65cec4f7d09 100644 GIT binary patch delta 5261 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 exactHost = new ArrayList<>(); + List parentHost = new ArrayList<>(); for (Entry entry : entries) { - if (entry.host.equals(normalizedDomain)) { - return entry; + if (entry.host.equals(target.host)) { + exactHost.add(entry); + continue; } - if (fallback == null && normalizedDomain.endsWith("." + entry.host)) { - fallback = entry; + if (!entry.host.isEmpty() && target.host.endsWith("." + entry.host)) { + parentHost.add(entry); } } - return fallback; + Entry matched = chooseEntry(target, exactHost); + if (matched != null) { + return matched; + } + return chooseEntry(target, parentHost); } private static File findCacheFile(Context context) { @@ -101,6 +107,7 @@ final class AutofillCacheStore { String username = ""; String password = ""; String host = ""; + String url = ""; reader.beginObject(); while (reader.hasNext()) { String name = reader.nextName(); @@ -114,6 +121,9 @@ final class AutofillCacheStore { case "password": password = nextString(reader); break; + case "url": + url = nextString(reader); + break; case "host": host = normalizeHost(nextString(reader)); break; @@ -123,7 +133,7 @@ final class AutofillCacheStore { } } reader.endObject(); - return new Entry(title, username, password, host); + return new Entry(title, username, password, host, url); } private static String nextString(JsonReader reader) throws IOException { @@ -135,8 +145,12 @@ final class AutofillCacheStore { } private static String normalizeHost(String raw) { + return normalizeURL(raw).host; + } + + private static NormalizedTarget normalizeURL(String raw) { if (raw == null) { - return ""; + return new NormalizedTarget("", "", ""); } String value = raw.trim().toLowerCase(Locale.US); if (value.startsWith("http://")) { @@ -152,20 +166,113 @@ final class AutofillCacheStore { if (colon >= 0) { value = value.substring(0, colon); } + String host = value; + String path = "/"; + int schemeSep = raw.indexOf("://"); + String original = raw.trim(); + if (schemeSep < 0) { + original = "https://" + original; + } + try { + java.net.URI uri = java.net.URI.create(original); + if (uri.getHost() != null) { + host = uri.getHost().toLowerCase(Locale.US); + } + path = cleanPath(uri.getPath()); + } catch (IllegalArgumentException ignored) { + path = "/"; + } + return new NormalizedTarget(host, path, host + path); + } + + private static String cleanPath(String raw) { + if (raw == null || raw.trim().isEmpty() || "/".equals(raw.trim())) { + return "/"; + } + String value = raw.trim(); + while (value.endsWith("/") && value.length() > 1) { + value = value.substring(0, value.length() - 1); + } + if (!value.startsWith("/")) { + value = "/" + value; + } return value; } + private static Entry chooseEntry(NormalizedTarget target, List entries) { + if (entries.isEmpty()) { + return null; + } + if (entries.size() == 1) { + return entries.get(0); + } + + List exact = new ArrayList<>(); + List prefix = new ArrayList<>(); + for (Entry entry : entries) { + NormalizedTarget entryTarget = normalizeURL(entry.url); + if (entryTarget.host.isEmpty()) { + continue; + } + if (entryTarget.url.equals(target.url)) { + exact.add(entry); + continue; + } + if (!"/".equals(entryTarget.path) && target.path.startsWith(entryTarget.path)) { + prefix.add(entry); + } + } + if (exact.size() == 1) { + return exact.get(0); + } + if (exact.size() > 1 || prefix.isEmpty()) { + 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; + } + } + if (ambiguous) { + return null; + } + return best; + } + static final class Entry { final String title; final String username; final String password; final String host; + final String url; - Entry(String title, String username, String password, String host) { + Entry(String title, String username, String password, String host, String url) { this.title = title; this.username = username; this.password = password; this.host = host; + this.url = url; + } + } + + private static final class NormalizedTarget { + final String host; + final String path; + final String url; + + NormalizedTarget(String host, String path, String url) { + this.host = host; + this.path = path; + this.url = url; } } } diff --git a/autofillcache/cache.go b/autofillcache/cache.go index bb4254f..efd7837 100644 --- a/autofillcache/cache.go +++ b/autofillcache/cache.go @@ -5,6 +5,7 @@ import ( "net/url" "os" "path/filepath" + "sort" "strings" "time" @@ -26,6 +27,30 @@ type File struct { Entries []Entry `json:"entries"` } +func Match(cache File, webURL string) (Entry, bool) { + target := normalizeURL(webURL) + if target.host == "" { + return Entry{}, false + } + + exactHost := make([]Entry, 0) + parentHost := make([]Entry, 0) + for _, entry := range cache.Entries { + if entry.Host == target.host { + exactHost = append(exactHost, entry) + continue + } + if entry.Host != "" && strings.HasSuffix(target.host, "."+entry.Host) { + parentHost = append(parentHost, entry) + } + } + + if matched, ok := chooseEntry(target, exactHost); ok { + return matched, true + } + return chooseEntry(target, parentHost) +} + func Build(model vault.Model, now time.Time) File { entries := make([]Entry, 0, len(model.Entries)) for _, item := range model.Entries { @@ -71,17 +96,96 @@ func Clear(path string) error { } func normalizeHost(raw string) string { + return normalizeURL(raw).host +} + +type normalizedTarget struct { + host string + path string + url string +} + +func normalizeURL(raw string) normalizedTarget { value := strings.TrimSpace(raw) if value == "" { - return "" + return normalizedTarget{} } if !strings.Contains(value, "://") { value = "https://" + value } parsed, err := url.Parse(value) if err != nil { - return "" + return normalizedTarget{} } host := strings.TrimSpace(parsed.Hostname()) - return strings.ToLower(host) + path := cleanPath(parsed.EscapedPath()) + return normalizedTarget{ + host: strings.ToLower(host), + path: path, + url: strings.ToLower(host) + path, + } +} + +func cleanPath(path string) string { + path = strings.TrimSpace(path) + if path == "" || path == "/" { + return "/" + } + path = strings.TrimRight(path, "/") + if path == "" { + return "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path +} + +func chooseEntry(target normalizedTarget, entries []Entry) (Entry, bool) { + switch len(entries) { + case 0: + return Entry{}, false + case 1: + return entries[0], true + } + + exact := make([]Entry, 0) + prefix := make([]Entry, 0) + for _, entry := range entries { + entryTarget := normalizeURL(entry.URL) + if entryTarget.host == "" { + continue + } + if entryTarget.url == target.url { + exact = append(exact, entry) + continue + } + if entryTarget.path != "/" && strings.HasPrefix(target.path, entryTarget.path) { + prefix = append(prefix, entry) + } + } + if len(exact) == 1 { + return exact[0], true + } + if len(exact) > 1 { + return Entry{}, false + } + if len(prefix) == 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 } diff --git a/autofillcache/cache_test.go b/autofillcache/cache_test.go index bc7899e..f892cb0 100644 --- a/autofillcache/cache_test.go +++ b/autofillcache/cache_test.go @@ -86,3 +86,98 @@ func TestWriteAndClear(t *testing.T) { t.Fatalf("cache path still exists, stat err = %v", err) } } + +func TestMatchChoosesExactURLWhenHostsRepeat(t *testing.T) { + t.Parallel() + + cache := File{ + Entries: []Entry{ + { + ID: "one", + Title: "Primary Login", + Username: "first", + Password: "secret1", + URL: "https://10.0.2.2:8443/login/", + Host: "10.0.2.2", + }, + { + ID: "two", + Title: "Alt Login", + Username: "second", + Password: "secret2", + URL: "https://10.0.2.2:8443/alt/", + Host: "10.0.2.2", + }, + }, + } + + got, ok := Match(cache, "https://10.0.2.2:8443/alt/") + if !ok { + t.Fatalf("Match() found no entry") + } + if got.ID != "two" { + t.Fatalf("Match() entry = %q, want two", got.ID) + } +} + +func TestMatchRejectsAmbiguousSharedHost(t *testing.T) { + t.Parallel() + + cache := File{ + Entries: []Entry{ + { + ID: "one", + Title: "Host A", + Username: "first", + Password: "secret1", + URL: "https://surveillance.crew.example.invalid/", + Host: "surveillance.crew.example.invalid", + }, + { + ID: "two", + Title: "Host B", + Username: "second", + Password: "secret2", + URL: "https://surveillance.crew.example.invalid/", + Host: "surveillance.crew.example.invalid", + }, + }, + } + + if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok { + t.Fatalf("Match() unexpectedly resolved ambiguous shared host") + } +} + +func TestMatchChoosesLongestPathPrefix(t *testing.T) { + t.Parallel() + + cache := File{ + Entries: []Entry{ + { + ID: "one", + Title: "Generic Login", + Username: "generic", + Password: "secret1", + URL: "https://example.com/", + Host: "example.com", + }, + { + ID: "two", + Title: "Admin Login", + Username: "admin", + Password: "secret2", + URL: "https://example.com/admin", + Host: "example.com", + }, + }, + } + + got, ok := Match(cache, "https://example.com/admin/login") + if !ok { + t.Fatalf("Match() found no entry") + } + if got.ID != "two" { + t.Fatalf("Match() entry = %q, want two", got.ID) + } +}