From 49d718fdb8f29e18717e3fa17fbd6c8f143767c2 Mon Sep 17 00:00:00 2001 From: Sean Doherty Date: Sat, 16 May 2026 21:20:39 -0500 Subject: [PATCH] Validate pylock package index URLs --- src/packaging/pylock.py | 11 ++++++++- tests/test_pylock.py | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index 663f69687..38574ed6f 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -243,6 +243,15 @@ def _validate_path_url(path: str | None, url: str | None) -> None: raise PylockValidationError("path or url must be provided") +def _validate_index_url(url: str) -> str: + parsed_url = urlparse(url) + if not parsed_url.scheme: + raise PylockValidationError(f"URL {url!r} must be absolute") + if parsed_url.scheme in {"http", "https"} and not parsed_url.netloc: + raise PylockValidationError(f"URL {url!r} must include a host") + return url + + def _path_name(path: str | None) -> str | None: if not path: return None @@ -580,7 +589,7 @@ def _from_dict(cls, d: Mapping[str, Any]) -> Self: vcs=_get_object(d, PackageVcs, "vcs"), directory=_get_object(d, PackageDirectory, "directory"), archive=_get_object(d, PackageArchive, "archive"), - index=_get(d, str, "index"), + index=_get_as(d, str, _validate_index_url, "index"), sdist=_get_object(d, PackageSdist, "sdist"), wheels=_get_sequence_of_objects(d, PackageWheel, "wheels"), attestation_identities=_get_sequence(d, Mapping, "attestation-identities"), # type: ignore[type-abstract] diff --git a/tests/test_pylock.py b/tests/test_pylock.py index f99a82d5c..dca97e740 100644 --- a/tests/test_pylock.py +++ b/tests/test_pylock.py @@ -233,6 +233,58 @@ def test_pylock_basic_package() -> None: assert pylock.to_dict() == data +@pytest.mark.parametrize("index", ["not-a-url", "https:///simple"]) +def test_pylock_invalid_package_index(index: str) -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": [ + { + "name": "example", + "index": index, + "wheels": [ + { + "name": "example-1.0-py3-none-any.whl", + "url": "https://example.com/example-1.0-py3-none-any.whl", + "hashes": {"sha256": "f" * 40}, + } + ], + } + ], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + expected_error = ( + f"URL {index!r} must be absolute" + if index == "not-a-url" + else f"URL {index!r} must include a host" + ) + assert str(exc_info.value) == f"{expected_error} in 'packages[0].index'" + + +def test_pylock_package_index_file_url() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": [ + { + "name": "example", + "index": "file:///tmp/simple/", + "wheels": [ + { + "name": "example-1.0-py3-none-any.whl", + "url": "https://example.com/example-1.0-py3-none-any.whl", + "hashes": {"sha256": "f" * 40}, + } + ], + } + ], + } + pylock = Pylock.from_dict(data) + assert pylock.packages[0].index == "file:///tmp/simple/" + assert pylock.to_dict() == data + + def test_pylock_vcs_package() -> None: data = { "lock-version": "1.0",