diff --git a/docs/source/release-notes/unreleased.rst b/docs/source/release-notes/unreleased.rst index 09b03b4..362003a 100644 --- a/docs/source/release-notes/unreleased.rst +++ b/docs/source/release-notes/unreleased.rst @@ -1,6 +1,7 @@ 2.x.y - 202z-aa-bb ------------------ -- *Add Items here* +- Reject colons in the first path segment when validating a relative + reference without a scheme. .. links below here diff --git a/src/rfc3986/_mixin.py b/src/rfc3986/_mixin.py index b03b772..9a93de8 100644 --- a/src/rfc3986/_mixin.py +++ b/src/rfc3986/_mixin.py @@ -221,7 +221,9 @@ def path_is_valid(self, require: bool = False) -> bool: "This method will be eventually removed.", DeprecationWarning, ) - return validators.path_is_valid(self.path, require) + return validators._path_is_valid_for_scheme( + self.path, self.scheme, require + ) def query_is_valid(self, require: bool = False) -> bool: """Determine if the query component is valid. diff --git a/src/rfc3986/validators.py b/src/rfc3986/validators.py index d68f16b..554c21c 100644 --- a/src/rfc3986/validators.py +++ b/src/rfc3986/validators.py @@ -371,6 +371,17 @@ def path_is_valid(path: t.Optional[str], require: bool = False) -> bool: return is_valid(path, misc.PATH_MATCHER, require) +def _path_is_valid_for_scheme( + path: t.Optional[str], + scheme: t.Optional[str], + require: bool = False, +) -> bool: + """Apply the path-noscheme restriction to relative references.""" + if not scheme and path and ":" in path.split("/", 1)[0]: + return False + return path_is_valid(path, require) + + def query_is_valid(query: t.Optional[str], require: bool = False) -> bool: """Determine if the query component is valid. @@ -469,8 +480,12 @@ def ensure_components_are_valid( # https://bitbucket.org/ned/coveragepy/issues/198/continue-marked-as-not-covered continue # nocov: Python 2.7, 3.3, 3.4 - validator = _COMPONENT_VALIDATORS[component] - if not validator(getattr(uri, component)): + if component == "path": + valid = _path_is_valid_for_scheme(uri.path, uri.scheme) + else: + validator = _COMPONENT_VALIDATORS[component] + valid = validator(getattr(uri, component)) + if not valid: invalid_components.add(component) if invalid_components: diff --git a/tests/test_relative_path_validation.py b/tests/test_relative_path_validation.py new file mode 100644 index 0000000..661c699 --- /dev/null +++ b/tests/test_relative_path_validation.py @@ -0,0 +1,38 @@ +import pytest + +from rfc3986 import exceptions +from rfc3986 import uri_reference +from rfc3986 import validators + + +@pytest.mark.parametrize("text", [".://", "1:a", ":a", "a_b:c", "a%20b:c"]) +def test_relative_first_segment_cannot_contain_colon(text): + reference = uri_reference(text) + assert reference.scheme is None + with pytest.raises(exceptions.InvalidComponentsError): + validators.Validator().check_validity_of("path").validate(reference) + assert not reference.path_is_valid() + assert not reference.is_valid() + + +@pytest.mark.parametrize( + "text", + ["./a:b", "/a:b", "a/b:c", "a%3Ab", "a:b", "urn:a:b", "", "?q=a:b"], +) +def test_colons_remain_valid_outside_relative_first_segment(text): + reference = uri_reference(text) + validators.Validator().check_validity_of("path").validate(reference) + assert reference.path_is_valid() + assert reference.is_valid() + + +def test_component_validation_does_not_infer_uri_context(): + assert validators.path_is_valid("a:b") + + +def test_path_validation_remains_opt_in(): + validators.Validator().validate(uri_reference(".://")) + + +def test_required_path_still_rejects_missing_path(): + assert not uri_reference("https://example.com").path_is_valid(require=True)