diff --git a/.github/workflows/pycqa.yaml b/.github/workflows/pycqa.yaml index 1aa6736e..04d01a7a 100644 --- a/.github/workflows/pycqa.yaml +++ b/.github/workflows/pycqa.yaml @@ -3,9 +3,9 @@ name: pycqa on: workflow_dispatch: push: - branches: [master] + branches: [master, release] pull_request: - branches: [master] + branches: [master, release] jobs: tooling: runs-on: ubuntu-latest diff --git a/.github/workflows/sast.yaml b/.github/workflows/sast.yaml index ce70dee1..70314783 100644 --- a/.github/workflows/sast.yaml +++ b/.github/workflows/sast.yaml @@ -4,9 +4,9 @@ name: sast on: workflow_dispatch: push: - branches: ["master"] + branches: ["master", "release"] pull_request: - branches: ["master"] + branches: ["master", "release"] jobs: sast: diff --git a/src/validators/email.py b/src/validators/email.py index 46cc5946..51645b06 100644 --- a/src/validators/email.py +++ b/src/validators/email.py @@ -55,7 +55,7 @@ def email( (Literal[True]): If `value` is a valid eMail. (ValidationError): If `value` is an invalid eMail. """ - if not value or value.count("@") != 1: + if not value or "@" not in value: return False username_part, domain_part = value.rsplit("@", 1) @@ -68,6 +68,8 @@ def email( if domain_part.startswith("[") and domain_part.endswith("]"): # ref: RFC 5321 domain_part = domain_part.lstrip("[").rstrip("]") + if ipv6_address and domain_part.startswith("IPv6:"): + domain_part = domain_part[5:] else: return False @@ -91,8 +93,8 @@ def email( + r"(\.[\u0100-\u017F\u0180-\u024F\u00A0-\u00FF0-9a-z!#$%&'*+/=?^_`{}|~\-]+)*$" # quoted-string + r'|^"(' - + r"[\u0100-\u017F\u0180-\u024F\u00A0-\u00FF\001-\010\013\014\016-\037" - + r"!#-\[\]-\177]|\\[\011.]" + + r"[\u0100-\u017F\u0180-\u024F\u00A0-\u00FF" + + r"!#-\[\]-~]|\\[\011.]" + r')*")$', username_part, re.IGNORECASE, diff --git a/src/validators/ip_address.py b/src/validators/ip_address.py index 94a42c62..12d3bd82 100644 --- a/src/validators/ip_address.py +++ b/src/validators/ip_address.py @@ -1,5 +1,6 @@ """IP Address.""" +# cosmetic: no functional change (CI trigger demo) # standard from ipaddress import ( AddressValueError, @@ -88,6 +89,10 @@ def ipv4( if cidr: if strict and value.count("/") != 1: raise ValueError("IPv4 address was expected in CIDR notation") + if strict: + _, prefix = value.split("/") + if not prefix.isdigit() or not (0 <= int(prefix) <= 32): + raise ValueError("CIDR prefix must be a number between 0 and 32") 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): @@ -113,7 +118,7 @@ def ipv6(value: str, /, *, cidr: bool = True, strict: bool = False, host_bit: bo Args: value: - IP address string to validate. + IP address string to validate. It may be enclosed in brackets. cidr: IP address string may contain CIDR annotation. strict: @@ -129,6 +134,8 @@ def ipv6(value: str, /, *, cidr: bool = True, strict: bool = False, host_bit: bo """ if not value: return False + if value.startswith("[") and value.endswith("]"): + value = value[1:-1] try: if cidr: if strict and value.count("/") != 1: diff --git a/tests/test_email.py b/tests/test_email.py index 990ef8ee..c5d11a45 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -45,7 +45,6 @@ def test_returns_true_on_valid_email(value: str): ("example@inv-.alid-.com",), ("example@inv-.-alid.com",), ("john56789.john56789.john56789.john56789.john56789.john56789.john5@example.com",), - ('"test@test"@example.com',), # Quoted-string format (CR not allowed) ('"\\\012"@here.com',), # Non-quoted space/semicolon not allowed diff --git a/tests/test_email_control_character.py b/tests/test_email_control_character.py new file mode 100644 index 00000000..2373d11b --- /dev/null +++ b/tests/test_email_control_character.py @@ -0,0 +1,20 @@ +"""HU-02: reject ASCII control characters in quoted SMTP local parts.""" + +# external +import pytest + +import validators + + +@pytest.mark.parametrize( + "control_code", + [1, 2, 7, 11, 12, 14, 27, 31, 127], +) + +def test_email_rejects_control_characters(control_code: int): + """Reject selected ASCII control characters in quoted local parts.""" + email = f'"user{chr(control_code)}name"@example.com' + + result = validators.email(email) + + assert isinstance(result, validators.ValidationError), repr(result) diff --git a/tests/test_email_ipv6_literal.py b/tests/test_email_ipv6_literal.py new file mode 100644 index 00000000..a4f208a3 --- /dev/null +++ b/tests/test_email_ipv6_literal.py @@ -0,0 +1,20 @@ +"""HU-02: accept the IPv6: prefix in an SMTP address literal.""" + +# external +import pytest + +import validators + + +@pytest.mark.parametrize( + ("email",), + [ + ("usuario@[IPv6:2001:db8::1]", ), + ], +) +def test_email_accepts_ipv6_literal_with_prefix(email: str): + """Email accepts ipv6 literal with prefix.""" + result = validators.email( + email, ipv6_address=True + ) + assert result is True, repr(result) diff --git a/tests/test_email_quoted_at_sign.py b/tests/test_email_quoted_at_sign.py new file mode 100644 index 00000000..3a2d4443 --- /dev/null +++ b/tests/test_email_quoted_at_sign.py @@ -0,0 +1,19 @@ +"""HU-02: accept @ inside a quoted local part.""" + +# external +import pytest + +import validators + + +@pytest.mark.parametrize( + ("email",), + [ + ('"user@name"@example.com',), + ('"test@test"@example.com',), + ], +) +def test_email_accepts_at_sign_inside_quoted_local_part(email: str): + """The email accepts at sign inside quoted local part character.""" + result = validators.email(email) + assert result is True, repr(result) diff --git a/tests/test_ipv4_strict_cidr.py b/tests/test_ipv4_strict_cidr.py new file mode 100644 index 00000000..68bdf329 --- /dev/null +++ b/tests/test_ipv4_strict_cidr.py @@ -0,0 +1,18 @@ +"""HU-04: require a prefix length in the documented strict CIDR mode.""" + +# external +import pytest + +import validators + + +@pytest.mark.parametrize( + ("ip",), + [ + ("192.168.1.0/255.255.255.0", ), + ], +) +def test_ipv4_strict_cidr_rejects_dotted_netmask(ip: str): + """The network is valid, but its representation is not CIDR prefix notation.""" + result = validators.ipv4(ip, strict=True) + assert isinstance(result, validators.ValidationError), repr(result) diff --git a/tests/test_ipv6_brackets.py b/tests/test_ipv6_brackets.py new file mode 100644 index 00000000..75700832 --- /dev/null +++ b/tests/test_ipv6_brackets.py @@ -0,0 +1,24 @@ +"""HU-04: proposed enhancement; brackets are URL host syntax. + +This test expresses a new requirement, not a defect in the current contract. +""" + +# external +import pytest + +import validators + + +@pytest.mark.parametrize( + ("ip",), + [ + ("[::1]",), + ("[2001:db8::1]",), + ("[2001:0db8:0000:0000:0000:0000:0000:0001]",), + ("[::ffff:192.0.2.128]",), + ], +) +def test_ipv6_accepts_address_in_brackets(ip: str): + """Accept an IPv6 address enclosed in brackets.""" + result = validators.ipv6(ip) + assert result is True, repr(result) diff --git a/tests/test_url_utf8_query_parameter.py b/tests/test_url_utf8_query_parameter.py new file mode 100644 index 00000000..65f579e4 --- /dev/null +++ b/tests/test_url_utf8_query_parameter.py @@ -0,0 +1,11 @@ +"""HA-01. Test URL validation with UTF-8 query parameters.""" + +# local +from validators import url + + +def test_url_with_utf8_query_parameter(): + """Test that URL validator accepts UTF-8 characters in query parameters.""" + result = url("https://domain.com/search?q=caƱa") + + assert result is True