Disambiguate same-host Android fill matches
This commit is contained in:
+107
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user