44from os import environ
55from pathlib import Path
66import re
7+ from stringprep import in_table_b1
78from typing import Optional , Set
9+ from unicodedata import normalize
810
911# local
1012from .utils import validator
@@ -37,6 +39,22 @@ def check(cls, tld: str):
3739 return tld in cls ._full_cache
3840
3941
42+ def _relies_on_idna_mapping (value : str ):
43+ """Return whether `value` only becomes a valid name through IDNA 2003 mapping.
44+
45+ Python's `idna` codec implements IDNA 2003 (RFC 3490), whose nameprep step
46+ (RFC 3491) drops invisible characters (RFC 3454, table B.1), folds
47+ compatibility characters such as fullwidth letters to ASCII, and treats
48+ U+3002 and similar dots as label separators. A value that needs any of
49+ these only looks like the name it encodes to, e.g. "example.com" written
50+ with fullwidth letters.
51+ """
52+ return not value .isascii () and (
53+ normalize ("NFKC" , value ) != value
54+ or any (in_table_b1 (char ) or char in "\u3002 \uff0e \uff61 " for char in value )
55+ )
56+
57+
4058@validator
4159def domain (
4260 value : str , / , * , consider_tld : bool = False , rfc_1034 : bool = False , rfc_2782 : bool = False
@@ -76,10 +94,15 @@ def domain(
7694 if not value :
7795 return False
7896
79- if consider_tld and not _IanaTLD .check (value .rstrip ("." ).rsplit ("." , 1 )[- 1 ].upper ()):
80- return False
81-
8297 try :
98+ if _relies_on_idna_mapping (value ):
99+ return False
100+
101+ if value .rstrip ("." ).rsplit ("." , 1 )[- 1 ].isdecimal ():
102+ # ref: RFC 1123 (2.1) and RFC 3696 (2), a TLD is not all-numeric. An
103+ # all-digit IDN label like "\u0661" is, but encodes to a letter-final A-label.
104+ return False
105+
83106 service_record = r"_" if rfc_2782 else ""
84107 trailing_dot = r"\.?$" if rfc_1034 else r"$"
85108
@@ -88,6 +111,11 @@ def domain(
88111 # ref: RFC 1035, presentation form is limited to 253 characters
89112 return False
90113
114+ # IANA lists IDN TLDs in their ASCII (punycode) form,
115+ # so look up the encoded TLD, not the raw one.
116+ if consider_tld and not _IanaTLD .check (encoded .rstrip ("." ).rsplit ("." , 1 )[- 1 ].upper ()):
117+ return False
118+
91119 return not re .search (r"\s|__+" , value ) and re .match (
92120 # First character of the domain
93121 rf"^(?:[a-z0-9{ service_record } ]"
0 commit comments