Files
keepassgo/androidsrc/org/julianfamily/keepassgo/AutofillCacheStore.java
2026-04-01 10:36:08 -07:00

335 lines
11 KiB
Java

package org.julianfamily.keepassgo;
import android.content.Context;
import android.util.JsonReader;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
final class AutofillCacheStore {
private static final String TAG = "KeePassGOAutofill";
private AutofillCacheStore() {
}
static Entry findBestMatch(Context context, String webDomain) {
File cacheFile = findCacheFile(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()) {
return null;
}
NormalizedTarget target = normalizeURL(webDomain);
if (target.host.isEmpty()) {
return null;
}
List<Entry> exactHost = new ArrayList<>();
List<Entry> parentHost = new ArrayList<>();
for (Entry entry : entries) {
if (entryMatchesHost(entry, target.host)) {
exactHost.add(entry);
continue;
}
if (entryMatchesParentHost(entry, target.host)) {
parentHost.add(entry);
}
}
Entry matched = chooseEntry(target, exactHost);
if (matched != null) {
return matched;
}
return chooseEntry(target, parentHost);
}
private static File findCacheFile(Context context) {
List<File> candidates = new ArrayList<>();
File filesDir = context.getFilesDir();
if (filesDir != null) {
candidates.add(new File(filesDir, "keepassgo/autofill-cache.json"));
candidates.add(new File(filesDir, ".config/keepassgo/autofill-cache.json"));
candidates.add(new File(filesDir, "config/keepassgo/autofill-cache.json"));
}
File baseDir = context.getDataDir();
if (baseDir != null) {
candidates.add(new File(baseDir, "files/keepassgo/autofill-cache.json"));
candidates.add(new File(baseDir, "files/.config/keepassgo/autofill-cache.json"));
candidates.add(new File(baseDir, "files/config/keepassgo/autofill-cache.json"));
}
for (File candidate : candidates) {
if (candidate.isFile()) {
Log.i(TAG, "using autofill cache " + candidate.getAbsolutePath());
return candidate;
}
}
Log.i(TAG, "no autofill cache file in " + candidates);
return null;
}
private static List<Entry> readEntries(File cacheFile) throws IOException {
List<Entry> entries = new ArrayList<>();
try (JsonReader reader = new JsonReader(new InputStreamReader(new FileInputStream(cacheFile), StandardCharsets.UTF_8))) {
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
if ("entries".equals(name)) {
reader.beginArray();
while (reader.hasNext()) {
entries.add(readEntry(reader));
}
reader.endArray();
} else {
reader.skipValue();
}
}
reader.endObject();
}
return entries;
}
private static Entry readEntry(JsonReader reader) throws IOException {
String title = "";
String username = "";
String password = "";
String host = "";
String url = "";
List<String> targets = new ArrayList<>();
reader.beginObject();
while (reader.hasNext()) {
String name = reader.nextName();
switch (name) {
case "title":
title = nextString(reader);
break;
case "username":
username = nextString(reader);
break;
case "password":
password = nextString(reader);
break;
case "url":
url = nextString(reader);
break;
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, targets);
}
private static String nextString(JsonReader reader) throws IOException {
if (reader.peek() == android.util.JsonToken.NULL) {
reader.nextNull();
return "";
}
return reader.nextString();
}
private static String normalizeHost(String raw) {
return normalizeURL(raw).host;
}
private static NormalizedTarget normalizeURL(String raw) {
if (raw == null) {
return new NormalizedTarget("", "", "");
}
String value = raw.trim().toLowerCase(Locale.US);
if (value.startsWith("http://")) {
value = value.substring("http://".length());
} else if (value.startsWith("https://")) {
value = value.substring("https://".length());
}
int slash = value.indexOf('/');
if (slash >= 0) {
value = value.substring(0, slash);
}
int colon = value.indexOf(':');
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<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<>();
int bestPrefixLen = -1;
for (Entry entry : entries) {
MatchQuality quality = bestTargetMatch(entry, target);
if (quality.exact) {
exact.add(entry);
continue;
}
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);
}
}
if (exact.size() == 1) {
return exact.get(0);
}
if (exact.size() > 1 || prefix.isEmpty()) {
return null;
}
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;
}
}
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 false;
}
private static List<NormalizedTarget> entryTargets(Entry entry) {
List<String> rawTargets = entry.targets;
if (rawTargets.isEmpty()) {
rawTargets = new ArrayList<>();
rawTargets.add(entry.url);
}
List<NormalizedTarget> 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 {
final String title;
final String username;
final String password;
final String host;
final String url;
final List<String> targets;
Entry(String title, String username, String password, String host, String url, List<String> 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;
}
}
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;
}
}
}