Disambiguate same-host Android fill matches
This commit is contained in:
@@ -449,6 +449,21 @@ Exit criteria:
|
|||||||
- Focus and accessibility states are visible and intentional.
|
- Focus and accessibility states are visible and intentional.
|
||||||
- `go test ./...` passes.
|
- `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
|
### Segment 17: UI Completion And Error Surfaces
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
|
|||||||
Binary file not shown.
@@ -35,20 +35,26 @@ final class AutofillCacheStore {
|
|||||||
if (entries.isEmpty()) {
|
if (entries.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String normalizedDomain = normalizeHost(webDomain);
|
NormalizedTarget target = normalizeURL(webDomain);
|
||||||
if (normalizedDomain.isEmpty()) {
|
if (target.host.isEmpty()) {
|
||||||
return entries.get(0);
|
return null;
|
||||||
}
|
}
|
||||||
Entry fallback = null;
|
List<Entry> exactHost = new ArrayList<>();
|
||||||
|
List<Entry> parentHost = new ArrayList<>();
|
||||||
for (Entry entry : entries) {
|
for (Entry entry : entries) {
|
||||||
if (entry.host.equals(normalizedDomain)) {
|
if (entry.host.equals(target.host)) {
|
||||||
return entry;
|
exactHost.add(entry);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (fallback == null && normalizedDomain.endsWith("." + entry.host)) {
|
if (!entry.host.isEmpty() && target.host.endsWith("." + entry.host)) {
|
||||||
fallback = entry;
|
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) {
|
private static File findCacheFile(Context context) {
|
||||||
@@ -101,6 +107,7 @@ final class AutofillCacheStore {
|
|||||||
String username = "";
|
String username = "";
|
||||||
String password = "";
|
String password = "";
|
||||||
String host = "";
|
String host = "";
|
||||||
|
String url = "";
|
||||||
reader.beginObject();
|
reader.beginObject();
|
||||||
while (reader.hasNext()) {
|
while (reader.hasNext()) {
|
||||||
String name = reader.nextName();
|
String name = reader.nextName();
|
||||||
@@ -114,6 +121,9 @@ final class AutofillCacheStore {
|
|||||||
case "password":
|
case "password":
|
||||||
password = nextString(reader);
|
password = nextString(reader);
|
||||||
break;
|
break;
|
||||||
|
case "url":
|
||||||
|
url = nextString(reader);
|
||||||
|
break;
|
||||||
case "host":
|
case "host":
|
||||||
host = normalizeHost(nextString(reader));
|
host = normalizeHost(nextString(reader));
|
||||||
break;
|
break;
|
||||||
@@ -123,7 +133,7 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
reader.endObject();
|
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 {
|
private static String nextString(JsonReader reader) throws IOException {
|
||||||
@@ -135,8 +145,12 @@ final class AutofillCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String normalizeHost(String raw) {
|
private static String normalizeHost(String raw) {
|
||||||
|
return normalizeURL(raw).host;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NormalizedTarget normalizeURL(String raw) {
|
||||||
if (raw == null) {
|
if (raw == null) {
|
||||||
return "";
|
return new NormalizedTarget("", "", "");
|
||||||
}
|
}
|
||||||
String value = raw.trim().toLowerCase(Locale.US);
|
String value = raw.trim().toLowerCase(Locale.US);
|
||||||
if (value.startsWith("http://")) {
|
if (value.startsWith("http://")) {
|
||||||
@@ -152,20 +166,113 @@ final class AutofillCacheStore {
|
|||||||
if (colon >= 0) {
|
if (colon >= 0) {
|
||||||
value = value.substring(0, colon);
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Entry chooseEntry(NormalizedTarget target, List<Entry> entries) {
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (entries.size() == 1) {
|
||||||
|
return entries.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Entry> exact = new ArrayList<>();
|
||||||
|
List<Entry> 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 {
|
static final class Entry {
|
||||||
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;
|
||||||
|
|
||||||
Entry(String title, String username, String password, String host) {
|
Entry(String title, String username, String password, String host, String url) {
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-3
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -26,6 +27,30 @@ type File struct {
|
|||||||
Entries []Entry `json:"entries"`
|
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 {
|
func Build(model vault.Model, now time.Time) File {
|
||||||
entries := make([]Entry, 0, len(model.Entries))
|
entries := make([]Entry, 0, len(model.Entries))
|
||||||
for _, item := range model.Entries {
|
for _, item := range model.Entries {
|
||||||
@@ -71,17 +96,96 @@ func Clear(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeHost(raw string) string {
|
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)
|
value := strings.TrimSpace(raw)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return ""
|
return normalizedTarget{}
|
||||||
}
|
}
|
||||||
if !strings.Contains(value, "://") {
|
if !strings.Contains(value, "://") {
|
||||||
value = "https://" + value
|
value = "https://" + value
|
||||||
}
|
}
|
||||||
parsed, err := url.Parse(value)
|
parsed, err := url.Parse(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return normalizedTarget{}
|
||||||
}
|
}
|
||||||
host := strings.TrimSpace(parsed.Hostname())
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,3 +86,98 @@ func TestWriteAndClear(t *testing.T) {
|
|||||||
t.Fatalf("cache path still exists, stat err = %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user