Move app packages under internal

This commit is contained in:
Joe Julian
2026-04-09 06:42:21 -07:00
parent 7751b5472a
commit fe921b8790
55 changed files with 162 additions and 162 deletions
+296
View File
@@ -0,0 +1,296 @@
package autofillcache
import (
"encoding/json"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.julianfamily.org/keepassgo/internal/vault"
)
type Entry struct {
ID string `json:"id"`
Title string `json:"title"`
Username string `json:"username"`
Password string `json:"password"`
URL string `json:"url"`
Host string `json:"host"`
Targets []string `json:"targets,omitempty"`
Path []string `json:"path,omitempty"`
}
type File struct {
UpdatedAt string `json:"updatedAt"`
Entries []Entry `json:"entries"`
}
type MatchStatus string
const (
MatchStatusNone MatchStatus = ""
MatchStatusFound MatchStatus = "found"
MatchStatusAmbiguous MatchStatus = "ambiguous"
MatchStatusMissing MatchStatus = "missing"
)
type MatchResult struct {
Status MatchStatus `json:"status"`
Entry Entry `json:"entry,omitempty"`
}
func Match(cache File, webURL string) (Entry, bool) {
result := Resolve(cache, webURL)
return result.Entry, result.Status == MatchStatusFound
}
func Resolve(cache File, webURL string) MatchResult {
target := normalizeURL(webURL)
if target.host == "" {
return MatchResult{Status: MatchStatusMissing}
}
exactHost := make([]Entry, 0)
parentHost := make([]Entry, 0)
for _, entry := range cache.Entries {
if entryMatchesHost(entry, target.host) {
exactHost = append(exactHost, entry)
continue
}
if entryMatchesParentHost(entry, target.host) {
parentHost = append(parentHost, entry)
}
}
if result := chooseEntry(target, exactHost); result.Status != MatchStatusMissing {
return result
}
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 {
targets := collectTargets(item)
host := normalizeHost(item.URL)
if host == "" {
for _, target := range targets {
host = normalizeHost(target)
if host != "" {
break
}
}
}
if host == "" {
continue
}
if strings.TrimSpace(item.Username) == "" || strings.TrimSpace(item.Password) == "" {
continue
}
entries = append(entries, Entry{
ID: item.ID,
Title: item.Title,
Username: item.Username,
Password: item.Password,
URL: item.URL,
Host: host,
Targets: targets,
Path: append([]string(nil), item.Path...),
})
}
return File{
UpdatedAt: now.UTC().Format(time.RFC3339),
Entries: entries,
}
}
func Write(path string, model vault.Model, now time.Time) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(Build(model, now), "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
func Clear(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
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 normalizedTarget{}
}
if !strings.Contains(value, "://") {
value = "https://" + value
}
parsed, err := url.Parse(value)
if err != nil {
return normalizedTarget{}
}
host := strings.TrimSpace(parsed.Hostname())
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) MatchResult {
switch len(entries) {
case 0:
return MatchResult{Status: MatchStatusMissing}
case 1:
return MatchResult{Status: MatchStatusFound, Entry: entries[0]}
}
exact := make([]Entry, 0)
bestPrefixLen := -1
bestPrefix := make([]Entry, 0)
for _, entry := range entries {
exactMatch, prefixLen := bestTargetMatch(entry, target)
if exactMatch {
exact = append(exact, entry)
continue
}
if prefixLen <= 0 {
continue
}
switch {
case prefixLen > bestPrefixLen:
bestPrefixLen = prefixLen
bestPrefix = []Entry{entry}
case prefixLen == bestPrefixLen:
bestPrefix = append(bestPrefix, entry)
}
}
if len(exact) == 1 {
return MatchResult{Status: MatchStatusFound, Entry: exact[0]}
}
if len(exact) > 1 {
return MatchResult{Status: MatchStatusAmbiguous}
}
if len(bestPrefix) == 1 {
return MatchResult{Status: MatchStatusFound, Entry: bestPrefix[0]}
}
if len(bestPrefix) == 0 {
return MatchResult{Status: MatchStatusMissing}
}
return MatchResult{Status: MatchStatusAmbiguous}
}
func collectTargets(item vault.Entry) []string {
seen := make(map[string]struct{})
targets := make([]string, 0, 1+len(item.Fields))
appendTarget := func(raw string) {
value := strings.TrimSpace(raw)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
targets = append(targets, value)
}
appendTarget(item.URL)
keys := make([]string, 0, len(item.Fields))
for key := range item.Fields {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
upper := strings.ToUpper(strings.TrimSpace(key))
if strings.HasPrefix(upper, "ANDROIDAPP") || strings.HasPrefix(upper, "KP2A_URL") {
appendTarget(item.Fields[key])
}
}
return targets
}
func entryTargets(entry Entry) []normalizedTarget {
values := entry.Targets
if len(values) == 0 {
values = []string{entry.URL}
}
targets := make([]normalizedTarget, 0, len(values))
for _, value := range values {
target := normalizeURL(value)
if target.host == "" {
continue
}
targets = append(targets, target)
}
return targets
}
func entryMatchesHost(entry Entry, host string) bool {
for _, target := range entryTargets(entry) {
if target.host == host {
return true
}
}
return false
}
func entryMatchesParentHost(entry Entry, host string) bool {
for _, target := range entryTargets(entry) {
if target.host != "" && strings.HasSuffix(host, "."+target.host) {
return true
}
}
return false
}
func bestTargetMatch(entry Entry, target normalizedTarget) (bool, int) {
bestPrefixLen := -1
for _, candidate := range entryTargets(entry) {
if candidate.url == target.url {
return true, 0
}
if candidate.path != "/" && strings.HasPrefix(target.path, candidate.path) {
if pathLen := len(candidate.path); pathLen > bestPrefixLen {
bestPrefixLen = pathLen
}
}
}
return false, bestPrefixLen
}
+339
View File
@@ -0,0 +1,339 @@
package autofillcache
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"git.julianfamily.org/keepassgo/internal/vault"
)
func TestBuildFiltersAndNormalizesEntries(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)
got := Build(vault.Model{
Entries: []vault.Entry{
{
ID: "one",
Title: "Chrome Test",
Username: "joe",
Password: "secret",
URL: "https://10.0.2.2:8443/login",
Path: []string{"Crew", "Internet"},
},
{
ID: "two",
Title: "No Password",
Username: "joe",
URL: "https://example.com",
},
{
ID: "three",
Title: "Bare Host",
Username: "user",
Password: "pass",
URL: "surveillance.crew.example.invalid",
Fields: map[string]string{
"AndroidApp1": "androidapp://com.lights.mobile",
"KP2A_URL_1": "https://surveillance.crew.example.invalid/account",
},
},
},
}, now)
if len(got.Entries) != 2 {
t.Fatalf("entry count = %d, want 2", len(got.Entries))
}
if got.Entries[0].Host != "10.0.2.2" {
t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host)
}
if got.Entries[1].Host != "surveillance.crew.example.invalid" {
t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host)
}
if len(got.Entries[1].Targets) != 3 {
t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets))
}
if got.UpdatedAt != "2026-03-31T12:00:00Z" {
t.Fatalf("updatedAt = %q", got.UpdatedAt)
}
}
func TestWriteAndClear(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "autofill-cache.json")
model := vault.Model{
Entries: []vault.Entry{
{ID: "one", Title: "Chrome Test", Username: "joe", Password: "secret", URL: "https://10.0.2.2:8443/login"},
},
}
if err := Write(path, model, time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)); err != nil {
t.Fatalf("Write() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
var got File
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(got.Entries) != 1 || got.Entries[0].Host != "10.0.2.2" {
t.Fatalf("cache entries = %#v", got.Entries)
}
if err := Clear(path); err != nil {
t.Fatalf("Clear() error = %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(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 TestResolveReportsFoundAmbiguousAndMissingStatuses(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Admin Login",
Username: "admin",
Password: "secret1",
URL: "https://example.com/admin",
Host: "example.com",
},
{
ID: "two",
Title: "Shared Login A",
Username: "shared-a",
Password: "secret2",
URL: "https://shared.example.com",
Host: "shared.example.com",
},
{
ID: "three",
Title: "Shared Login B",
Username: "shared-b",
Password: "secret3",
URL: "https://shared.example.com",
Host: "shared.example.com",
},
},
}
if got := Resolve(cache, "https://example.com/admin/login"); got.Status != MatchStatusFound || got.Entry.ID != "one" {
t.Fatalf("Resolve(found) = %#v, want found entry one", got)
}
if got := Resolve(cache, "https://shared.example.com"); got.Status != MatchStatusAmbiguous {
t.Fatalf("Resolve(ambiguous) = %#v, want ambiguous", got)
}
if got := Resolve(cache, "https://nowhere.invalid"); got.Status != MatchStatusMissing {
t.Fatalf("Resolve(missing) = %#v, want missing", got)
}
}
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)
}
}
func TestMatchSupportsAndroidAppPackageTargets(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Thunderbird",
Username: "mail-user",
Password: "secret1",
URL: "androidapp://org.mozilla.thunderbird/login",
Host: "org.mozilla.thunderbird",
},
},
}
got, ok := Match(cache, "androidapp://org.mozilla.thunderbird")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
func TestMatchRejectsAmbiguousAndroidAppPackageTargets(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Thunderbird Primary",
Username: "mail-user",
Password: "secret1",
URL: "androidapp://org.mozilla.thunderbird",
Host: "org.mozilla.thunderbird",
},
{
ID: "two",
Title: "Thunderbird Secondary",
Username: "other-user",
Password: "secret2",
URL: "androidapp://org.mozilla.thunderbird",
Host: "org.mozilla.thunderbird",
},
},
}
if _, ok := Match(cache, "androidapp://org.mozilla.thunderbird"); ok {
t.Fatalf("Match() unexpectedly resolved ambiguous android app package target")
}
}
func TestMatchUsesAndroidAppCustomFieldTarget(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Blink",
Username: "blink-user",
Password: "secret1",
URL: "https://account.blinknetwork.com",
Host: "account.blinknetwork.com",
Targets: []string{"https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"},
},
},
}
got, ok := Match(cache, "androidapp://com.blinknetwork.mobile2")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}
func TestMatchUsesKP2AURLCustomFieldTarget(t *testing.T) {
t.Parallel()
cache := File{
Entries: []Entry{
{
ID: "one",
Title: "Blink",
Username: "blink-user",
Password: "secret1",
URL: "https://blinknetwork.com",
Host: "blinknetwork.com",
Targets: []string{"https://blinknetwork.com", "https://account.blinknetwork.com"},
},
},
}
got, ok := Match(cache, "https://account.blinknetwork.com")
if !ok {
t.Fatalf("Match() found no entry")
}
if got.ID != "one" {
t.Fatalf("Match() entry = %q, want one", got.ID)
}
}