diff --git a/CHANGES.md b/CHANGES.md index e2eb3017..1913b961 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -9,6 +9,25 @@ Note to self: Breaking changes must increment either --> +## 0.27.0 (2024-04-03) + +_**Breaking**_ ⚠️ + +- patch: moves `base58` and `base64` into `encoding` by @yozachar in [#354](https://github.com/python-validators/validators/pull/354) + +_**Features**_ + +- feat: lays foundation for URI validation by @yozachar in [#353](https://github.com/python-validators/validators/pull/353) +- feat: adds `private` parameter to `ip_address`, `hostname` & `url` by @yozachar in [#356](https://github.com/python-validators/validators/pull/356) + +_**Maintenance**_ + +- patch: adds `encoding` tests and docs by @yozachar in [#355](https://github.com/python-validators/validators/pull/355) + +**Full Changelog**: [`0.26.0...0.27.0`](https://github.com/python-validators/validators/compare/0.26.0...0.27.0) + +--- + ## 0.26.0 (2024-04-02) _**Breaking**_ diff --git a/SECURITY.md b/SECURITY.md index e2bc917d..e0b76b7c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ | Version | Supported | | ---------- | ------------------ | -| `>=0.26.0` | :white_check_mark: | +| `>=0.27.0` | :white_check_mark: | ## Reporting a Vulnerability diff --git a/docs/api/encoding.md b/docs/api/encoding.md new file mode 100644 index 00000000..8f2794cb --- /dev/null +++ b/docs/api/encoding.md @@ -0,0 +1,4 @@ +# encoding + +::: validators.encoding.base58 +::: validators.encoding.base64 diff --git a/docs/api/encoding.rst b/docs/api/encoding.rst new file mode 100644 index 00000000..55d1799a --- /dev/null +++ b/docs/api/encoding.rst @@ -0,0 +1,6 @@ +encoding +-------- + +.. module:: validators.encoding +.. autofunction:: base58 +.. autofunction:: base64 diff --git a/docs/api/hashes.md b/docs/api/hashes.md index 0f583e1b..82b11bf0 100644 --- a/docs/api/hashes.md +++ b/docs/api/hashes.md @@ -1,7 +1,5 @@ # hashes -::: validators.hashes.base58 -::: validators.hashes.base64 ::: validators.hashes.md5 ::: validators.hashes.sha1 ::: validators.hashes.sha224 diff --git a/docs/api/hashes.rst b/docs/api/hashes.rst index 829f0992..bc77b7b2 100644 --- a/docs/api/hashes.rst +++ b/docs/api/hashes.rst @@ -2,8 +2,6 @@ hashes ------ .. module:: validators.hashes -.. autofunction:: base58 -.. autofunction:: base64 .. autofunction:: md5 .. autofunction:: sha1 .. autofunction:: sha224 diff --git a/mkdocs.yaml b/mkdocs.yaml index 895e5af0..511b66f0 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -76,6 +76,7 @@ nav: - api/cron.md - api/domain.md - api/email.md + - api/encoding.md - api/hashes.md - api/hostname.md - api/i18n.md diff --git a/src/validators/__init__.py b/src/validators/__init__.py index 806531ff..a64fb0e0 100644 --- a/src/validators/__init__.py +++ b/src/validators/__init__.py @@ -8,7 +8,8 @@ from .cron import cron from .domain import domain from .email import email -from .hashes import base58, base64, md5, sha1, sha224, sha256, sha512 +from .encoding import base58, base64 +from .hashes import md5, sha1, sha224, sha256, sha512 from .hostname import hostname from .i18n import es_cif, es_doi, es_nie, es_nif, fi_business_id, fi_ssn, fr_department, fr_ssn from .iban import iban @@ -25,9 +26,8 @@ __all__ = ( # ... "between", - # crypto addresses + # crypto_addresses "btc_address", - # "eth_address", # cards "amex", "card_number", @@ -45,9 +45,10 @@ "domain", # ... "email", - # hashes + # encodings "base58", "base64", + # hashes "md5", "sha1", "sha224", @@ -66,7 +67,7 @@ "fr_ssn", # ... "iban", - # ip addresses + # ip_addresses "ipv4", "ipv6", # ... @@ -84,4 +85,4 @@ "validator", ) -__version__ = "0.26.0" +__version__ = "0.27.0" diff --git a/src/validators/encoding.py b/src/validators/encoding.py new file mode 100644 index 00000000..f2720748 --- /dev/null +++ b/src/validators/encoding.py @@ -0,0 +1,53 @@ +"""Encoding.""" + +# standard +import re + +# local +from .utils import validator + + +@validator +def base58(value: str, /): + """Return whether or not given value is a valid base58 encoding. + + Examples: + >>> base58('14pq6y9H2DLGahPsM4s7ugsNSD2uxpHsJx') + # Output: True + >>> base58('cUSECm5YzcXJwP') + # Output: ValidationError(func=base58, args={'value': 'cUSECm5YzcXJwP'}) + + Args: + value: + base58 string to validate. + + Returns: + (Literal[True]): If `value` is a valid base58 encoding. + (ValidationError): If `value` is an invalid base58 encoding. + """ + return re.match(r"^[1-9A-HJ-NP-Za-km-z]+$", value) if value else False + + +@validator +def base64(value: str, /): + """Return whether or not given value is a valid base64 encoding. + + Examples: + >>> base64('Y2hhcmFjdGVyIHNldA==') + # Output: True + >>> base64('cUSECm5YzcXJwP') + # Output: ValidationError(func=base64, args={'value': 'cUSECm5YzcXJwP'}) + + Args: + value: + base64 string to validate. + + Returns: + (Literal[True]): If `value` is a valid base64 encoding. + (ValidationError): If `value` is an invalid base64 encoding. + """ + return ( + re.match(r"^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", value) + if value + else False + ) diff --git a/src/validators/hashes.py b/src/validators/hashes.py index d72c86fe..6004d30a 100644 --- a/src/validators/hashes.py +++ b/src/validators/hashes.py @@ -7,52 +7,6 @@ from .utils import validator -@validator -def base58(value: str, /): - """Return whether or not given value is a valid base58 hash. - - Examples: - >>> base58('14pq6y9H2DLGahPsM4s7ugsNSD2uxpHsJx') - # Output: True - >>> base58('cUSECm5YzcXJwP') - # Output: ValidationError(func=base58, args={'value': 'cUSECm5YzcXJwP'}) - - Args: - value: - base58 string to validate. - - Returns: - (Literal[True]): If `value` is a valid base58 hash. - (ValidationError): If `value` is an invalid base58 hash. - """ - return re.match(r"^[1-9A-HJ-NP-Za-km-z]+$", value) if value else False - - -@validator -def base64(value: str, /): - """Return whether or not given value is a valid base64 hash. - - Examples: - >>> base64('Y2hhcmFjdGVyIHNldA==') - # Output: True - >>> base64('cUSECm5YzcXJwP') - # Output: ValidationError(func=base64, args={'value': 'cUSECm5YzcXJwP'}) - - Args: - value: - base64 string to validate. - - Returns: - (Literal[True]): If `value` is a valid base64 hash. - (ValidationError): If `value` is an invalid base64 hash. - """ - return ( - re.match(r"^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", value) - if value - else False - ) - - @validator def md5(value: str, /): """Return whether or not given value is a valid MD5 hash. diff --git a/src/validators/hostname.py b/src/validators/hostname.py index 55b30647..10450585 100644 --- a/src/validators/hostname.py +++ b/src/validators/hostname.py @@ -3,6 +3,7 @@ # standard from functools import lru_cache import re +from typing import Optional from .domain import domain @@ -54,6 +55,7 @@ def hostname( skip_ipv4_addr: bool = False, may_have_port: bool = True, maybe_simple: bool = True, + private: Optional[bool] = None, # only for ip-addresses rfc_1034: bool = False, rfc_2782: bool = False, ): @@ -92,6 +94,8 @@ def hostname( Hostname string may contain port number. maybe_simple: Hostname string maybe only hyphens and alpha-numerals. + private: + Embedded IP address is public if `False`, private/local if `True`. rfc_1034: Allow trailing dot in domain/host name. Ref: [RFC 1034](https://www.rfc-editor.org/rfc/rfc1034). @@ -110,13 +114,13 @@ def hostname( return ( (_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_ipv4_addr else ipv4(host_seg, cidr=False)) + or (False if skip_ipv4_addr else ipv4(host_seg, cidr=False, private=private)) or (False if skip_ipv6_addr else ipv6(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_ipv4_addr else ipv4(value, cidr=False)) + or (False if skip_ipv4_addr else ipv4(value, cidr=False, private=private)) or (False if skip_ipv6_addr else ipv6(value, cidr=False)) ) diff --git a/src/validators/ip_address.py b/src/validators/ip_address.py index 64bf15f4..66ca2e99 100644 --- a/src/validators/ip_address.py +++ b/src/validators/ip_address.py @@ -9,13 +9,44 @@ IPv6Network, NetmaskValueError, ) +import re +from typing import Optional # local from .utils import validator +def _check_private_ip(value: str, is_private: Optional[bool]): + if is_private is None: + return True + if is_private and ( + any( + value.startswith(l_bit) + for l_bit in { + "10.", # private + "192.168.", # private + "169.254.", # link-local + "127.", # localhost + "0.0.0.0", # loopback #nosec + } + ) + or re.match(r"^172\.(?:1[6-9]|2\d|3[0-1])\.", value) # private + or re.match(r"^(?:22[4-9]|23[0-9]|24[0-9]|25[0-5])\.", value) # broadcast + ): + return True + return False + + @validator -def ipv4(value: str, /, *, cidr: bool = True, strict: bool = False, host_bit: bool = True): +def ipv4( + value: str, + /, + *, + cidr: bool = True, + strict: bool = False, + private: Optional[bool] = None, + host_bit: bool = True, +): """Returns whether a given value is a valid IPv4 address. From Python version 3.9.5 leading zeros are no longer tolerated @@ -36,9 +67,11 @@ def ipv4(value: str, /, *, cidr: bool = True, strict: bool = False, host_bit: bo value: IP address string to validate. cidr: - IP address string may contain CIDR notation + IP address string may contain CIDR notation. strict: - IP address string is strictly in CIDR notation + IP address string is strictly in CIDR notation. + private: + IP address is public if `False`, private/local/loopback/broadcast if `True`. host_bit: If `False` and host bits (along with network bits) _are_ set in the supplied address, this function raises a validation error. ref [IPv4Network][2]. @@ -54,8 +87,8 @@ def ipv4(value: str, /, *, cidr: bool = True, strict: bool = False, host_bit: bo if cidr: if strict and value.count("/") != 1: raise ValueError("IPv4 address was expected in CIDR notation") - return IPv4Network(value, strict=not host_bit) - return IPv4Address(value) + return IPv4Network(value, strict=not host_bit) and _check_private_ip(value, private) + return IPv4Address(value) and _check_private_ip(value, private) except (ValueError, AddressValueError, NetmaskValueError): return False @@ -81,9 +114,9 @@ def ipv6(value: str, /, *, cidr: bool = True, strict: bool = False, host_bit: bo value: IP address string to validate. cidr: - IP address string may contain CIDR annotation + IP address string may contain CIDR annotation. strict: - IP address string is strictly in CIDR notation + IP address string is strictly in CIDR notation. host_bit: If `False` and host bits (along with network bits) _are_ set in the supplied address, this function raises a validation error. ref [IPv6Network][2]. diff --git a/src/validators/uri.py b/src/validators/uri.py new file mode 100644 index 00000000..03b64948 --- /dev/null +++ b/src/validators/uri.py @@ -0,0 +1,91 @@ +"""URI.""" + +# Read: https://stackoverflow.com/questions/176264 +# https://www.rfc-editor.org/rfc/rfc3986#section-3 + +# local +from .email import email +from .url import url +from .utils import validator + + +def _file_url(value: str): + if not value.startswith("file:///"): + return False + return True + + +def _ipfs_url(value: str): + if not value.startswith("ipfs://"): + return False + return True + + +@validator +def uri(value: str, /): + """Return whether or not given value is a valid URI. + + Examples: + >>> uri('mailto:example@domain.com') + # Output: True + >>> uri('file:path.txt') + # Output: ValidationError(func=uri, ...) + + Args: + value: + URI to validate. + + Returns: + (Literal[True]): If `value` is a valid URI. + (ValidationError): If `value` is an invalid URI. + """ + if not value: + return False + + # TODO: work on various validations + + # url + if any( + # fmt: off + value.startswith(item) for item in { + "ftp", "ftps", "git", "http", "https", + "irc", "rtmp", "rtmps", "rtsp", "sftp", + "ssh", "telnet", + } + # fmt: on + ): + return url(value) + + # email + if value.startswith("mailto:"): + return email(value.lstrip("mailto:")) + + # file + if value.startswith("file:"): + return _file_url(value) + + # ipfs + if value.startswith("ipfs:"): + return _ipfs_url(value) + + # magnet + if value.startswith("magnet:?"): + return True + + # telephone + if value.startswith("tel:"): + return True + + # data + if value.startswith("data:"): + return True + + # urn + if value.startswith("urn:"): + return True + + # urc + if value.startswith("urc:"): + return True + + return False diff --git a/src/validators/url.py b/src/validators/url.py index 779761be..38d898ec 100644 --- a/src/validators/url.py +++ b/src/validators/url.py @@ -3,6 +3,7 @@ # standard from functools import lru_cache import re +from typing import Optional from urllib.parse import parse_qs, unquote, urlsplit # local @@ -44,7 +45,7 @@ def _validate_scheme(value: str): # fmt: off in { "ftp", "ftps", "git", "http", "https", - "rtmp", "rtmps", "rtsp", "sftp", + "irc", "rtmp", "rtmps", "rtsp", "sftp", "ssh", "telnet", } # fmt: on @@ -80,6 +81,7 @@ def _validate_netloc( skip_ipv4_addr: bool, may_have_port: bool, simple_host: bool, + private: Optional[bool], rfc_1034: bool, rfc_2782: bool, ): @@ -97,6 +99,7 @@ def _validate_netloc( skip_ipv4_addr=skip_ipv4_addr, may_have_port=may_have_port, maybe_simple=simple_host, + private=private, rfc_1034=rfc_1034, rfc_2782=rfc_2782, ) @@ -111,6 +114,7 @@ def _validate_netloc( skip_ipv4_addr=skip_ipv4_addr, may_have_port=may_have_port, maybe_simple=simple_host, + private=private, rfc_1034=rfc_1034, rfc_2782=rfc_2782, ) and _validate_auth_segment(basic_auth) @@ -151,6 +155,7 @@ def url( may_have_port: bool = True, simple_host: bool = False, strict_query: bool = True, + private: Optional[bool] = None, # only for ip-addresses rfc_1034: bool = False, rfc_2782: bool = False, ): @@ -191,6 +196,8 @@ def url( URL string maybe only hyphens and alpha-numerals. strict_query: Fail validation on query string parsing error. + private: + Embedded IP address is public if `False`, private/local if `True`. rfc_1034: Allow trailing dot in domain/host name. Ref: [RFC 1034](https://www.rfc-editor.org/rfc/rfc1034). @@ -220,6 +227,7 @@ def url( skip_ipv4_addr, may_have_port, simple_host, + private, rfc_1034, rfc_2782, ) diff --git a/tests/test_encoding.py b/tests/test_encoding.py new file mode 100644 index 00000000..86567ffb --- /dev/null +++ b/tests/test_encoding.py @@ -0,0 +1,53 @@ +"""Test Encodings.""" + +# external +import pytest + +# local +from validators import ValidationError, base58, base64 + +# ==> base58 <== # + + +@pytest.mark.parametrize( + "value", + [ + "cUSECaVvAiV3srWbFRvVPzm5YzcXJwPSwZfE7veYPHoXmR9h6YMQ", + "18KToMF5ckjXBYt2HAj77qsG3GPeej3PZn", + "n4FFXRNNEW1aA2WPscSuzHTCjzjs4TVE2Z", + "38XzQ9dPGb1uqbZsjPtUajp7omy8aefjqj", + ], +) +def test_returns_true_on_valid_base58(value: str): + """Test returns true on valid base58.""" + assert base58(value) + + +@pytest.mark.parametrize( + "value", + ["ThisIsAReallyLongStringThatIsDefinitelyNotBase58Encoded", "abcABC!@#", "InvalidBase58!"], +) +def test_returns_failed_validation_on_invalid_base58(value: str): + """Test returns failed validation on invalid base58.""" + assert isinstance(base58(value), ValidationError) + + +# ==> base64 <== # + + +@pytest.mark.parametrize( + "value", + ["SGVsbG8gV29ybGQ=", "U29tZSBkYXRhIHN0cmluZw==", "YW55IGNhcm5hbCBwbGVhcw=="], +) +def test_returns_true_on_valid_base64(value: str): + """Test returns true on valid base64.""" + assert base64(value) + + +@pytest.mark.parametrize( + "value", + ["SGVsbG8gV29ybGQ", "U29tZSBkYXRhIHN0cmluZw", "YW55IGNhcm5hbCBwbGVhc"], +) +def test_returns_failed_validation_on_invalid_base64(value: str): + """Test returns failed validation on invalid base64.""" + assert isinstance(base64(value), ValidationError) diff --git a/tests/test_url.py b/tests/test_url.py index 079e62e2..6b365833 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -1,5 +1,8 @@ """Test URL.""" +# standard +from typing import Optional + # external import pytest @@ -106,6 +109,19 @@ def test_returns_true_on_valid_url(value: str): assert url(value) +@pytest.mark.parametrize( + "value, private", + [ + ("http://username:password@10.0.10.1/", True), + ("http://username:password@192.168.10.10:4010/", True), + ("http://127.0.0.1", True), + ], +) +def test_returns_true_on_valid_private_url(value: str, private: Optional[bool]): + """Test returns true on valid private url.""" + assert url(value, private=private) + + @pytest.mark.parametrize( "value", [ @@ -188,3 +204,17 @@ def test_returns_true_on_valid_url(value: str): def test_returns_failed_validation_on_invalid_url(value: str): """Test returns failed validation on invalid url.""" assert isinstance(url(value), ValidationError) + + +@pytest.mark.parametrize( + "value, private", + [ + ("http://username:password@192.168.10.10:4010", False), + ("http://username:password@127.0.0.1:8080", False), + ("http://10.0.10.1", False), + ("http://255.255.255.255", False), + ], +) +def test_returns_failed_validation_on_invalid_private_url(value: str, private: Optional[bool]): + """Test returns failed validation on invalid private url.""" + assert isinstance(url(value, private=private), ValidationError)