package autofillcache import ( "encoding/json" "net/url" "os" "path/filepath" "sort" "strings" "time" "git.julianfamily.org/keepassgo/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 }