Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
=== UNRELEASED

* Fixed references through empty root keys and colliding generated validator names

* Fixed property names with `{` or `}` being evaluated as format string in error messages
* Fixed numeric keywords (minimum, maximum, multipleOf, exclusiveMinimum, exclusiveMaximum) being applied to booleans
* Fixed `propertyNames: false` crashing on non-object values
Expand Down
22 changes: 18 additions & 4 deletions fastjsonschema/ref_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ def resolve_path(schema: Any, fragment: str) -> Any:

Path is unescaped according https://tools.ietf.org/html/rfc6901
"""
fragment = fragment.lstrip('/')
parts = unquote(fragment).split('/') if fragment else []
if not fragment:
return schema
fragment = fragment[1:] if fragment.startswith('/') else fragment
parts = unquote(fragment).split('/')
for part in parts:
part = part.replace('~1', '/').replace('~0', '~')
if isinstance(schema, list):
Expand Down Expand Up @@ -76,7 +78,7 @@ def resolve_remote(uri: str, handlers: Mapping[str, Callable[[str], Any]]) -> An
return result


class RefResolver:
class RefResolver: # pylint: disable=too-many-instance-attributes
"""
Resolve JSON References.
"""
Expand Down Expand Up @@ -105,6 +107,8 @@ def __init__(
self.store = store
self.cache = cache
self.handlers = handlers
self._scope_names: dict[str, str] = {}
self._used_scope_names: set[str] = set()
self._walked_uris: set[str] = set()
self.walk(schema)
self._walked_uris.add(normalize(base_uri) if base_uri else '')
Expand Down Expand Up @@ -185,11 +189,21 @@ def get_uri(self) -> str:

def get_scope_name(self) -> str:
"""
Get current scope and return it as a valid function name.
Get a stable, unique function name for the current scope.
"""
uri = self.get_uri()
if uri in self._scope_names:
return self._scope_names[uri]
name = 'validate_' + unquote(self.resolution_scope).replace('~1', '_').replace('~0', '_').replace('"', '')
name = re.sub(r'($[^a-zA-Z]|[^a-zA-Z0-9])', '_', name)
name = name.lower().rstrip('_')
# Distinct URIs, such as the root and #/, can sanitize to the same name.
base_name, suffix = name, 1
while name in self._used_scope_names:
name = '{}_{}'.format(base_name, suffix)
suffix += 1
self._scope_names[uri] = name
self._used_scope_names.add(name)
return name

def walk(self, node: dict[str, Any] | bool, depth: int = 0, rewrite_refs: bool = True) -> None:
Expand Down
82 changes: 82 additions & 0 deletions tests/test_ref_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import pytest

from fastjsonschema import JsonSchemaValueException, compile, compile_to_code
from fastjsonschema.ref_resolver import RefResolver, resolve_path


@pytest.mark.parametrize('fragment, expected', [
('/', {'child': 1, '': 2, 'a/b': 3, '~': 4, 'café': 5}),
('//child', 1),
('//', 2),
('//a~1b', 3),
('//~0', 4),
('//caf%C3%A9', 5),
])
def test_empty_leading_pointer_tokens(fragment, expected):
schema = {'': {'child': 1, '': 2, 'a/b': 3, '~': 4, 'café': 5}}
assert resolve_path(schema, fragment) == expected


def test_root_and_ordinary_pointer_tokens():
schema = {'items': [{'type': 'integer'}]}
assert resolve_path(schema, '') is schema
assert resolve_path(schema, '/items/0') == {'type': 'integer'}
assert resolve_path(schema, 'items/0') == {'type': 'integer'}


@pytest.mark.parametrize('generated', [False, True])
@pytest.mark.parametrize('ref, subschema', [
('#/', {'type': 'integer'}),
('#//child', {'child': {'type': 'integer'}}),
('#//', {'': {'type': 'integer'}}),
])
def test_compile_references_through_empty_root_key(generated, ref, subschema):
schema = {
'': subschema,
'type': 'object',
'properties': {'value': {'$ref': ref}},
}
if generated:
namespace = {}
exec(compile_to_code(schema), namespace)
validator = namespace['validate']
else:
validator = compile(schema)

assert validator({'value': 42}) == {'value': 42}
with pytest.raises(JsonSchemaValueException):
validator({'value': 'wrong type'})


@pytest.mark.parametrize('generated', [False, True])
def test_empty_key_in_handler_loaded_schema(generated):
remote = {
'': {'type': 'integer'},
'type': 'object',
'properties': {'value': {'$ref': '#/'}},
}
schema = {'$ref': 'https://schemas.example/schema.json'}
handlers = {'https': lambda _uri: remote}
if generated:
namespace = {}
exec(compile_to_code(schema, handlers=handlers), namespace)
validator = namespace['validate']
else:
validator = compile(schema, handlers=handlers)

assert validator({'value': 42}) == {'value': 42}
with pytest.raises(JsonSchemaValueException):
validator({'value': 'wrong type'})


def test_empty_key_scope_names_stay_unique_and_stable():
resolver = RefResolver.from_schema({})
root_name = resolver.get_scope_name()
with resolver.in_scope('#/'):
child_name = resolver.get_scope_name()
assert resolver.get_scope_name() == child_name
with resolver.in_scope('#//'):
grandchild_name = resolver.get_scope_name()
with resolver.in_scope('#'):
assert resolver.get_scope_name() == root_name
assert len({root_name, child_name, grandchild_name}) == 3