From 1db30565940d0da4d4878a36ed65a38ec1b68208 Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 19 Feb 2026 20:09:07 -0800 Subject: [PATCH] feat: add PSL-based target classification Classify DNS names as apex domains or hostnames using the Public Suffix List (golang.org/x/net/publicsuffix). Correctly handles multi-level TLDs like .co.uk and .com.au. --- internal/config/classify.go | 61 +++++++++++++++++++++++ internal/config/classify_test.go | 84 ++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 internal/config/classify.go create mode 100644 internal/config/classify_test.go diff --git a/internal/config/classify.go b/internal/config/classify.go new file mode 100644 index 0000000..e0fc3ff --- /dev/null +++ b/internal/config/classify.go @@ -0,0 +1,61 @@ +// Package config provides application configuration via Viper. +package config + +import ( + "fmt" + "strings" + + "golang.org/x/net/publicsuffix" +) + +// ClassifyTarget determines whether a DNS name is an apex domain +// (eTLD+1) or a hostname (subdomain of an eTLD+1). Returns +// "domain" or "hostname". Returns an error if the name is itself +// a public suffix (e.g. "co.uk") or otherwise invalid. +func ClassifyTarget(name string) (string, error) { + // Normalize: lowercase, strip trailing dot. + name = strings.ToLower(strings.TrimSuffix(name, ".")) + + if name == "" { + return "", fmt.Errorf("empty target name") + } + + apex, err := publicsuffix.EffectiveTLDPlusOne(name) + if err != nil { + return "", fmt.Errorf( + "invalid target %q: %w", name, err, + ) + } + + if name == apex { + return "domain", nil + } + + return "hostname", nil +} + +// classifyTargets splits a list of DNS names into apex domains +// and hostnames using the Public Suffix List. +func classifyTargets( + targets []string, +) (domains, hostnames []string, err error) { + for _, target := range targets { + kind, classifyErr := ClassifyTarget(target) + if classifyErr != nil { + return nil, nil, classifyErr + } + + switch kind { + case "domain": + domains = append(domains, strings.ToLower( + strings.TrimSuffix(target, "."), + )) + case "hostname": + hostnames = append(hostnames, strings.ToLower( + strings.TrimSuffix(target, "."), + )) + } + } + + return domains, hostnames, nil +} diff --git a/internal/config/classify_test.go b/internal/config/classify_test.go new file mode 100644 index 0000000..6e2759e --- /dev/null +++ b/internal/config/classify_test.go @@ -0,0 +1,84 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClassifyTarget(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + wantErr bool + }{ + {"apex .com", "example.com", "domain", false}, + {"apex .org", "example.org", "domain", false}, + {"apex .co.uk", "example.co.uk", "domain", false}, + {"apex .com.au", "example.com.au", "domain", false}, + {"subdomain www", "www.example.com", "hostname", false}, + {"subdomain api", "api.example.com", "hostname", false}, + {"deep subdomain", "a.b.c.example.com", "hostname", false}, + {"subdomain .co.uk", "www.example.co.uk", "hostname", false}, + {"trailing dot", "example.com.", "domain", false}, + {"trailing dot sub", "www.example.com.", "hostname", false}, + {"uppercase", "EXAMPLE.COM", "domain", false}, + {"mixed case", "Www.Example.Com", "hostname", false}, + {"public suffix", "co.uk", "", true}, + {"tld only", "com", "", true}, + {"empty", "", "", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result, err := ClassifyTarget(tc.input) + if tc.wantErr { + assert.Error(t, err) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestClassifyTargets(t *testing.T) { + t.Parallel() + + targets := []string{ + "example.com", + "www.example.com", + "api.example.com", + "example.co.uk", + "blog.example.co.uk", + } + + domains, hostnames, err := classifyTargets(targets) + require.NoError(t, err) + + assert.Equal( + t, + []string{"example.com", "example.co.uk"}, + domains, + ) + assert.Equal( + t, + []string{"www.example.com", "api.example.com", "blog.example.co.uk"}, + hostnames, + ) +} + +func TestClassifyTargets_RejectsPublicSuffix(t *testing.T) { + t.Parallel() + + _, _, err := classifyTargets([]string{"example.com", "co.uk"}) + assert.Error(t, err) +}