codekingpro/portable-devtools
114k
1import bisect2import re3import unicodedata4import warnings5from typing import Optional, Union6 7from . import idnadata8from .intranges import intranges_contain9 10_virama_combining_class = 911_alabel_prefix = b"xn--"12_unicode_dots_re = re.compile("[\u002e\u3002\uff0e\uff61]")13 14 15# Bidi category sets from RFC 5893, hoisted out of the per-codepoint loop16_bidi_rtl_first = frozenset({"R", "AL"})17_bidi_rtl_categories = frozenset({"R", "AL", "AN"})18_bidi_rtl_allowed = frozenset({"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"})19_bidi_rtl_valid_ending = frozenset({"R", "AL", "EN", "AN"})20_bidi_rtl_numeric = frozenset({"AN", "EN"})21_bidi_ltr_allowed = frozenset({"L", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"})22_bidi_ltr_valid_ending = frozenset({"L", "EN"})23_bidi_joiner_l_or_d = frozenset({ord("L"), ord("D")})24_bidi_joiner_r_or_d = frozenset({ord("R"), ord("D")})25 26 27class IDNAError(UnicodeError):28 """Base exception for all IDNA-encoding related problems"""29 30 pass31 32 33class IDNABidiError(IDNAError):34 """Exception when bidirectional requirements are not satisfied"""35 36 pass37 38 39class InvalidCodepoint(IDNAError):40 """Exception when a disallowed or unallocated codepoint is used"""41 42 pass43 44 45class InvalidCodepointContext(IDNAError):46 """Exception when the codepoint is not valid in the context it is used"""47 48 pass49 50 51def _combining_class(cp: int) -> int:52 v = unicodedata.combining(chr(cp))53 if v == 0 and not unicodedata.name(chr(cp)):54 raise ValueError("Unknown character in unicodedata")55 return v56 57 58def _is_script(cp: str, script: str) -> bool:59 return intranges_contain(ord(cp), idnadata.scripts[script])60 61 62def _punycode(s: str) -> bytes:63 return s.encode("punycode")64 65 66def _unot(s: int) -> str:67 return f"U+{s:04X}"68 69 70def valid_label_length(label: Union[bytes, str]) -> bool:71 """Check that a label does not exceed the maximum permitted length.72 73 Per :rfc:`1035` (and :rfc:`5891` §4.2.4) a DNS label must not exceed74 63 octets. The argument may be either a :class:`str` (a U-label, where75 length is measured in characters) or :class:`bytes` (an A-label, where76 length is measured in octets).77 78 :param label: The label to check.79 :returns: ``True`` if the label is within the length limit, otherwise80 ``False``.81 """82 return len(label) <= 6383 84 85def valid_string_length(domain: Union[bytes, str], trailing_dot: bool) -> bool:86 """Check that a full domain name does not exceed the maximum length.87 88 Per :rfc:`1035`, a domain name is limited to 253 octets when no trailing89 dot is present, or 254 octets when one is included.90 91 :param domain: The full (possibly multi-label) domain name.92 :param trailing_dot: ``True`` if ``domain`` includes a trailing ``.``.93 :returns: ``True`` if the domain is within the length limit, otherwise94 ``False``.95 """96 return len(domain) <= (254 if trailing_dot else 253)97 98 99def check_bidi(label: str, check_ltr: bool = False) -> bool:100 """Validate the Bidi Rule from :rfc:`5893` for a single label.101 102 The Bidi Rule constrains how bidirectional characters (Hebrew, Arabic,103 etc.) may appear within a label. By default the check is only applied104 when the label contains at least one right-to-left character (Unicode105 bidirectional categories ``R``, ``AL``, or ``AN``); set ``check_ltr``106 to ``True`` to apply it to LTR-only labels as well.107 108 :param label: The label to validate, as a Unicode string.109 :param check_ltr: If ``True``, apply the rules even when the label110 contains no RTL characters.111 :returns: ``True`` if the label satisfies the Bidi Rule.112 :raises IDNABidiError: If any of Bidi Rule conditions 1-6 are violated,113 or if the directional category of a codepoint cannot be determined.114 """115 # Bidi rules should only be applied if string contains RTL characters116 bidi_label = False117 for idx, cp in enumerate(label, 1):118 direction = unicodedata.bidirectional(cp)119 if direction == "":120 # String likely comes from a newer version of Unicode121 raise IDNABidiError(f"Unknown directionality in label {repr(label)} at position {idx}")122 if direction in _bidi_rtl_categories:123 bidi_label = True124 if not bidi_label and not check_ltr:125 return True126 127 # Bidi rule 1128 direction = unicodedata.bidirectional(label[0])129 if direction in _bidi_rtl_first:130 rtl = True131 elif direction == "L":132 rtl = False133 else:134 raise IDNABidiError(f"First codepoint in label {repr(label)} must be directionality L, R or AL")135 136 valid_ending = False137 number_type: Optional[str] = None138 for idx, cp in enumerate(label, 1):139 direction = unicodedata.bidirectional(cp)140 141 if rtl:142 # Bidi rule 2143 if direction not in _bidi_rtl_allowed:144 raise IDNABidiError(f"Invalid direction for codepoint at position {idx} in a right-to-left label")145 # Bidi rule 3146 if direction in _bidi_rtl_valid_ending:147 valid_ending = True148 elif direction != "NSM":149 valid_ending = False150 # Bidi rule 4151 if direction in _bidi_rtl_numeric:152 if not number_type:153 number_type = direction154 else:155 if number_type != direction:156 raise IDNABidiError("Can not mix numeral types in a right-to-left label")157 else:158 # Bidi rule 5159 if direction not in _bidi_ltr_allowed:160 raise IDNABidiError(f"Invalid direction for codepoint at position {idx} in a left-to-right label")161 # Bidi rule 6162 if direction in _bidi_ltr_valid_ending:163 valid_ending = True164 elif direction != "NSM":165 valid_ending = False166 167 if not valid_ending:168 raise IDNABidiError("Label ends with illegal codepoint directionality")169 170 return True171 172 173def check_initial_combiner(label: str) -> bool:174 """Reject labels that begin with a combining mark.175 176 Per :rfc:`5891` §4.2.3.2 a label must not start with a character of177 Unicode general category ``M`` (Mark).178 179 :param label: The label to check.180 :returns: ``True`` if the first character is not a combining mark.181 :raises IDNAError: If the label begins with a combining character.182 """183 if unicodedata.category(label[0])[0] == "M":184 raise IDNAError("Label begins with an illegal combining character")185 return True186 187 188def check_hyphen_ok(label: str) -> bool:189 """Validate the hyphen restrictions for a label.190 191 Per :rfc:`5891` §4.2.3.1 a label must not start or end with a hyphen192 (``U+002D``), and must not have hyphens in both the third and fourth193 positions (the prefix reserved for A-labels).194 195 :param label: The label to check.196 :returns: ``True`` if the hyphen restrictions are satisfied.197 :raises IDNAError: If any of the hyphen restrictions are violated.198 """199 if label[2:4] == "--":200 raise IDNAError("Label has disallowed hyphens in 3rd and 4th position")201 if label[0] == "-" or label[-1] == "-":202 raise IDNAError("Label must not start or end with a hyphen")203 return True204 205 206def check_nfc(label: str) -> None:207 """Require that a label is in Unicode Normalization Form C.208 209 :param label: The label to check.210 :raises IDNAError: If ``label`` differs from its NFC normalisation.211 """212 if unicodedata.normalize("NFC", label) != label:213 raise IDNAError("Label must be in Normalization Form C")214 215 216def valid_contextj(label: str, pos: int) -> bool:217 """Validate the CONTEXTJ rules from :rfc:`5892` Appendix A.218 219 These rules govern the contextual use of the joiner codepoints220 ``U+200C`` (ZERO WIDTH NON-JOINER, Appendix A.1) and ``U+200D``221 (ZERO WIDTH JOINER, Appendix A.2) within a label.222 223 :param label: The label containing the codepoint.224 :param pos: Index of the joiner codepoint within ``label``.225 :returns: ``True`` if the codepoint at ``pos`` satisfies its CONTEXTJ226 rule, ``False`` otherwise (including when the codepoint at227 ``pos`` is not a recognised joiner).228 :raises ValueError: If an adjacent codepoint has no Unicode name when229 determining its combining class.230 """231 cp_value = ord(label[pos])232 233 if cp_value == 0x200C:234 if pos > 0 and _combining_class(ord(label[pos - 1])) == _virama_combining_class:235 return True236 237 ok = False238 for i in range(pos - 1, -1, -1):239 joining_type = idnadata.joining_types().get(ord(label[i]))240 if joining_type == ord("T"):241 continue242 elif joining_type in _bidi_joiner_l_or_d:243 ok = True244 break245 else:246 break247 248 if not ok:249 return False250 251 ok = False252 for i in range(pos + 1, len(label)):253 joining_type = idnadata.joining_types().get(ord(label[i]))254 if joining_type == ord("T"):255 continue256 elif joining_type in _bidi_joiner_r_or_d:257 ok = True258 break259 else:260 break261 return ok262 263 if cp_value == 0x200D:264 return pos > 0 and _combining_class(ord(label[pos - 1])) == _virama_combining_class265 266 else:267 return False268 269 270def valid_contexto(label: str, pos: int, exception: bool = False) -> bool:271 """Validate the CONTEXTO rules from :rfc:`5892` Appendix A.272 273 Covers the contextual rules for codepoints such as MIDDLE DOT274 (``U+00B7``), Greek lower numeral sign, Hebrew punctuation, Katakana275 middle dot, and the Arabic-Indic / Extended Arabic-Indic digit ranges.276 277 :param label: The label containing the codepoint.278 :param pos: Index of the codepoint within ``label``.279 :param exception: Reserved for forward compatibility; currently unused.280 :returns: ``True`` if the codepoint at ``pos`` satisfies its CONTEXTO281 rule, ``False`` otherwise (including when the codepoint is not a282 recognised CONTEXTO codepoint).283 """284 cp_value = ord(label[pos])285 286 if cp_value == 0x00B7:287 return 0 < pos < len(label) - 1 and ord(label[pos - 1]) == 0x006C and ord(label[pos + 1]) == 0x006C288 289 elif cp_value == 0x0375:290 if pos < len(label) - 1 and len(label) > 1:291 return _is_script(label[pos + 1], "Greek")292 return False293 294 elif cp_value == 0x05F3 or cp_value == 0x05F4:295 if pos > 0:296 return _is_script(label[pos - 1], "Hebrew")297 return False298 299 elif cp_value == 0x30FB:300 for cp in label:301 if cp == "\u30fb":302 continue303 if _is_script(cp, "Hiragana") or _is_script(cp, "Katakana") or _is_script(cp, "Han"):304 return True305 return False306 307 elif 0x660 <= cp_value <= 0x669:308 return not any(0x6F0 <= ord(cp) <= 0x06F9 for cp in label)309 310 elif 0x6F0 <= cp_value <= 0x6F9:311 return not any(0x660 <= ord(cp) <= 0x0669 for cp in label)312 313 return False314 315 316def check_label(label: Union[str, bytes, bytearray]) -> None:317 """Run the full set of IDNA 2008 validity checks on a single label.318 319 Applies, in order: NFC normalisation (:func:`check_nfc`), hyphen320 restrictions (:func:`check_hyphen_ok`), the no-leading-combiner rule321 (:func:`check_initial_combiner`), per-codepoint validity (PVALID,322 CONTEXTJ, CONTEXTO classes from :rfc:`5892`), and the Bidi Rule323 (:func:`check_bidi`).324 325 :param label: The label to validate. ``bytes`` or ``bytearray`` input326 is decoded as UTF-8 first.327 :raises IDNAError: If the label is empty or fails a structural rule.328 :raises InvalidCodepoint: If the label contains a DISALLOWED or329 UNASSIGNED codepoint.330 :raises InvalidCodepointContext: If a CONTEXTJ or CONTEXTO codepoint331 is not valid in its context.332 :raises IDNABidiError: If the Bidi Rule is violated.333 """334 if isinstance(label, (bytes, bytearray)):335 label = label.decode("utf-8")336 if len(label) == 0:337 raise IDNAError("Empty Label")338 339 # Reject on domain length rather than label length so support some UTS 46340 # use cases, still reducing processing of label contextual rules341 if not valid_string_length(label, trailing_dot=True):342 raise IDNAError("Label too long")343 344 check_nfc(label)345 check_hyphen_ok(label)346 check_initial_combiner(label)347 348 for pos, cp in enumerate(label):349 cp_value = ord(cp)350 if intranges_contain(cp_value, idnadata.codepoint_classes["PVALID"]):351 continue352 elif intranges_contain(cp_value, idnadata.codepoint_classes["CONTEXTJ"]):353 try:354 if not valid_contextj(label, pos):355 raise InvalidCodepointContext(356 f"Joiner {_unot(cp_value)} not allowed at position {pos + 1} in {repr(label)}"357 )358 except ValueError as err:359 raise IDNAError(360 f"Unknown codepoint adjacent to joiner {_unot(cp_value)} at position {pos + 1} in {repr(label)}"361 ) from err362 elif intranges_contain(cp_value, idnadata.codepoint_classes["CONTEXTO"]):363 if not valid_contexto(label, pos):364 raise InvalidCodepointContext(365 f"Codepoint {_unot(cp_value)} not allowed at position {pos + 1} in {repr(label)}"366 )367 else:368 raise InvalidCodepoint(f"Codepoint {_unot(cp_value)} at position {pos + 1} of {repr(label)} not allowed")369 370 check_bidi(label)371 372 373def alabel(label: str) -> bytes:374 """Convert a single U-label into its A-label form.375 376 The result is the ASCII-Compatible Encoding (ACE) form per :rfc:`5891`377 §4: the label is validated, Punycode-encoded, and prefixed with378 ``xn--``. Pure ASCII labels that are already valid IDNA labels are379 returned unchanged (as :class:`bytes`).380 381 :param label: The label to convert, as a Unicode string.382 :returns: The A-label as ASCII-encoded :class:`bytes`.383 :raises IDNAError: If the label is invalid or the resulting A-label384 exceeds 63 octets.385 """386 try:387 label_bytes = label.encode("ascii")388 ulabel(label_bytes)389 if not valid_label_length(label_bytes):390 raise IDNAError("Label too long")391 return label_bytes392 except UnicodeEncodeError:393 pass394 395 check_label(label)396 label_bytes = _alabel_prefix + _punycode(label)397 398 if not valid_label_length(label_bytes):399 raise IDNAError("Label too long")400 401 return label_bytes402 403 404def ulabel(label: Union[str, bytes, bytearray]) -> str:405 """Convert a single A-label into its U-label form.406 407 Performs the inverse of :func:`alabel`: an ``xn--``-prefixed label is408 Punycode-decoded and validated. Labels that are already Unicode (or409 plain ASCII without the ACE prefix) are validated and returned as a410 Unicode string.411 412 :param label: The label to convert. ``bytes`` or ``bytearray`` input413 is treated as ASCII.414 :returns: The U-label as a Unicode string.415 :raises IDNAError: If the label is malformed or fails validation.416 """417 if not isinstance(label, (bytes, bytearray)):418 try:419 label_bytes = label.encode("ascii")420 except UnicodeEncodeError:421 check_label(label)422 return label423 else:424 label_bytes = bytes(label)425 426 label_bytes = label_bytes.lower()427 if label_bytes.startswith(_alabel_prefix):428 label_bytes = label_bytes[len(_alabel_prefix) :]429 if not label_bytes:430 raise IDNAError("Malformed A-label, no Punycode eligible content found")431 if label_bytes.endswith(b"-"):432 raise IDNAError("A-label must not end with a hyphen")433 else:434 check_label(label_bytes)435 return label_bytes.decode("ascii")436 437 try:438 label = label_bytes.decode("punycode")439 except UnicodeError as err:440 raise IDNAError("Invalid A-label") from err441 check_label(label)442 return label443 444 445def uts46_remap(domain: str, std3_rules: bool = True, transitional: bool = False) -> str:446 """Apply the UTS #46 character mapping to a domain string.447 448 Implements the mapping table from `UTS #46 §4449 <https://www.unicode.org/reports/tr46/>`_: each character is kept,450 replaced, or rejected based on its status (``V``, ``M``, ``D``, ``3``,451 ``I``). The result is returned in Normalisation Form C.452 453 :param domain: The full domain name to remap.454 :param std3_rules: If ``True``, apply the stricter STD3 ASCII rules455 (status ``3`` codepoints raise instead of being kept or mapped).456 :param transitional: If ``True``, use transitional processing (status457 ``D`` codepoints are mapped instead of kept). Transitional458 processing has been removed from UTS #46 and this option is459 retained only for backwards compatibility.460 :returns: The remapped domain, in Normalisation Form C.461 :raises InvalidCodepoint: If the domain contains a disallowed462 codepoint under the chosen rules.463 """464 from .uts46data import uts46data465 466 output = ""467 468 for pos, char in enumerate(domain):469 code_point = ord(char)470 uts46row = uts46data[code_point if code_point < 256 else bisect.bisect_left(uts46data, (code_point, "Z")) - 1]471 status = uts46row[1]472 replacement: Optional[str] = None473 if len(uts46row) == 3:474 replacement = uts46row[2] # ty: ignore[index-out-of-bounds]475 476 # UTS #46 §4: V is always valid, D is deviation (kept unless transitional),477 # 3 is disallowed-STD3 (kept unmapped if std3_rules is off and no mapping).478 keep_as_is = (479 status == "V" or (status == "D" and not transitional) or (status == "3" and not std3_rules and replacement is None)480 )481 # M is mapped, 3-with-replacement and transitional D fall through to the482 # same replacement output path.483 use_replacement = replacement is not None and (484 status == "M" or (status == "3" and not std3_rules) or (status == "D" and transitional)485 )486 487 if keep_as_is:488 output += char489 elif use_replacement:490 assert replacement is not None # narrowed by use_replacement491 output += replacement492 elif status == "I":493 continue494 else:495 raise InvalidCodepoint(f"Codepoint {_unot(code_point)} not allowed at position {pos + 1} in {repr(domain)}")496 497 return unicodedata.normalize("NFC", output)498 499 500def encode(501 s: Union[str, bytes, bytearray],502 strict: bool = False,503 uts46: bool = False,504 std3_rules: bool = False,505 transitional: bool = False,506) -> bytes:507 """Encode a Unicode domain name into its ASCII (A-label) form.508 509 Splits the input on label separators (only ``U+002E`` if ``strict`` is510 set; otherwise also IDEOGRAPHIC FULL STOP ``U+3002``, FULLWIDTH FULL511 STOP ``U+FF0E``, and HALFWIDTH IDEOGRAPHIC FULL STOP ``U+FF61``),512 encodes each label with :func:`alabel`, and rejoins them with ``.``.513 Optionally pre-processes the input through :func:`uts46_remap`.514 515 :param s: The domain name to encode.516 :param strict: If ``True``, only ``U+002E`` is recognised as a label517 separator.518 :param uts46: If ``True``, apply UTS #46 mapping before encoding.519 :param std3_rules: Forwarded to :func:`uts46_remap` when ``uts46`` is520 ``True``.521 :param transitional: Forwarded to :func:`uts46_remap` when ``uts46``522 is ``True``. Deprecated: emits a :class:`DeprecationWarning` and523 will be removed in a future version.524 :returns: The encoded domain as ASCII :class:`bytes`.525 :raises IDNAError: If the domain is empty, contains an invalid label,526 or exceeds the maximum domain length.527 """528 if transitional:529 warnings.warn(530 "Transitional processing has been removed from UTS #46. "531 "The transitional argument will be removed in a future version.",532 DeprecationWarning,533 stacklevel=2,534 )535 if not isinstance(s, str):536 try:537 s = str(s, "ascii")538 except (UnicodeDecodeError, TypeError) as err:539 raise IDNAError("should pass a unicode string to the function rather than a byte string.") from err540 if uts46:541 s = uts46_remap(s, std3_rules, transitional)542 543 # Reject inputs that exceed the maximum DNS domain length up-front544 # to avoid expensive computation on long inputs.545 if not valid_string_length(s, trailing_dot=True):546 raise IDNAError("Domain too long")547 548 trailing_dot = False549 result = []550 labels = s.split(".") if strict else _unicode_dots_re.split(s)551 if not labels or labels == [""]:552 raise IDNAError("Empty domain")553 if labels[-1] == "":554 del labels[-1]555 trailing_dot = True556 for label in labels:557 s = alabel(label)558 if s:559 result.append(s)560 else:561 raise IDNAError("Empty label")562 if trailing_dot:563 result.append(b"")564 s = b".".join(result)565 if not valid_string_length(s, trailing_dot):566 raise IDNAError("Domain too long")567 return s568 569 570def decode(571 s: Union[str, bytes, bytearray],572 strict: bool = False,573 uts46: bool = False,574 std3_rules: bool = False,575) -> str:576 """Decode an A-label-encoded domain name back to Unicode.577 578 Splits the input on label separators (see :func:`encode` for the579 rules), decodes each label with :func:`ulabel`, and rejoins them580 with ``.``. Optionally pre-processes the input through581 :func:`uts46_remap`.582 583 :param s: The domain name to decode.584 :param strict: If ``True``, only ``U+002E`` is recognised as a label585 separator.586 :param uts46: If ``True``, apply UTS #46 mapping before decoding.587 :param std3_rules: Forwarded to :func:`uts46_remap` when ``uts46`` is588 ``True``.589 :returns: The decoded domain as a Unicode string.590 :raises IDNAError: If the input is not valid ASCII, contains an591 invalid label, or is empty.592 """593 if not isinstance(s, str):594 try:595 s = str(s, "ascii")596 except (UnicodeDecodeError, TypeError) as err:597 raise IDNAError("Invalid ASCII in A-label") from err598 if uts46:599 s = uts46_remap(s, std3_rules, False)600 # Reject inputs that exceed the maximum DNS domain length up-front601 # to avoid expensive computation on long inputs.602 if not valid_string_length(s, trailing_dot=True):603 raise IDNAError("Domain too long")604 trailing_dot = False605 result = []606 labels = s.split(".") if strict else _unicode_dots_re.split(s)607 if not labels or labels == [""]:608 raise IDNAError("Empty domain")609 if not labels[-1]:610 del labels[-1]611 trailing_dot = True612 for label in labels:613 s = ulabel(label)614 if s:615 result.append(s)616 else:617 raise IDNAError("Empty label")618 if trailing_dot:619 result.append("")620 return ".".join(result)621 