diff --git a/referencing/_core.py b/referencing/_core.py index db926a2..dba52bd 100644 --- a/referencing/_core.py +++ b/referencing/_core.py @@ -264,6 +264,16 @@ def pointer(self, pointer: str, resolver: Resolver[D]) -> Resolved[D]: segments: list[int | str] = [] for segment in unquote(pointer[1:]).split("/"): if isinstance(contents, Sequence): + # 2026-10-11: Validate unsigned ASCII spelling before int(). + if ( + not segment.isascii() + or not segment.isdecimal() + or (len(segment) > 1 and segment.startswith("0")) + ): + raise exceptions.PointerToNowhere( + ref=pointer, + resource=self, + ) segment = int(segment) else: segment = segment.replace("~1", "/").replace("~0", "~") diff --git a/referencing/tests/test_core.py b/referencing/tests/test_core.py index 3edddbc..ac0e523 100644 --- a/referencing/tests/test_core.py +++ b/referencing/tests/test_core.py @@ -762,6 +762,69 @@ def test_lookup_non_existent_pointer_to_array_index(self): resource=resource, ) + @pytest.mark.parametrize("index", [-1, -2, -4]) + def test_lookup_negative_array_index(self, index): + # 2026-10-08: JSON arrays have no negative-index members. + resource = Resource.opaque([1, 2, 4, 8]) + resolver = Registry({"http://example.com/1": resource}).resolver() + pointer = f"/{index}" + with pytest.raises(exceptions.PointerToNowhere) as exc: + resolver.lookup(f"http://example.com/1#{pointer}") + assert exc.value == exceptions.PointerToNowhere( + ref=pointer, + resource=resource, + ) + + def test_lookup_negative_integer_object_key(self): + # 2026-10-08: Object member names remain literal strings. + resource = Resource.opaque({"-1": "value"}) + resolver = Registry().resolver_with_root(resource) + assert resolver.lookup("#/-1").contents == "value" + + # 2026-10-11: Array tokens retain their spelling until validation. + @pytest.mark.parametrize( + "index", + [ + "-0", + "+1", + "01", + "00", + " 1", + "1 ", + "\u0661", + "\uff11", + "1_0", + "", + "-", + "foo", + "1x", + ], + ) + def test_lookup_invalid_array_index_spelling(self, index): + resource = Resource.opaque([10, 20, 30]) + resolver = Registry().resolver_with_root(resource) + pointer = f"/{index}" + with pytest.raises(exceptions.PointerToNowhere) as exc: + resolver.lookup(f"#{pointer}") + assert exc.value == exceptions.PointerToNowhere( + ref=pointer, + resource=resource, + ) + + @pytest.mark.parametrize("index", ["0", "1", "2"]) + def test_lookup_unsigned_array_index(self, index): + resolver = Registry().resolver_with_root(Resource.opaque([10, 20, 30])) + assert ( + resolver.lookup(f"#/{index}").contents == [10, 20, 30][int(index)] + ) + + @pytest.mark.parametrize("key", ["-0", "+1", "01"]) + def test_lookup_array_index_spelling_as_object_key(self, key): + resolver = Registry().resolver_with_root( + Resource.opaque({key: "value"}), + ) + assert resolver.lookup(f"#/{key}").contents == "value" + def test_lookup_pointer_to_empty_string(self): resolver = Registry().resolver_with_root(Resource.opaque({"": {}})) assert resolver.lookup("#/").contents == {}