1//go:generate sh -c "curl https://publicsuffix.org/list/public_suffix_list.dat >public_suffix_list.txt"
3// Package publicsuffix implements a public suffix list to look up the
4// organizational domain for a given host name. Organizational domains can be
5// registered, one level below a top-level domain.
7// Example.com has a public suffix ".com", and example.co.uk has a public
8// suffix ".co.uk". The organizational domain of sub.example.com is
9// example.com, and the organization domain of sub.example.co.uk is
25 "golang.org/x/net/idna"
27 "github.com/mjl-/mox/dns"
28 "github.com/mjl-/mox/mlog"
31// todo: automatically fetch new lists periodically? compare it with the old one. refuse it if it changed too much, especially if it contains far fewer entries than before.
33// Labels map from utf8 labels to labels for subdomains.
34// The end is marked with an empty string as label.
35type labels map[string]labels
37// List is a public suffix list.
39 includes, excludes labels
42var publicsuffixList List
44//go:embed public_suffix_list.txt
45var publicsuffixData []byte
48 log := mlog.New("publicsuffix", nil)
49 l, err := ParseList(log.Logger, bytes.NewReader(publicsuffixData))
51 log.Fatalx("parsing public suffix list", err)
56// ParseList parses a public suffix list.
57// Only the "ICANN DOMAINS" are used.
58func ParseList(elog *slog.Logger, r io.Reader) (List, error) {
59 log := mlog.New("publicsuffix", elog)
61 list := List{labels{}, labels{}}
62 br := bufio.NewReader(r)
67 line, err := br.ReadString('\n')
69 line = strings.TrimSpace(line)
70 if strings.HasPrefix(line, "// ===BEGIN ICANN DOMAINS===") {
73 } else if strings.HasPrefix(line, "// ===END ICANN DOMAINS===") {
76 } else if line == "" || strings.HasPrefix(line, "//") || !icannDomains {
82 if strings.HasPrefix(line, "!") {
85 t = strings.Split(line, ".")
87 log.Print("exclude rule with single label, skipping", slog.String("line", oline))
91 t = strings.Split(line, ".")
93 for i, w := range slices.Backward(t) {
96 log.Print("empty label in rule, skipping", slog.String("line", oline))
99 if w != "" && w != "*" {
100 w, err = idna.Lookup.ToUnicode(w)
102 log.Printx("invalid label, skipping", err, slog.String("line", oline))
107 if _, dup := m[""]; i == 0 && dup {
108 log.Print("duplicate rule", slog.String("line", oline))
117 l[""] = nil // Mark end.
123 return List{}, fmt.Errorf("reading public suffix list: %w", err)
129// Lookup calls Lookup on the builtin public suffix list, from
130// https://publicsuffix.org/list/.
131func Lookup(ctx context.Context, elog *slog.Logger, domain dns.Domain) (orgDomain dns.Domain) {
132 return publicsuffixList.Lookup(ctx, elog, domain)
135// Lookup returns the organizational domain. If domain is an organizational
136// domain, or higher-level, the same domain is returned.
137func (l List) Lookup(ctx context.Context, elog *slog.Logger, domain dns.Domain) (orgDomain dns.Domain) {
138 log := mlog.New("publicsuffix", elog)
140 log.Debug("publicsuffix lookup result", slog.Any("reqdom", domain), slog.Any("orgdom", orgDomain))
143 t := strings.Split(domain.Name(), ".")
146 if nexcl, ok := match(l.excludes, t); ok {
148 } else if nincl, ok := match(l.includes, t); ok {
156 name := strings.Join(t[len(t)-n:], ".")
158 return dns.Domain{ASCII: name}
160 t = strings.Split(domain.ASCII, ".")
161 ascii := strings.Join(t[len(t)-n:], ".")
162 return dns.Domain{ASCII: ascii, Unicode: name}
165func isASCII(s string) bool {
166 for _, c := range s {
174func match(l labels, t []string) (int, bool) {
182 if m, mok := l[s]; mok {
183 if nn, sok := match(m, t); sok {
187 if m, mok := l["*"]; mok {
188 if nn, sok := match(m, t); sok && nn >= n {
193 return n, n > 0 || mok