Repository: go-openapi/strfmt (go)

Issue:
Hostname should support Internationalized domain name (IDN) TLDs
Hostname RegEx should support a TLD that exists in the IANA list of TLDs at http://data.iana.org/TLD/tlds-alpha-by-domain.txt,
e.g. XN--11B4C3D, or कॉम.  

Currently it disallows numbers and hyphen in the last label of the Hostname (TLD):
`^([a-zA-Z0-9\p{S}\p{L}]((-?[a-zA-Z0-9\p{S}\p{L}]{0,62})?)|([a-zA-Z0-9\p{S}\p{L}](([a-zA-Z0-9-\p{S}\p{L}]{0,61}[a-zA-Z0-9\p{S}\p{L}])?)(\.)){1,}([a-zA-Z\p{L}]){2,63})$
`

Can I change it to allow digits and hyphens in the TLD, or create a new format called HostnameIDN?
`^([a-zA-Z0-9\p{S}\p{L}]((-?[a-zA-Z0-9\p{S}\p{L}]{0,62})?)|([a-zA-Z0-9\p{S}\p{L}](([a-zA-Z0-9-\p{S}\p{L}]{0,61}[a-zA-Z0-9\p{S}\p{L}])?)(\.)){1,}([a-zA-Z0-9-\p{L}]){2,63})$
`

Pull request:
Hostname with internationalized top-level domain names
This PR modifies the Hostname validation.

It no longer uses a regular expression, but implements the set of rules detailed at https://url.spec.whatwg.org/#concept-host-parser and implemented by most modern web browsers.

It supports IDNA rules regarding internationalized names with unicode.

In particular, this adds support for internationalized top-level domain names for the Hostname format:

* fixes #94

Tests:

