diff --git a/docs/index.md b/docs/index.md index 53d151d6..4ed81bac 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,6 +12,8 @@ ::: validators.hashes +::: validators.hostname + ::: validators.iban ::: validators.ip_address diff --git a/tests/test_hostname.py b/tests/test_hostname.py new file mode 100644 index 00000000..e889c37b --- /dev/null +++ b/tests/test_hostname.py @@ -0,0 +1,68 @@ +"""Test Hostname.""" +# -*- coding: utf-8 -*- + +# external +import pytest + +# local +from validators import hostname, ValidationFailure + + +@pytest.mark.parametrize( + ("value", "rfc_1034", "rfc_2782"), + [ + # simple hostname w/ optional ports + ("ubuntu-pc:443", False, False), + ("this-pc", False, False), + ("lab-01a-notebook:404", False, False), + ("4-oh-4", False, False), + # hostname w/ optional ports + ("example.com:4444", False, False), + ("kräuter.com.", True, False), + ("xn----gtbspbbmkef.xn--p1ai:65535", False, False), + ("_example.com", False, True), + # ipv4 addr w/ optional ports + ("123.123.123.123:9090", False, False), + ("127.0.0.1:43512", False, False), + ("123.5.77.88:31000", False, False), + ("12.12.12.12:5353", False, False), + # ipv6 addr w/ optional ports + ("[::1]:22", False, False), + ("[dead:beef:0:0:0:0000:42:1]:5731", False, False), + ("[0:0:0:0:0:ffff:1.2.3.4]:80", False, False), + ("[0:a:b:c:d:e:f::]:53", False, False), + ], +) +def test_returns_true_on_valid_hostname(value: str, rfc_1034: bool, rfc_2782: bool): + """Test returns true on valid hostname.""" + assert hostname(value, rfc_1034=rfc_1034, rfc_2782=rfc_2782) + + +@pytest.mark.parametrize( + ("value", "rfc_1034", "rfc_2782"), + [ + # bad (simple hostname w/ optional ports) + ("ubuntu-pc:443080", False, False), + ("this-pc-is-sh*t", False, False), + ("lab-01a-note._com_.com:404", False, False), + ("4-oh-4:@.com", False, False), + # bad (hostname w/ optional ports) + ("example.com:-4444", False, False), + ("xn----gtbspbbmkef.xn--p1ai:65538", False, False), + ("_example.com:0", False, True), + ("kräuter.com.:81_00", True, False), + # bad (ipv4 addr w/ optional ports) + ("123.123.123.123:99999", False, False), + ("127.0.0.1:", False, False), + ("123.5.-12.88:8080", False, False), + ("12.12.12.12:$#", False, False), + # bad (ipv6 addr w/ optional ports) + ("[::1]:[22]", False, False), + ("[dead:beef:0:-:0:-:42:1]:5731", False, False), + ("[0:0:0:0:0:ffff:1.2.3.4]:-65538", False, False), + ("[0:&:b:c:@:e:f:::9999", False, False), + ], +) +def test_returns_failed_validation_on_invalid_hostname(value: str, rfc_1034: bool, rfc_2782: bool): + """Test returns failed validation on invalid hostname.""" + assert isinstance(hostname(value, rfc_1034=rfc_1034, rfc_2782=rfc_2782), ValidationFailure) diff --git a/validators/__init__.py b/validators/__init__.py index 2005ed88..1ba5d816 100644 --- a/validators/__init__.py +++ b/validators/__init__.py @@ -7,6 +7,7 @@ from .domain import domain from .email import email from .hashes import md5, sha1, sha224, sha256, sha512 +from .hostname import hostname from .i18n import fi_business_id, fi_ssn from .iban import iban from .ip_address import ipv4, ipv6 @@ -28,6 +29,7 @@ "email", "fi_business_id", "fi_ssn", + "hostname", "iban", "ipv4", "ipv6", diff --git a/validators/hostname.py b/validators/hostname.py new file mode 100644 index 00000000..db4696d7 --- /dev/null +++ b/validators/hostname.py @@ -0,0 +1,107 @@ +"""Hostname.""" +# -*- coding: utf-8 -*- + +# standard +from functools import lru_cache +import re + +# local +from .ip_address import ipv4, ipv6 +from .utils import validator +from .domain import domain + + +@lru_cache +def _port_regex(): + """Port validation regex.""" + return re.compile( + r"^\:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|" + + r"6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3})$", + ) + + +@lru_cache +def _simple_hostname_regex(): + """Simple hostname validation regex.""" + return re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$") + + +@validator +def hostname( + value: str, + /, + *, + may_have_port: bool = True, + skip_ip_addr: bool = False, + maybe_simple: bool = True, + rfc_1034: bool = False, + rfc_2782: bool = False, +): + """Return whether or not given value is a valid hostname. + + Examples: + >>> hostname("ubuntu-pc:443") + # Output: True + >>> hostname("this-pc") + # Output: True + >>> hostname("xn----gtbspbbmkef.xn--p1ai:65535") + # Output: True + >>> hostname("_example.com") + # Output: True + >>> hostname("123.5.77.88:31000") + # Output: True + >>> hostname("12.12.12.12") + # Output: True + >>> hostname("[::1]:22") + # Output: True + >>> hostname("dead:beef:0:0:0:0000:42:1") + # Output: True + >>> hostname("[0:0:0:0:0:ffff:1.2.3.4]:-65538") + # Output: ValidationFailure(func=hostname, ...) + >>> hostname("[0:&:b:c:@:e:f::]:9999") + # Output: ValidationFailure(func=hostname, ...) + + Args: + value: + Hostname string to validate. + may_have_port: + Hostname string may contain port number. + skip_ip_addr: + When hostname string cannot be an IP address. + maybe_simple: + Hostname string maybe only hyphens and alpha-numerals. + rfc_1034: + Allow trailing dot in domain/host name. + Ref: [RFC 1034](https://www.rfc-editor.org/rfc/rfc1034). + rfc_2782: + Domain/Host name is of type service record. + Ref: [RFC 2782](https://www.rfc-editor.org/rfc/rfc2782). + + Returns: + (Literal[True]): + If `value` is a valid hostname. + (ValidationFailure): + If `value` is an invalid hostname. + + > *New in version 0.21.0*. + """ + if may_have_port: + if value.count("]:") == 1 and not skip_ip_addr: + host_seg, port_seg = value.rsplit(":", 1) + return _port_regex().match(f":{port_seg}") and ipv6( + host_seg.lstrip("[").rstrip("]"), cidr=False + ) + if value.count(":") == 1: + host_seg, port_seg = value.rsplit(":", 1) + return _port_regex().match(f":{port_seg}") and ( + (_simple_hostname_regex().match(host_seg) if maybe_simple else False) + or domain(host_seg, rfc_1034=rfc_1034, rfc_2782=rfc_2782) + or (False if skip_ip_addr else ipv4(host_seg, cidr=False)) + ) + + return ( + (_simple_hostname_regex().match(value) if maybe_simple else False) + or domain(value, rfc_1034=rfc_1034, rfc_2782=rfc_2782) + or (False if skip_ip_addr else ipv4(value, cidr=False)) + or (False if skip_ip_addr else ipv6(value, cidr=False)) + )