diff --git a/src/packaging/pylock.py b/src/packaging/pylock.py index 2e9a71a55..75f05be50 100644 --- a/src/packaging/pylock.py +++ b/src/packaging/pylock.py @@ -75,6 +75,45 @@ def _from_dict(cls, d: Mapping[str, Any]) -> Self: ... _PYLOCK_FILE_NAME_RE = re.compile(r"^pylock\.([^.]+)\.toml$") +_PYLOCK_1_0_KEYS = frozenset( + { + "lock-version", + "environments", + "requires-python", + "extras", + "dependency-groups", + "default-groups", + "created-by", + "packages", + "tool", + } +) +_PACKAGE_KEYS = frozenset( + { + "name", + "version", + "marker", + "requires-python", + "dependencies", + "vcs", + "directory", + "archive", + "index", + "sdist", + "wheels", + "attestation-identities", + "tool", + } +) +_VCS_KEYS = frozenset( + {"type", "url", "path", "requested-revision", "commit-id", "subdirectory"} +) +_DIRECTORY_KEYS = frozenset({"path", "editable", "subdirectory"}) +_ARCHIVE_KEYS = frozenset( + {"url", "path", "size", "upload-time", "hashes", "subdirectory"} +) +_DISTRIBUTION_KEYS = frozenset({"name", "upload-time", "url", "path", "size", "hashes"}) + def is_valid_pylock_path(path: Path) -> bool: """Check if the given path is a valid pylock file path.""" @@ -235,6 +274,65 @@ def _get_required_sequence_of_objects( return result +def _validate_known_keys(d: Mapping[str, Any], expected_keys: frozenset[str]) -> None: + """Reject keys outside a schema-defined closed object.""" + for key in d: + if key not in expected_keys: + raise PylockValidationError("Unexpected key", context=str(key)) + + +def _validate_nested_object_keys( + d: Mapping[str, Any], key: str, expected_keys: frozenset[str] +) -> None: + value = d.get(key) + if not isinstance(value, Mapping): + return + try: + _validate_known_keys(value, expected_keys) + except Exception as e: + raise PylockValidationError(e, context=key) from e + + +def _validate_nested_sequence_keys( + d: Mapping[str, Any], key: str, expected_keys: frozenset[str] +) -> None: + value = d.get(key) + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + return + for i, item in enumerate(value): + if not isinstance(item, Mapping): + continue + try: + _validate_known_keys(item, expected_keys) + except Exception as e: + raise PylockValidationError(e, context=f"{key}[{i}]") from e + + +def _validate_pylock_1_0_keys(d: Mapping[str, Any]) -> None: + """Apply the closed-object rules from the pylock 1.0 schema. + + Dependency references, attestation identities, hashes, and tool tables + intentionally preserve the parser's existing open-map behavior, so they are + not traversed here. + """ + _validate_known_keys(d, _PYLOCK_1_0_KEYS) + packages = d.get("packages") + if not isinstance(packages, Sequence) or isinstance(packages, (str, bytes)): + return + for i, package in enumerate(packages): + if not isinstance(package, Mapping): + continue + try: + _validate_known_keys(package, _PACKAGE_KEYS) + _validate_nested_object_keys(package, "vcs", _VCS_KEYS) + _validate_nested_object_keys(package, "directory", _DIRECTORY_KEYS) + _validate_nested_object_keys(package, "archive", _ARCHIVE_KEYS) + _validate_nested_object_keys(package, "sdist", _DISTRIBUTION_KEYS) + _validate_nested_sequence_keys(package, "wheels", _DISTRIBUTION_KEYS) + except Exception as e: + raise PylockValidationError(e, context=f"packages[{i}]") from e + + def _validate_normalized_name(name: str) -> NormalizedName: """Validate that a string is a NormalizedName.""" if not is_normalized_name(name): @@ -703,8 +801,11 @@ def __init__( @classmethod def _from_dict(cls, d: Mapping[str, Any]) -> Self: + lock_version = _get_required_as(d, str, Version, "lock-version") + if lock_version == Version("1.0"): + _validate_pylock_1_0_keys(d) pylock = cls( - lock_version=_get_required_as(d, str, Version, "lock-version"), + lock_version=lock_version, environments=_get_sequence_as(d, str, Marker, "environments"), extras=_get_sequence_as(d, str, _validate_normalized_name, "extras"), dependency_groups=_get_sequence(d, str, "dependency-groups"), diff --git a/tests/test_pylock.py b/tests/test_pylock.py index 1ff65f070..a246c7028 100644 --- a/tests/test_pylock.py +++ b/tests/test_pylock.py @@ -64,6 +64,121 @@ def test_pylock_version(version: str) -> None: Pylock.from_dict(data) +@pytest.mark.parametrize( + ("data_update", "error_context"), + [ + ({"requires_python": ">=99"}, "requires_python"), + ( + { + "packages": [ + { + "name": "example", + "directory": {"path": "."}, + "markr": 'python_version >= "99"', + } + ] + }, + "packages[0].markr", + ), + ( + { + "packages": [ + { + "name": "example", + "directory": {"path": ".", "unexpected": True}, + } + ] + }, + "packages[0].directory.unexpected", + ), + ( + { + "packages": [ + { + "name": "example", + "wheels": [ + { + "name": "example-1.0-py3-none-any.whl", + "unexpected": True, + } + ], + } + ] + }, + "packages[0].wheels[0].unexpected", + ), + ], +) +def test_pylock_1_0_rejects_unknown_keys( + data_update: dict[str, Any], error_context: str +) -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": [], + **data_update, + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == f"Unexpected key in {error_context!r}" + + +def test_pylock_1_0_leaves_invalid_wheel_types_to_value_validation() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "packages": [{"name": "example", "wheels": [42]}], + } + with pytest.raises(PylockValidationError) as exc_info: + Pylock.from_dict(data) + assert str(exc_info.value) == ( + "Unexpected type int (expected Mapping) in 'packages[0].wheels[0]'" + ) + + +def test_pylock_1_0_preserves_open_extension_maps() -> None: + data = { + "lock-version": "1.0", + "created-by": "pip", + "tool": {"vendor": {"future": True}}, + "packages": [ + { + "name": "example", + "archive": { + "path": "example.tar.gz", + "hashes": {"sha256": "f" * 40, "future": "value"}, + }, + "dependencies": [{"name": "dependency", "future": "value"}], + "attestation-identities": [ + { + "kind": "GitHub", + "repository": "example/project", + "workflow": "release.yml", + } + ], + "tool": {"vendor": {"future": True}}, + } + ], + } + Pylock.from_dict(data) + + +def test_future_pylock_minor_preserves_unknown_keys() -> None: + data = { + "lock-version": "1.1", + "created-by": "pip", + "future": True, + "packages": [ + { + "name": "example", + "directory": {"path": ".", "future": True}, + "future": True, + } + ], + } + Pylock.from_dict(data) + + @pytest.mark.parametrize("version", ["0.9", "2", "2.0", "2.1"]) def test_pylock_unsupported_version(version: str) -> None: data = {