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 b2345ae..1c3579f 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 41b0fac..b48cd4d 100644 --- a/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java +++ b/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java @@ -35,20 +35,26 @@ final class AutofillCacheStore { if (entries.isEmpty()) { return null; } - String normalizedDomain = normalizeHost(webDomain); - if (normalizedDomain.isEmpty()) { - return entries.get(0); + NormalizedTarget target = normalizeURL(webDomain); + if (target.host.isEmpty()) { + return null; } - Entry fallback = null; + List 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) + } +}