* Test assertions have been revisited and augmented (source: https://github.com/web-platform-tests/wpt/blob/master/url/resources/urltestdata.json).
* Some previously valid hosts are now considered valid, as the checks on IDNA rules are now stricter (invalid puny codes, list of invalid code points, use of dashes, ...)
* Some previously invalid hosts are now considerd valid (e.g. trailing dot, IPv6 address, IPv4 with hex or octal notation)

Examples:

* "x." is a valid hostname according to WHATWG
* "1.1.1.1" is valid
* " "www.च.चऒ" is valid
* "www.詹姆斯.xn--11b4c3d" is valid
* "192.0x00A80001" and "0300.0250.0340.001" are both valid IPv4 representations

* "💩.123" is an invalid hostname (expected an IP v4 here)
* "ex$ample" and "<foo>" are invalid
* "www.詹姆斯.XN--1B4C3D" is invalid

NOTE: the dependency to golang.org/x/net/idna has been throttled to the latest version that do not require an upgrade of the go compiler > go1.21.

* co-authored by @ccoVeille
  Thanks for the review and help

Patch:
diff --git a/default.go b/default.go
index eb9a852..0c9514d 100644
--- a/default.go
+++ b/default.go
@@ -20,43 +20,21 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/mail"
-	"regexp"
+	"net/netip"
+	"strconv"
 	"strings"
 
 	"github.com/asaskevich/govalidator"
 	"github.com/google/uuid"
 	"go.mongodb.org/mongo-driver/bson"
+	"golang.org/x/net/idna"
 )
 
 const (
-	// HostnamePattern http://json-schema.org/latest/json-schema-validation.html#anchor114
-	//  A string instance is valid against this attribute if it is a valid
-	//  representation for an Internet host name, as defined by RFC 1034, section 3.1 [RFC1034].
-	//  http://tools.ietf.org/html/rfc1034#section-3.5
-	//  <digit> ::= any one of the ten digits 0 through 9
-	//  var digit = /[0-9]/;
-	//  <letter> ::= any one of the 52 alphabetic characters A through Z in upper case and a through z in lower case
-	//  var letter = /[a-zA-Z]/;
-	//  <let-dig> ::= <letter> | <digit>
-	//  var letDig = /[0-9a-zA-Z]/;
-	//  <let-dig-hyp> ::= <let-dig> | "-"
-	//  var letDigHyp = /[-0-9a-zA-Z]/;
-	//  <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str>
-	//  var ldhStr = /[-0-9a-zA-Z]+/;
-	//  <label> ::= <letter> [ [ <ldh-str> ] <let-dig> ]
-	//  var label = /[a-zA-Z](([-0-9a-zA-Z]+)?[0-9a-zA-Z])?/;
-	//  <subdomain> ::= <label> | <subdomain> "." <label>
-	//  var subdomain = /^[a-zA-Z](([-0-9a-zA-Z]+)?[0-9a-zA-Z])?(\.[a-zA-Z](([-0-9a-zA-Z]+)?[0-9a-zA-Z])?)*$/;
-	//  <domain> ::= <subdomain> | " "
+	// HostnamePattern http://json-schema.org/latest/json-schema-validation.html#anchor114.
 	//
-	// Additional validations:
-	//   - for FDQNs, top-level domain (e.g. ".com"), is at least to letters long (no special characters here)
-	//   - hostnames may start with a digit [RFC1123]
-	//   - special registered names with an underscore ('_') are not allowed in this context
-	//   - dashes are permitted, but not at the start or the end of a segment
-	//   - long top-level domain names (e.g. example.london) are permitted
-	//   - symbol unicode points are permitted (e.g. emoji) (not for top-level domain)
-	HostnamePattern = `^([a-zA-Z0-9\p{S}\p{L}]((-?[a-zA-Z0-9\p{S}\p{L}]{0,62})?)|([a-zA-Z0-9\p{S}\p{L}](([a-zA-Z0-9-\p{S}\p{L}]{0,61}[a-zA-Z0-9\p{S}\p{L}])?)(\.)){1,}([a-zA-Z\p{L}]){2,63})$`
+	// Deprecated: this package no longer uses regular expressions to validate hostnames.
+	HostnamePattern = `^([a-zA-Z0-9\p{S}\p{L}]((-?[a-zA-Z0-9\p{S}\p{L}]{0,62})?)|([a-zA-Z0-9\p{S}\p{L}](([a-zA-Z0-9-\p{S}\p{L}]{0,61}[a-zA-Z0-9\p{S}\p{L}])?)(\.)){1,}([a-zA-Z0-9-\p{L}]){2,63})$`
 
 	// json null type
 	jsonNull = "null"
@@ -84,32 +62,254 @@ const (
 	UUID5Pattern = `(?i)(^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$)|(^[0-9a-f]{12}5[0-9a-f]{3}[89ab][0-9a-f]{15}$)`
 )
 
-var (
-	rxHostname = regexp.MustCompile(HostnamePattern)
+var idnaHostChecker = idna.New(
+	idna.ValidateForRegistration(), // shorthand for [idna.StrictDomainName],  [idna.ValidateLabels], [idna.VerifyDNSLength], [idna.BidiRule]
 )
 
-// IsHostname returns true when the string is a valid hostname
+// IsHostname returns true when the string is a valid hostname.
+//
+// It follows the rules detailed at https://url.spec.whatwg.org/#concept-host-parser
+// and implemented by most modern web browsers.
+//
+// It supports IDNA rules regarding internationalized names with unicode.
+//
+// Besides:
+// * the empty string is not a valid host name
+// * a trailing dot is allowed in names and IPv4's (not IPv6)
+// * a host name can be a valid IPv4 (with decimal, octal or hexadecimal numbers) or IPv6 address
+// * IPv6 zones are disallowed
+// * top-level domains can be unicode (cf. https://www.iana.org/domains/root/db).
+//
+// NOTE: this validator doesn't check top-level domains against the IANA root database.
+// It merely ensures that a top-level domain in a FQDN is at least 2 code points long.
 func IsHostname(str string) bool {
-	if !rxHostname.MatchString(str) {
+	if len(str) == 0 {
+		return false
+	}
+
+	// IP v6 check
+	if ipv6Cleaned, found := strings.CutPrefix(str, "["); found {
+		ipv6Cleaned, found = strings.CutSuffix(ipv6Cleaned, "]")
+		if !found {
+			return false
+		}
+
+		return isValidIPv6(ipv6Cleaned)
+	}
+
+	// IDNA check
+	res, err := idnaHostChecker.ToASCII(strings.ToLower(str))
+	if err != nil || res == "" {
+		return false
+	}
+
+	parts := strings.Split(res, ".")
+
+	// IP v4 check
+	lastPart, lastIndex, shouldBeIPv4 := domainEndsAsNumber(parts)
+	if shouldBeIPv4 {
+		// domain ends in a number: must be an IPv4
+		return isValidIPv4(parts[:lastIndex+1]) // if the last part is a trailing dot, remove it
+	}
+
+	// check TLD length (excluding trailing dot)
+	const minTLDLength = 2
+	if lastIndex > 0 && len(lastPart) < minTLDLength {
+		return false
+	}
+
+	return true
+}
+
+// domainEndsAsNumber determines if a domain name ends with a decimal, octal or hex digit,
+// accounting for a possible trailing dot (the last part being empty in that case).
+//
+// It returns the last non-trailing dot part and if that part consists only of (dec/hex/oct) digits.
+func domainEndsAsNumber(parts []string) (lastPart string, lastIndex int, ok bool) {
+	// NOTE: using ParseUint(x, 0, 32) is not an option, as the IPv4 format supported why WHATWG
+	// doesn't support notations such as "0b1001" (binary digits) or "0o666" (alternate notation for octal digits).
+	lastIndex = len(parts) - 1
+	lastPart = parts[lastIndex]
+	if len(lastPart) == 0 {
+		// trailing dot
+		if len(parts) == 1 { // dot-only string: normally already ruled out by the IDNA check above
+			return lastPart, lastIndex, false
+		}
+
+		lastIndex--
+		lastPart = parts[lastIndex]
+	}
+
+	if startOfHexDigit(lastPart) {
+		for _, b := range []byte(lastPart[2:]) {
+			if !isHexDigit(b) {
+				return lastPart, lastIndex, false
+			}
+		}
+
+		return lastPart, lastIndex, true
+	}
+
+	// check for decimal and octal
+	for _, b := range []byte(lastPart) {
+		if !isASCIIDigit(b) {
+			return lastPart, lastIndex, false
+		}
+	}
+
+	return lastPart, lastIndex, true
+}
+
+func startOfHexDigit(str string) bool {
+	return strings.HasPrefix(str, "0x") // the input has already been lower-cased
+}
+
+func startOfOctalDigit(str string) bool {
+	if str == "0" {
+		// a single "0" is considered decimal
+		return false
+	}
+
+	return strings.HasPrefix(str, "0")
+}
+
+func isValidIPv6(str string) bool {
+	// disallow empty ipv6 address
+	if len(str) == 0 {
+		return false
+	}
+
+	addr, err := netip.ParseAddr(str)
+	if err != nil {
+		return false
+	}
+
+	if !addr.Is6() {
+		return false
+	}
+
+	// explicit desupport of IPv6 zones
+	if addr.Zone() != "" {
+		return false
+	}
+
+	return true
+}
+
+// isValidIPv4 parses an IPv4 with deciaml, hex or octal digit parts.
+//
+// We can't rely on [netip.ParseAddr] because we may get a mix of decimal, octal and hex digits.
+//
+// Examples of valid addresses not supported by [netip.ParseAddr] or [net.ParseIP]:
+//
+//	"192.0x00A80001"
+//	"0300.0250.0340.001"
+//	"1.0x.1.1"
+//
+// But not:
+//
+//	"0b1010.2.3.4"
+//	"0o07.2.3.4"
+func isValidIPv4(parts []string) bool {
+	// NOTE: using ParseUint(x, 0, 32) is not an option, even though it would simplify this code a lot.
+	// The IPv4 format supported why WHATWG doesn't support notations such as "0b1001" (binary digits)
+	// or "0o666" (alternate notation for octal digits).
+	const (
+		maxPartsInIPv4  = 4
+		maxDigitsInPart = 11 // max size of a 4-bytes hex or octal digit
+	)
+
+	if len(parts) == 0 || len(parts) > maxPartsInIPv4 {
 		return false
 	}
 
-	// the sum of all label octets and label lengths is limited to 255.
-	const maxHostnameLength = 255
-	if len(str) > maxHostnameLength {
+	// we call this when we know that the last part is a digit part, so len(lastPart)>0
+
+	digits := make([]uint64, 0, maxPartsInIPv4)
+	for _, part := range parts {
+		if len(part) == 0 { // empty part: this case has normally been already ruled out by the IDNA check above
+			return false
+		}
+
+		if len(part) > maxDigitsInPart { // whether decimal, octal or hex, an address can't exceed that length
+			return false
+		}
+
+		if !isASCIIDigit(part[0]) { // start of an IPv4 part is always a digit
+			return false
+		}
+
+		switch {
+		case startOfHexDigit(part):
+			const hexDigitOffset = 2
+			hexString := part[hexDigitOffset:]
+			if len(hexString) == 0 { // 0x part: assume 0
+				digits = append(digits, 0)
+
+				continue
+			}
+
+			hexDigit, err := strconv.ParseUint(hexString, 16, 32)
+			if err != nil {
+				return false
+			}
+
+			digits = append(digits, hexDigit)
+
+			continue
+
+		case startOfOctalDigit(part):
+			const octDigitOffset = 1
+			octString := part[octDigitOffset:] // we know that this is not empty
+			octDigit, err := strconv.ParseUint(octString, 8, 32)
+			if err != nil {
+				return false
+			}
+
+			digits = append(digits, octDigit)
+
+		default: // assume decimal digits (0-255)
+			// we know that we don't have a leading 0 (would have been caught by octal digit)
+			decDigit, err := strconv.ParseUint(part, 10, 8)
+			if err != nil {
+				return false
+			}
+
+			digits = append(digits, decDigit)
+		}
+	}
+
+	// now check the digits: the last digit may encompass several parts of the address
+	lastDigit := digits[len(digits)-1]
+	if lastDigit > uint64(1)<<uint64(8*(maxPartsInIPv4+1-len(digits))) { //nolint:gosec,mnd // 256^(5 - len(digits)) - safe conversion
 		return false
 	}
 
-	// Each node has a label, which is zero to 63 octets in length
-	const maxNodeLength = 63
-	parts := strings.Split(str, ".")
-	valid := true
-	for _, p := range parts {
-		if len(p) > maxNodeLength {
-			valid = false
+	if len(digits) > 1 {
+		const maxUint8 = uint64(^uint8(0))
+
+		for i := 0; i < len(digits)-2; i++ {
+			if digits[i] > maxUint8 {
+				return false
+			}
 		}
 	}
-	return valid
+
+	return true
+}
+
+func isHexDigit(c byte) bool {
+	switch {
+	case '0' <= c && c <= '9':
+		return true
+	case 'a' <= c && c <= 'f': // assume the input string to be lower case
+		return true
+	}
+	return false
+}
+
+func isASCIIDigit(c byte) bool {
+	return c >= '0' && c <= '9'
 }
 
 // IsUUID returns true is the string matches a UUID (in any version, including v6 and v7), upper case is allowed
diff --git a/go.mod b/go.mod
index 09afa38..18a05f4 100644
--- a/go.mod
+++ b/go.mod
@@ -8,12 +8,14 @@ require (
 	github.com/oklog/ulid v1.3.1
 	github.com/stretchr/testify v1.10.0
 	go.mongodb.org/mongo-driver v1.17.3
+	golang.org/x/net v0.35.0
 )
 
 require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
+	golang.org/x/text v0.22.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
 
-go 1.20
+go 1.20.0
diff --git a/go.sum b/go.sum
index 8d6659d..6e5ebda 100644
--- a/go.sum
+++ b/go.sum
@@ -19,6 +19,10 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
 go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
+golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=


Test patch:
diff --git a/default_test.go b/default_test.go
index 87371d8..79316d0 100644
--- a/default_test.go
+++ b/default_test.go
@@ -75,7 +75,6 @@ func TestFormatHostname(t *testing.T) {
 	invalidHostnames := []string{
 		"somewhere.com!",
 		"user@email.domain",
-		"1.1.1.1",
 		veryLongStr,
 		longAddrSegment,
 		// dashes
@@ -87,22 +86,68 @@ func TestFormatHostname(t *testing.T) {
 		"www.-d.org",
 		"www-",
 		"-www",
+		"a.b.c.dot-",
 		// other characters (not in symbols)
 		"www.ex ample.org",
 		"_www.example.org",
 		"www.ex;ample.org",
 		"www.example_underscored.org",
-		// short top-level domains
+		// top-level domains too short
 		"www.詹姆斯.x",
 		"a.b.c.d",
-		"-xyz",
-		"xyz-",
-		"x.",
-		"a.b.c.dot-",
+		"www.詹姆斯.XN--1B4C3D", // invalid puny code
+		"@www",
 		"a.b.c.é;ö",
+		// these code points are invalid
+		"ex=ample.com",
+		"ex$ample",
+		"example^example",
+		"<foo>",
+		"ex_ample",
+		"ex*ample",
+		"ex\\ample",
+		"www.\u0025",
+		"www.\u007f",
+		"www.\u0000",
+		"www..com",
+		"..example.com",
+		"5512",                      // only digits is invalid
+		"[fe80::b059:65f4:e877:c40", // invalid ip v6
+		"fe80::b059:65f4:e877:c40]",
+		"[192.168.250.1]",               // is an ip v4 not an ip v6
+		"[",                             // invalid start of ipv6
+		"[]",                            // empty ip v6
+		"[1:2:3:4:5:6:7:8:9]",           // invalid ip v6
+		"[:1]]",                         // invalid ip v6
+		"[1::1::1]]",                    // invalid ip v6
+		"[fe80::1%en0]",                 // ip v6 with zone
+		"[fe80::b059:65f4:e877:c40%20]", // ip v6 with zone
+		"[2001:0db8:85a3:0000:0000:8a2e:0370:7334].", // invalid in this context
+		"", // empty host
+		".",
+		"..",
+		"192.168.219.168.254",    // invalid ip v4
+		"256.256.256.256",        // looks like an IP v4 but is not
+		"192.168..168",           // invalid ip v4
+		"192.168.0xg.168",        // invalid ip v4
+		"0..0x300",               // out of range IP
+		"1.2.3.09",               // leading 0, not an octal value
+		"09.2.3.4",               // leading 0, not an octal value
+		"0x100.2.3.4",            // out of range IP v4
+		"192.0xffA80001",         // out of range IP v4
+		"0x0a.2.0x0000000000f.3", // number part is too long
+		"foo.2.3.4",              // expected an IP v4
+		"foo.09",                 // expected an IP v4
+		"foo.0x04",               // expected an IP v4
+		"💩.123",                  //  expected an IP v4
+		"0b1010.2.3.4",           // unsupported binary digits
+		"0o07.2.3.4",             // unsupported alternated octal notation
+		"localhost:81",
 	}
+
 	validHostnames := []string{
 		"somewhere.com",
+		"Somewhere.Com",
 		"888.com",
 		"a.com",
 		"a.b.com",
@@ -121,12 +166,15 @@ func TestFormatHostname(t *testing.T) {
 		"xn-80ak6aa92e.co",
 		"xn-80ak6aa92e.com",
 		"xn--ls8h.la",
+		"x.",       // valid trailing dot
+		"foo.bar.", // valid trailing dot
+		// extended symbol alphabet
 		"☁→❄→☃→☀→☺→☂→☹→✝.ws",
+		"💩.tv",
 		"www.example.onion",
 		"www.example.ôlà",
 		"ôlà.ôlà",
 		"ôlà.ôlà.ôlà",
-		"ex$ample",
 		"localhost",
 		"example",
 		"x",
@@ -134,15 +182,30 @@ func TestFormatHostname(t *testing.T) {
 		"a.b.c.dot",
 		"www.example.org",
 		"a.b.c.d.e.f.g.dot",
-		// extended symbol alphabet
-		"ex=ample.com",
-		"<foo>",
 		"www.example-hyphenated.org",
+		"foo.x04", // valid (last part not a number)
+		"foo.0xz", // valid (last part not a number)
 		// localized hostnames
 		"www.詹姆斯.org",
+		"example.إختبار",
 		"www.élégigôö.org",
-		// long top-level domains
-		"www.詹姆斯.london",
+		"www.詹姆斯.london", // long top-level domain
+		// localized top-level domains (valid unicode top-level domains)
+		"www.च.चऒ",
+		"www.कॉम",
+		"www.詹姆斯.xn--11b4c3d", // valid puny code
+		"1.1.1.1",             // is a valid IP v4 address
+		"1.1.1.1.",            // is a valid IP v4 address, with trailing dot
+		"1.1.1.06",            // valid IP, with last part octal
+		"1.1.1.0xf",           // valid IP, with last part hex
+		"1.1.1.0xz",           // valid hostname, not IP
+		"1.0.1.1",             // is a valid IP v4 address
+		"1.0x.1.1",            // is a valid IP v4 address
+		"[2001:0db8:85a3:0000:0000:8a2e:0370:7334]", // is a valid IP v6 address
+		"192.168.219.a1",     // looks like an invalid ip v4, but is actually a valid domain
+		"192.0x00A80001",     // mixed decimal / hex IP v4
+		"0300.0250.0340.001", // octal IP v4
+		"1.2.3.00",           // leading 0, valid octal value
 	}
 
 	testStringFormat(t, &hostname, "hostname", str, []string{}, invalidHostnames)
@@ -897,3 +960,71 @@ func benchmarkIs(input []string, fn func(string) bool) func(*testing.B) {
 		fmt.Fprintln(io.Discard, isTrue)
 	}
 }
+
+func BenchmarkIsHostname(b *testing.B) {
+	hostnames := []string{
+		"somewhere.com",
+		"888.com",
+		"a.com",
+		"a.b.com",
+		"a.b.c.com",
+		"a.b.c.d.com",
+		"a.b.c.d.e.com",
+		"1.com",
+		"1.2.com",
+		"1.2.3.com",
+		"1.2.3.4.com",
+		"99.domain.com",
+		"99.99.domain.com",
+		"1wwworg.example.com",
+		"1000wwworg.example.com",
+		"xn--bcher-kva.example.com",
+		"xn-80ak6aa92e.co",
+		"xn-80ak6aa92e.com",
+		"xn--ls8h.la",
+		"☁→❄→☃→☀→☺→☂→☹→✝.ws",
+		"www.example.onion",
+		"www.example.ôlà",
+		"ôlà.ôlà",
+		"ôlà.ôlà.ôlà",
+		"ex$ample",
+		"localhost",
+		"example",
+		"x",
+		"x-y",
+		"a.b.c.dot",
+		"www.example.org",
+		"a.b.c.d.e.f.g.dot",
+		"ex=ample.com",
+		"<foo>",
+		"www.example-hyphenated.org",
+		"www.詹姆斯.org",
+		"www.élégigôö.org",
+		"www.詹姆斯.london",
+	}
+	rxHostname := regexp.MustCompile(HostnamePattern)
+
+	b.Run("IsHostname - regexp", benchmarkIs(hostnames, func(str string) bool {
+		// regexp-based version of IsHostname
+		if !rxHostname.MatchString(str) {
+			return false
+		}
+
+		const maxHostnameLength = 255
+		if len(str) > maxHostnameLength {
+			return false
+		}
+
+		const maxNodeLength = 63
+		parts := strings.Split(str, ".")
+		valid := true
+		for _, p := range parts {
+			if len(p) > maxNodeLength {
+				valid = false
+			}
+		}
+		return valid
+
+	}))
+	b.Run("IsHostname - idna", benchmarkIs(hostnames, IsHostname))
+}