diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 843ea6f..92c9574 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -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 diff --git a/fastjsonschema/ref_resolver.py b/fastjsonschema/ref_resolver.py index 67fcbdd..223e9f4 100644 --- a/fastjsonschema/ref_resolver.py +++ b/fastjsonschema/ref_resolver.py @@ -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): @@ -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. """ @@ -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 '') @@ -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: diff --git a/tests/test_ref_resolver.py b/tests/test_ref_resolver.py new file mode 100644 index 0000000..c4965a0 --- /dev/null +++ b/tests/test_ref_resolver.py @@ -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