Skip to content

Commit e5f8f83

Browse files
authored
Merge pull request #27 from sigmavirus24/moar-validation
Expand our Validation efforts and documentation
2 parents 4d7a326 + 2723538 commit e5f8f83

File tree

4 files changed

+180
-2
lines changed

4 files changed

+180
-2
lines changed

.coveragerc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
[report]
22
exclude_lines =
33
.* # Python \d.*
4+
.* # nocov: Python \d.*
45
fail_under = 100

src/rfc3986/exceptions.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,22 @@ def __init__(self, uri):
9090
)
9191
)
9292
self.uri = uri
93+
94+
95+
class InvalidComponentsError(ValidationError):
96+
"""Exception raised when one or more components are invalid."""
97+
98+
def __init__(self, uri, *component_names):
99+
"""Initialize the error with the invalid component name(s)."""
100+
verb = 'was'
101+
if len(component_names) > 1:
102+
verb = 'were'
103+
104+
self.uri = uri
105+
self.components = sorted(component_names)
106+
components = ', '.join(self.components)
107+
super(InvalidComponentsError, self).__init__(
108+
"{} {} found to be invalid".format(components, verb),
109+
uri,
110+
self.components,
111+
)

src/rfc3986/validators.py

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
class Validator(object):
2222
"""Object used to configure validation of all objects in rfc3986.
2323
24+
.. versionadded:: 1.0
25+
2426
Example usage::
2527
2628
>>> from rfc3986 import api, validators
@@ -68,10 +70,13 @@ def __init__(self):
6870
'query': False,
6971
'fragment': False,
7072
}
73+
self.validated_components = self.required_components.copy()
7174

7275
def allow_schemes(self, *schemes):
7376
"""Require the scheme to be one of the provided schemes.
7477
78+
.. versionadded:: 1.0
79+
7580
:param schemes:
7681
Schemes, without ``://`` that are allowed.
7782
:returns:
@@ -86,6 +91,8 @@ def allow_schemes(self, *schemes):
8691
def allow_hosts(self, *hosts):
8792
"""Require the host to be one of the provided hosts.
8893
94+
.. versionadded:: 1.0
95+
8996
:param hosts:
9097
Hosts that are allowed.
9198
:returns:
@@ -100,6 +107,8 @@ def allow_hosts(self, *hosts):
100107
def allow_ports(self, *ports):
101108
"""Require the port to be one of the provided ports.
102109
110+
.. versionadded:: 1.0
111+
103112
:param ports:
104113
Ports that are allowed.
105114
:returns:
@@ -114,18 +123,63 @@ def allow_ports(self, *ports):
114123
return self
115124

116125
def allow_use_of_password(self):
117-
"""Allow passwords to be present in the URI."""
126+
"""Allow passwords to be present in the URI.
127+
128+
.. versionadded:: 1.0
129+
130+
:returns:
131+
The validator instance.
132+
:rtype:
133+
Validator
134+
"""
118135
self.allow_password = True
119136
return self
120137

121138
def forbid_use_of_password(self):
122-
"""Prevent passwords from being included in the URI."""
139+
"""Prevent passwords from being included in the URI.
140+
141+
.. versionadded:: 1.0
142+
143+
:returns:
144+
The validator instance.
145+
:rtype:
146+
Validator
147+
"""
123148
self.allow_password = False
124149
return self
125150

