verstak-desktop/internal/core/hostname/hostname.go

124 lines
2.9 KiB
Go

package hostname
import (
"net/netip"
"net/url"
"strings"
"unicode"
"golang.org/x/net/idna"
)
const (
maxDNSHostnameLength = 253
maxDNSLabelLength = 63
)
// NormalizeHostnameV1 returns the canonical A-label hostname used by domain
// bindings. It accepts a bare DNS name, IPv4 address, bracketed IPv6 literal,
// localhost, or a single-label internal hostname. Invalid input returns "".
func NormalizeHostnameV1(input string) string {
value := strings.TrimSpace(input)
if value == "" || strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "\\/?#@") {
return ""
}
if strings.HasPrefix(value, "[") || strings.HasSuffix(value, "]") {
if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") {
return ""
}
return normalizeIPv6(value[1 : len(value)-1])
}
if strings.Contains(value, ":") {
return ""
}
value = strings.TrimSuffix(value, ".")
if value == "" || strings.HasSuffix(value, ".") {
return ""
}
if isNumericHostname(value) {
return normalizeIPv4(value)
}
return normalizeDNS(value)
}
// NormalizeURLHostnameV1 returns the canonical hostname from an HTTP(S) URL.
// Ports, paths, credentials, and fragments are intentionally not preserved.
func NormalizeURLHostnameV1(input string) string {
value := strings.TrimSpace(input)
if value == "" {
return ""
}
parsed, err := url.ParseRequestURI(value)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return ""
}
host := parsed.Hostname()
if host == "" {
return ""
}
if address, err := netip.ParseAddr(host); err == nil && address.Is6() {
return address.String()
}
return NormalizeHostnameV1(host)
}
func normalizeIPv4(value string) string {
address, err := netip.ParseAddr(value)
if err != nil || !address.Is4() || address.String() != value {
return ""
}
return address.String()
}
func normalizeIPv6(value string) string {
address, err := netip.ParseAddr(value)
if err != nil || !address.Is6() {
return ""
}
return address.String()
}
func normalizeDNS(value string) string {
ascii, err := idna.Lookup.ToASCII(value)
if err != nil {
return ""
}
ascii = strings.ToLower(strings.TrimSuffix(ascii, "."))
if ascii == "" || strings.HasSuffix(ascii, ".") || len(ascii) > maxDNSHostnameLength {
return ""
}
for _, label := range strings.Split(ascii, ".") {
if len(label) == 0 || len(label) > maxDNSLabelLength || !isDNSLabel(label) {
return ""
}
}
return ascii
}
func isNumericHostname(value string) bool {
for _, char := range value {
if (char < '0' || char > '9') && char != '.' {
return false
}
}
return true
}
func isDNSLabel(label string) bool {
if !isASCIIAlphaNum(label[0]) || !isASCIIAlphaNum(label[len(label)-1]) {
return false
}
for _, char := range label {
if !isASCIIAlphaNum(byte(char)) && char != '-' {
return false
}
}
return true
}
func isASCIIAlphaNum(char byte) bool {
return char >= 'a' && char <= 'z' || char >= '0' && char <= '9'
}