151+
def check_validity_of(self, *components):
152+
"""Check the validity of the components provided.
153+
154+
This can be specified repeatedly.
155+
156+
.. versionadded:: 1.1
157+
158+
:param components:
159+
Names of components from :attr:`Validator.COMPONENT_NAMES`.
160+
:returns:
161+
The validator instance.
162+
:rtype:
163+
Validator
164+
"""
165+
components = [c.lower() for c in components]
166+
for component in components:
167+
if component not in self.COMPONENT_NAMES:
168+
raise ValueError(
169+
'"{}" is not a valid component'.format(component)
170+
)
171+
self.validated_components.update({
172+
component: True for component in components
173+
})
174+
return self
175+
126176
def require_presence_of(self, *components):
127177
"""Require the components provided.
128178
179+
This can be specified repeatedly.
180+
181+
.. versionadded:: 1.0
182+
129183
:param components:
130184
Names of components from :attr:`Validator.COMPONENT_NAMES`.
131185
:returns:
@@ -147,6 +201,8 @@ def require_presence_of(self, *components):
147201
def validate(self, uri):
148202
"""Check a URI for conditions specified on this validator.
149203
204+
.. versionadded:: 1.0
205+
150206
:param uri:
151207
Parsed URI to validate.
152208
:type uri:
@@ -158,6 +214,8 @@ def validate(self, uri):
158214
:raises PasswordForbidden:
159215
When a password is present in the userinfo component but is
160216
not permitted by configuration.
217+
:raises InvalidComponentsError:
218+
When a component was found to be invalid.
161219
"""
162220
if not self.allow_password:
163221
check_password(uri)
@@ -167,8 +225,15 @@ def validate(self, uri):
167225
for component, required in self.required_components.items()
168226
if required
169227
]
228+
validated_components = [
229+
component
230+
for component, required in self.validated_components.items()
231+
if required
232+
]
170233
if required_components:
171234
ensure_required_components_exist(uri, required_components)
235+
if validated_components:
236+
ensure_components_are_valid(uri, validated_components)
172237

173238
ensure_one_of(self.allowed_schemes, uri, 'scheme')
174239
ensure_one_of(self.allowed_hosts, uri, 'host')
@@ -309,3 +374,55 @@ def valid_ipv4_host_address(host):
309374
# If the host exists, and it might be IPv4, check each byte in the
310375
# address.
311376
return all([0 <= int(byte, base=10) <= 255 for byte in host.split('.')])
377+
378+
379+
_COMPONENT_VALIDATORS = {
380+
'scheme': scheme_is_valid,
381+
'path': path_is_valid,
382+
'query': query_is_valid,
383+
'fragment': fragment_is_valid,
384+
}
385+
386+
_SUBAUTHORITY_VALIDATORS = set(['userinfo', 'host', 'port'])
387+
388+
389+
def subauthority_component_is_valid(uri, component):
390+
"""Determine if the userinfo, host, and port are valid."""
391+
try:
392+
subauthority_dict = uri.authority_info()
393+
except exceptions.InvalidAuthority:
394+
return False
395+
396+
# If we can parse the authority into sub-components and we're not
397+
# validating the port, we can assume it's valid.
398+
if component != 'port':
399+
return True
400+
401+
try:
402+
port = int(subauthority_dict['port'])
403+
except TypeError:
404+
# If the port wasn't provided it'll be None and int(None) raises a
405+
# TypeError
406+
return True
407+
408+
return (0 <= port <= 65535)
409+
410+
411+
def ensure_components_are_valid(uri, validated_components):
412+
"""Assert that all components are valid in the URI."""
413+
invalid_components = set([])
414+
for component in validated_components:
415+
if component in _SUBAUTHORITY_VALIDATORS:
416+
if not subauthority_component_is_valid(uri, component):
417+
invalid_components.add(component)
418+
# Python's peephole optimizer means that while this continue *is*
419+
# actually executed, coverage.py cannot detect that. See also,
420+
# https://bitbucket.org/ned/coveragepy/issues/198/continue-marked-as-not-covered
421+
continue # nocov: Python 2.7, 3.3, 3.4
422+
423+
validator = _COMPONENT_VALIDATORS[component]
424+
if not validator(getattr(uri, component)):
425+
invalid_components.add(component)
426+
427+
if invalid_components:
428+
raise exceptions.InvalidComponentsError(uri, *invalid_components)

tests/test_validators.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ def test_requiring_invalid_component():
5252
validators.Validator().require_presence_of('frob')
5353

5454

55+
def test_checking_validity_of_component():
56+
"""Verify that we validate components we're validating."""
57+
with pytest.raises(ValueError):
58+
validators.Validator().check_validity_of('frob')
59+
60+
5561
def test_use_of_password():
5662
"""Verify the behaviour of {forbid,allow}_use_of_password."""
5763
validator = validators.Validator()
@@ -182,6 +188,22 @@ def test_allowed_hosts_and_schemes(uri, failed_component):
182188
rfc3986.uri_reference('ssh://git.openstack.org/sigmavirus24'),
183189
rfc3986.uri_reference('ssh://[email protected]:22/sigmavirus24'),
184190
rfc3986.uri_reference('https://git.openstack.org:443/sigmavirus24'),
191+
rfc3986.uri_reference(
192+
'ssh://[email protected]:22/sigmavirus24?foo=bar#fragment'
193+
),
194+
rfc3986.uri_reference(
195+
'ssh://git.openstack.org:22/sigmavirus24?foo=bar#fragment'
196+
),
197+
rfc3986.uri_reference('ssh://git.openstack.org:22/?foo=bar#fragment'),
198+
rfc3986.uri_reference('ssh://git.openstack.org:22/sigmavirus24#fragment'),
199+
rfc3986.uri_reference('ssh://git.openstack.org:22/#fragment'),
200+
rfc3986.uri_reference('ssh://git.openstack.org:22/'),
201+
rfc3986.uri_reference('ssh://[email protected]:22/?foo=bar#fragment'),
202+
rfc3986.uri_reference(
203+
'ssh://[email protected]:22/sigmavirus24#fragment'
204+
),
205+
rfc3986.uri_reference('ssh://[email protected]:22/#fragment'),
206+
rfc3986.uri_reference('ssh://[email protected]:22/'),
185207
])
186208
def test_successful_complex_validation(uri):
187209
"""Verify we do not raise ValidationErrors for good URIs."""
@@ -193,4 +215,23 @@ def test_successful_complex_validation(uri):
193215
'22', '443',
194216
).require_presence_of(
195217
'scheme', 'host', 'path',
218+
).check_validity_of(
219+
'scheme', 'userinfo', 'host', 'port', 'path', 'query', 'fragment',
196220
).validate(uri)
221+
222+
223+
def test_invalid_uri_generates_error(invalid_uri):
224+
"""Verify we catch invalid URIs."""
225+
uri = rfc3986.uri_reference(invalid_uri)
226+
with pytest.raises(exceptions.InvalidComponentsError):
227+
validators.Validator().check_validity_of('host').validate(uri)
228+
229+
230+
def test_invalid_uri_with_invalid_path(invalid_uri):
231+
"""Verify we catch multiple invalid components."""
232+
uri = rfc3986.uri_reference(invalid_uri)
233+
uri = uri.copy_with(path='#foobar')
234+
with pytest.raises(exceptions.InvalidComponentsError):
235+
validators.Validator().check_validity_of(
236+
'host', 'path',
237+
).validate(uri)

0 commit comments

Comments
 (0)