Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-installer for openSUSE:Factory checked in at 2023-03-29 23:25:57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-installer (Old) and /work/SRC/openSUSE:Factory/.python-installer.new.31432 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-installer" Wed Mar 29 23:25:57 2023 rev:6 rq:1074810 version:0.7.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-installer/python-installer.changes 2023-02-14 16:42:28.293391951 +0100 +++ /work/SRC/openSUSE:Factory/.python-installer.new.31432/python-installer.changes 2023-03-29 23:26:00.315119544 +0200 @@ -1,0 +2,11 @@ +Tue Mar 28 03:51:53 UTC 2023 - Steve Kowalik <steven.kowa...@suse.com> + +- Update to 0.7.0: + * Improve handling of non-normalized .dist-info folders (#168) + * Explicitly use policy=compat32 (#163) + * Normalize RECORD file paths when parsing (#152) + * Search wheels for .dist-info directories (#137) + * Separate validation of RECORD (#147, #167) +- Only build the wheel once. + +------------------------------------------------------------------- Old: ---- installer-0.6.0.tar.gz New: ---- installer-0.7.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-installer.spec ++++++ --- /var/tmp/diff_new_pack.iBappl/_old 2023-03-29 23:26:00.819121912 +0200 +++ /var/tmp/diff_new_pack.iBappl/_new 2023-03-29 23:26:00.823121931 +0200 @@ -25,7 +25,7 @@ %bcond_with test %endif Name: python-installer%{pkg_suffix} -Version: 0.6.0 +Version: 0.7.0 Release: 0 Summary: A library for installing Python wheels License: MIT @@ -50,7 +50,7 @@ %if !%{with test} %build -%python_expand $python -m flit_core.wheel +python3 -m flit_core.wheel %endif %if !%{with test} ++++++ installer-0.6.0.tar.gz -> installer-0.7.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/installer-0.6.0/.pre-commit-config.yaml new/installer-0.7.0/.pre-commit-config.yaml --- old/installer-0.6.0/.pre-commit-config.yaml 2022-12-07 03:28:06.838862000 +0100 +++ new/installer-0.7.0/.pre-commit-config.yaml 2023-03-17 21:36:16.472300500 +0100 @@ -1,24 +1,24 @@ repos: - repo: https://github.com/psf/black - rev: "22.10.0" + rev: "23.1.0" hooks: - id: black language_version: python3.8 - repo: https://github.com/PyCQA/isort - rev: "5.10.1" + rev: "5.12.0" hooks: - id: isort files: \.py$ - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v0.991" + rev: "v1.1.1" hooks: - id: mypy exclude: docs/.*|tests/.*|noxfile.py - repo: https://github.com/pre-commit/mirrors-prettier - rev: "v3.0.0-alpha.4" + rev: "v3.0.0-alpha.6" hooks: - id: prettier args: [--prose-wrap, always] @@ -42,13 +42,12 @@ - id: flake8 - repo: https://github.com/PyCQA/pydocstyle.git - rev: "6.1.1" + rev: "6.3.0" hooks: - id: pydocstyle files: src/.*\.py$ - repo: https://github.com/asottile/blacken-docs - rev: "v1.12.1" + rev: "1.13.0" hooks: - id: blacken-docs - additional_dependencies: [black==21.9b0] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/installer-0.6.0/PKG-INFO new/installer-0.7.0/PKG-INFO --- old/installer-0.6.0/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 +++ new/installer-0.7.0/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: installer -Version: 0.6.0 +Version: 0.7.0 Summary: A library for installing Python wheels. Author-email: Pradyun Gedam <pradyu...@gmail.com> Requires-Python: >=3.7 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/installer-0.6.0/docs/changelog.md new/installer-0.7.0/docs/changelog.md --- old/installer-0.6.0/docs/changelog.md 2022-12-07 03:29:41.190069000 +0100 +++ new/installer-0.7.0/docs/changelog.md 2023-03-17 21:38:45.610304000 +0100 @@ -1,5 +1,14 @@ # Changelog +## v0.7.0 (Mar 17, 2023) + +- Improve handling of non-normalized `.dist-info` folders (#168) +- Refactor `validate_record` (#167) +- Explicitly use `policy=compat32` (#163) +- Normalize `RECORD` file paths when parsing (#152) +- Search wheels for `.dist-info` directories (#137) +- Separate validation of `RECORD` (#147) + ## v0.6.0 (Dec 7, 2022) - Add support for Python 3.11 (#154) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/installer-0.6.0/src/installer/__init__.py new/installer-0.7.0/src/installer/__init__.py --- old/installer-0.6.0/src/installer/__init__.py 2022-12-07 03:30:07.284015000 +0100 +++ new/installer-0.7.0/src/installer/__init__.py 2023-03-17 21:39:00.952675800 +0100 @@ -1,6 +1,6 @@ """A library for installing Python wheels.""" -__version__ = "0.6.0" +__version__ = "0.7.0" __all__ = ["install"] from installer._core import install # noqa diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/installer-0.6.0/src/installer/records.py new/installer-0.7.0/src/installer/records.py --- old/installer-0.6.0/src/installer/records.py 2022-12-07 03:28:06.840245700 +0100 +++ new/installer-0.7.0/src/installer/records.py 2023-03-02 23:06:48.987741500 +0100 @@ -213,5 +213,8 @@ ) raise InvalidRecordEntry(elements=elements, issues=[message]) + # Convert Windows paths to use / for consistency + elements[0] = elements[0].replace("\\", "/") + value = cast(Tuple[str, str, str], tuple(elements)) yield value diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/installer-0.6.0/src/installer/sources.py new/installer-0.7.0/src/installer/sources.py --- old/installer-0.6.0/src/installer/sources.py 2022-03-24 09:42:55.220565300 +0100 +++ new/installer-0.7.0/src/installer/sources.py 2023-03-17 21:36:16.472648000 +0100 @@ -5,10 +5,11 @@ import stat import zipfile from contextlib import contextmanager -from typing import BinaryIO, Iterator, List, Tuple, cast +from typing import BinaryIO, ClassVar, Iterator, List, Optional, Tuple, Type, cast -from installer.records import parse_record_file -from installer.utils import parse_wheel_filename +from installer.exceptions import InstallerError +from installer.records import RecordEntry, parse_record_file +from installer.utils import canonicalize_name, parse_wheel_filename WheelContentElement = Tuple[Tuple[str, str, str], BinaryIO, bool] @@ -22,6 +23,8 @@ This is an abstract class, whose methods have to be implemented by subclasses. """ + validation_error: ClassVar[Type[Exception]] = ValueError + def __init__(self, distribution: str, version: str) -> None: """Initialize a WheelSource object. @@ -65,6 +68,14 @@ """ raise NotImplementedError + def validate_record(self) -> None: + """Validate ``RECORD`` of the wheel. + + This method should be called before :py:func:`install <installer.install>` + if validation is required. + """ + raise NotImplementedError + def get_contents(self) -> Iterator[WheelContentElement]: """Sequential access to all contents of the wheel (including dist-info files). @@ -91,6 +102,32 @@ raise NotImplementedError +class _WheelFileValidationError(ValueError, InstallerError): + """Raised when a wheel file fails validation.""" + + def __init__(self, issues: List[str]) -> None: + super().__init__(repr(issues)) + self.issues = issues + + def __repr__(self) -> str: + return f"WheelFileValidationError(issues={self.issues!r})" + + +class _WheelFileBadDistInfo(ValueError, InstallerError): + """Raised when a wheel file has issues around `.dist-info`.""" + + def __init__(self, *, reason: str, filename: Optional[str], dist_info: str) -> None: + super().__init__(reason) + self.reason = reason + self.filename = filename + self.dist_info = dist_info + + def __str__(self) -> str: + return ( + f"{self.reason} (filename={self.filename!r}, dist_info={self.dist_info!r})" + ) + + class WheelFile(WheelSource): """Implements `WheelSource`, for an existing file from the filesystem. @@ -100,6 +137,8 @@ ... installer.install(source, destination) """ + validation_error = _WheelFileValidationError + def __init__(self, f: zipfile.ZipFile) -> None: """Initialize a WheelFile object. @@ -114,6 +153,7 @@ version=parsed_name.version, distribution=parsed_name.distribution, ) + self._dist_info_dir: Optional[str] = None @classmethod @contextmanager @@ -123,6 +163,43 @@ yield cls(f) @property + def dist_info_dir(self) -> str: + """Name of the dist-info directory.""" + if self._dist_info_dir is not None: + return self._dist_info_dir + + top_level_directories = { + path.split("/", 1)[0] for path in self._zipfile.namelist() + } + dist_infos = [ + name for name in top_level_directories if name.endswith(".dist-info") + ] + + try: + (dist_info_dir,) = dist_infos + except ValueError: + raise _WheelFileBadDistInfo( + reason="Wheel doesn't contain exactly one .dist-info directory", + filename=self._zipfile.filename, + dist_info=str(sorted(dist_infos)), + ) from None + + # NAME-VER.dist-info + di_dname = dist_info_dir.rsplit("-", 2)[0] + norm_di_dname = canonicalize_name(di_dname) + norm_file_dname = canonicalize_name(self.distribution) + + if norm_di_dname != norm_file_dname: + raise _WheelFileBadDistInfo( + reason="Wheel .dist-info directory doesn't match wheel filename", + filename=self._zipfile.filename, + dist_info=dist_info_dir, + ) + + self._dist_info_dir = dist_info_dir + return dist_info_dir + + @property def dist_info_filenames(self) -> List[str]: """Get names of all files in the dist-info directory.""" base = self.dist_info_dir @@ -138,6 +215,79 @@ path = posixpath.join(self.dist_info_dir, filename) return self._zipfile.read(path).decode("utf-8") + def validate_record(self, *, validate_contents: bool = True) -> None: + """Validate ``RECORD`` of the wheel. + + This method should be called before :py:func:`install <installer.install>` + if validation is required. + + File names will always be validated against ``RECORD``. + + If ``validate_contents`` is true, sizes and hashes of files + will also be validated against ``RECORD``. + + :param validate_contents: Whether to validate content integrity. + """ + try: + record_lines = self.read_dist_info("RECORD").splitlines() + record_mapping = { + record[0]: record for record in parse_record_file(record_lines) + } + except Exception as exc: + raise _WheelFileValidationError( + [f"Unable to retrieve `RECORD` from {self._zipfile.filename}: {exc!r}"] + ) from exc + + issues: List[str] = [] + + for item in self._zipfile.infolist(): + if item.filename[-1:] == "/": # looks like a directory + continue + + record_args = record_mapping.pop(item.filename, None) + + if self.dist_info_dir == posixpath.commonprefix( + [self.dist_info_dir, item.filename] + ) and item.filename.split("/")[-1] in ("RECORD.p7s", "RECORD.jws"): + # both are for digital signatures, and not mentioned in RECORD + if record_args is not None: + # Incorrectly contained + issues.append( + f"In {self._zipfile.filename}, digital signature file {item.filename} is incorrectly contained in RECORD." + ) + continue + + if record_args is None: + issues.append( + f"In {self._zipfile.filename}, {item.filename} is not mentioned in RECORD" + ) + continue + + record = RecordEntry.from_elements(*record_args) + + if item.filename == f"{self.dist_info_dir}/RECORD": + # Assert that RECORD doesn't have size and hash. + if record.hash_ is not None or record.size is not None: + # Incorrectly contained hash / size + issues.append( + f"In {self._zipfile.filename}, RECORD file incorrectly contains hash / size." + ) + continue + if record.hash_ is None or record.size is None: + # Report empty hash / size + issues.append( + f"In {self._zipfile.filename}, hash / size of {item.filename} is not included in RECORD" + ) + if validate_contents: + data = self._zipfile.read(item) + if not record.validate(data): + issues.append( + f"In {self._zipfile.filename}, hash / size of {item.filename} didn't match RECORD" + ) + + if issues: + raise _WheelFileValidationError(issues) + def get_contents(self) -> Iterator[WheelContentElement]: """Sequential access to all contents of the wheel (including dist-info files). @@ -154,11 +304,8 @@ if item.filename[-1:] == "/": # looks like a directory continue - record = record_mapping.pop(item.filename, None) - assert record is not None, "In {}, {} is not mentioned in RECORD".format( - self._zipfile.filename, - item.filename, - ) # should not happen for valid wheels + # Pop record with empty default, because validation is handled by `validate_record` + record = record_mapping.pop(item.filename, (item.filename, "", "")) # Borrowed from: # https://github.com/pypa/pip/blob/0f21fb92/src/pip/_internal/utils/unpacking.py#L96-L100 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/installer-0.6.0/src/installer/utils.py new/installer-0.7.0/src/installer/utils.py --- old/installer-0.6.0/src/installer/utils.py 2022-12-07 03:28:06.840684400 +0100 +++ new/installer-0.7.0/src/installer/utils.py 2023-03-02 23:06:48.988392400 +0100 @@ -12,6 +12,7 @@ from configparser import ConfigParser from email.message import Message from email.parser import FeedParser +from email.policy import compat32 from typing import ( TYPE_CHECKING, BinaryIO, @@ -89,11 +90,19 @@ :param contents: The entire contents of the file """ - feed_parser = FeedParser() + feed_parser = FeedParser(policy=compat32) feed_parser.feed(contents) return feed_parser.close() +def canonicalize_name(name: str) -> str: + """Canonicalize a project name according to PEP-503. + + :param name: The project name to canonicalize + """ + return re.sub(r"[-_.]+", "-", name).lower() + + def parse_wheel_filename(filename: str) -> WheelFilename: """Parse a wheel filename, into it's various components. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/installer-0.6.0/tests/conftest.py new/installer-0.7.0/tests/conftest.py --- old/installer-0.6.0/tests/conftest.py 2022-02-16 20:24:54.876479100 +0100 +++ new/installer-0.7.0/tests/conftest.py 2023-03-02 23:06:48.988669000 +0100 @@ -51,16 +51,14 @@ Platform: UNKNOWN Classifier: Intended Audience :: Developers """, - # The RECORD file is indirectly validated by the WheelFile, since it only - # provides the items that are a part of the wheel. "fancy-1.0.0.dist-info/RECORD": b"""\ - fancy/__init__.py,, - fancy/__main__.py,, - fancy-1.0.0.data/data/fancy/data.py,, - fancy-1.0.0.dist-info/top_level.txt,, - fancy-1.0.0.dist-info/entry_points.txt,, - fancy-1.0.0.dist-info/WHEEL,, - fancy-1.0.0.dist-info/METADATA,, + fancy/__init__.py,sha256=qZ2qq7xVBAiUFQVv-QBHhdtCUF5p1NsWwSOiD7qdHN0,36 + fancy/__main__.py,sha256=Wd4SyWJOIMsHf_5-0oN6aNFwen8ehJnRo-erk2_K-eY,61 + fancy-1.0.0.data/data/fancy/data.py,sha256=nuFRUNQF5vP7FWE-v5ysyrrfpIaAvfzSiGOgfPpLOeI,17 + fancy-1.0.0.dist-info/top_level.txt,sha256=SW-yrrF_c8KlserorMw54inhLjZ3_YIuLz7fYT4f8ao,6 + fancy-1.0.0.dist-info/entry_points.txt,sha256=AxJl21_zgoNWjCfvSkC9u_rWSzGyCtCzhl84n979jCc,75 + fancy-1.0.0.dist-info/WHEEL,sha256=1DrXMF1THfnBjsdS5sZn-e7BKcmUn7jnMbShGeZomgc,84 + fancy-1.0.0.dist-info/METADATA,sha256=hRhZavK_Y6WqKurFFAABDnoVMjZFBH0NJRjwLOutnJI,236 fancy-1.0.0.dist-info/RECORD,, """, } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/installer-0.6.0/tests/test_core.py new/installer-0.7.0/tests/test_core.py --- old/installer-0.6.0/tests/test_core.py 2022-12-07 03:28:06.841139300 +0100 +++ new/installer-0.7.0/tests/test_core.py 2023-03-02 23:06:48.989035600 +0100 @@ -65,6 +65,10 @@ def read_dist_info(self, filename): return self.dist_info_files[filename] + def validate_record(self) -> None: + # Skip validation since the logic is different. + return + def get_contents(self): # Sort for deterministic behaviour for Python versions that do not preserve # insertion order for dictionaries. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/installer-0.6.0/tests/test_records.py new/installer-0.7.0/tests/test_records.py --- old/installer-0.6.0/tests/test_records.py 2022-12-07 03:28:06.842787300 +0100 +++ new/installer-0.7.0/tests/test_records.py 2023-03-02 23:06:48.989353400 +0100 @@ -273,3 +273,12 @@ ), ("distribution-1.0.dist-info/RECORD", "", ""), ] + + def test_parse_record_entry_with_backslash_path(self): + record_lines = [ + "distribution-1.0.dist-info\\RECORD,,", + ] + records = list(parse_record_file(record_lines)) + assert records == [ + ("distribution-1.0.dist-info/RECORD", "", ""), + ] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/installer-0.6.0/tests/test_sources.py new/installer-0.7.0/tests/test_sources.py --- old/installer-0.6.0/tests/test_sources.py 2022-02-16 20:24:54.878092800 +0100 +++ new/installer-0.7.0/tests/test_sources.py 2023-03-17 21:36:16.473140700 +0100 @@ -1,8 +1,12 @@ +import json import posixpath import zipfile +from base64 import urlsafe_b64encode +from hashlib import sha256 import pytest +from installer.exceptions import InstallerError from installer.records import parse_record_file from installer.sources import WheelFile, WheelSource @@ -30,6 +34,30 @@ with pytest.raises(NotImplementedError): source.get_contents() + with pytest.raises(NotImplementedError): + source.validate_record() + + +def replace_file_in_zip(path: str, filename: str, content: "str | None") -> None: + """Helper function for replacing a file in the zip. + + Exists because ZipFile doesn't support remove. + """ + files = {} + # Copy everything except `filename`, and replace it with `content`. + with zipfile.ZipFile(path) as archive: + for file in archive.namelist(): + if file == filename: + if content is None: + continue # Remove the file + files[file] = content.encode() + else: + files[file] = archive.read(file) + # Replace original archive + with zipfile.ZipFile(path, mode="w") as archive: + for name, content in files.items(): + archive.writestr(name, content) + class TestWheelFile: def test_rejects_not_okay_name(self, tmp_path): @@ -92,3 +120,208 @@ assert sorted(got_records) == sorted(expected_records) assert got_files == files + + def test_finds_dist_info(self, fancy_wheel): + denorm = fancy_wheel.rename(fancy_wheel.parent / "Fancy-1.0.0-py3-none-any.whl") + # Python 3.7: rename doesn't return the new name: + denorm = fancy_wheel.parent / "Fancy-1.0.0-py3-none-any.whl" + with WheelFile.open(denorm) as source: + assert source.dist_info_filenames + + def test_requires_dist_info_name_match(self, fancy_wheel): + misnamed = fancy_wheel.rename( + fancy_wheel.parent / "misnamed-1.0.0-py3-none-any.whl" + ) + # Python 3.7: rename doesn't return the new name: + misnamed = fancy_wheel.parent / "misnamed-1.0.0-py3-none-any.whl" + with pytest.raises(InstallerError) as ctx: + with WheelFile.open(misnamed) as source: + source.dist_info_filenames + + error = ctx.value + print(error) + assert error.filename == str(misnamed) + assert error.dist_info == "fancy-1.0.0.dist-info" + assert "" in error.reason + assert error.dist_info in str(error) + + def test_enforces_single_dist_info(self, fancy_wheel): + with zipfile.ZipFile(fancy_wheel, "a") as archive: + archive.writestr( + "name-1.0.0.dist-info/random.txt", + b"This is a random file.", + ) + + with pytest.raises(InstallerError) as ctx: + with WheelFile.open(fancy_wheel) as source: + source.dist_info_filenames + + error = ctx.value + print(error) + assert error.filename == str(fancy_wheel) + assert error.dist_info == str(["fancy-1.0.0.dist-info", "name-1.0.0.dist-info"]) + assert "exactly one .dist-info" in error.reason + assert error.dist_info in str(error) + + def test_rejects_no_record_on_validate(self, fancy_wheel): + # Remove RECORD + replace_file_in_zip( + fancy_wheel, + filename="fancy-1.0.0.dist-info/RECORD", + content=None, + ) + with WheelFile.open(fancy_wheel) as source: + with pytest.raises( + WheelFile.validation_error, match="Unable to retrieve `RECORD`" + ): + source.validate_record(validate_contents=False) + + def test_rejects_invalid_record_entry(self, fancy_wheel): + with WheelFile.open(fancy_wheel) as source: + record_file_contents = source.read_dist_info("RECORD") + + replace_file_in_zip( + fancy_wheel, + filename="fancy-1.0.0.dist-info/RECORD", + content="\n".join( + line.replace("sha256=", "") for line in record_file_contents + ), + ) + with WheelFile.open(fancy_wheel) as source: + with pytest.raises( + WheelFile.validation_error, + match="Unable to retrieve `RECORD`", + ): + source.validate_record() + + def test_rejects_record_missing_file_on_validate(self, fancy_wheel): + with WheelFile.open(fancy_wheel) as source: + record_file_contents = source.read_dist_info("RECORD") + + # Remove the first two entries from the RECORD file + new_record_file_contents = "\n".join(record_file_contents.split("\n")[2:]) + replace_file_in_zip( + fancy_wheel, + filename="fancy-1.0.0.dist-info/RECORD", + content=new_record_file_contents, + ) + with WheelFile.open(fancy_wheel) as source: + with pytest.raises( + WheelFile.validation_error, match="not mentioned in RECORD" + ): + source.validate_record(validate_contents=False) + + def test_rejects_record_missing_hash(self, fancy_wheel): + with WheelFile.open(fancy_wheel) as source: + record_file_contents = source.read_dist_info("RECORD") + + new_record_file_contents = "\n".join( + line.split(",")[0] + ",," # file name with empty size and hash + for line in record_file_contents.split("\n") + ) + replace_file_in_zip( + fancy_wheel, + filename="fancy-1.0.0.dist-info/RECORD", + content=new_record_file_contents, + ) + with WheelFile.open(fancy_wheel) as source: + with pytest.raises( + WheelFile.validation_error, + match="hash / size of (.+) is not included in RECORD", + ): + source.validate_record(validate_contents=False) + + def test_accept_wheel_with_signature_file(self, fancy_wheel): + with WheelFile.open(fancy_wheel) as source: + record_file_contents = source.read_dist_info("RECORD") + hash_b64_nopad = ( + urlsafe_b64encode(sha256(record_file_contents.encode()).digest()) + .decode("utf-8") + .rstrip("=") + ) + jws_content = json.dumps({"hash": f"sha256={hash_b64_nopad}"}) + with zipfile.ZipFile(fancy_wheel, "a") as archive: + archive.writestr("fancy-1.0.0.dist-info/RECORD.jws", jws_content) + with WheelFile.open(fancy_wheel) as source: + source.validate_record() + + def test_reject_signature_file_in_record(self, fancy_wheel): + with WheelFile.open(fancy_wheel) as source: + record_file_contents = source.read_dist_info("RECORD") + record_hash_nopad = ( + urlsafe_b64encode(sha256(record_file_contents.encode()).digest()) + .decode("utf-8") + .rstrip("=") + ) + jws_content = json.dumps({"hash": f"sha256={record_hash_nopad}"}) + with zipfile.ZipFile(fancy_wheel, "a") as archive: + archive.writestr("fancy-1.0.0.dist-info/RECORD.jws", jws_content) + + # Add signature file to RECORD + jws_content = jws_content.encode() + jws_hash_nopad = ( + urlsafe_b64encode(sha256(jws_content).digest()).decode("utf-8").rstrip("=") + ) + replace_file_in_zip( + fancy_wheel, + filename="fancy-1.0.0.dist-info/RECORD", + content=record_file_contents.rstrip("\n") + + f"\nfancy-1.0.0.dist-info/RECORD.jws,sha256={jws_hash_nopad},{len(jws_content)}\n", + ) + with WheelFile.open(fancy_wheel) as source: + with pytest.raises( + WheelFile.validation_error, + match="digital signature file (.+) is incorrectly contained in RECORD.", + ): + source.validate_record(validate_contents=False) + + def test_rejects_record_contain_self_hash(self, fancy_wheel): + with WheelFile.open(fancy_wheel) as source: + record_file_contents = source.read_dist_info("RECORD") + + new_record_file_lines = [] + for line in record_file_contents.split("\n"): + if not line: + continue + filename, hash_, size = line.split(",") + if filename.split("/")[-1] == "RECORD": + hash_ = "sha256=pREiHcl39jRySUXMCOrwmSsnOay8FB7fOJP5mZQ3D3A" + size = str(len(record_file_contents)) + new_record_file_lines.append(",".join((filename, hash_, size))) + + replace_file_in_zip( + fancy_wheel, + filename="fancy-1.0.0.dist-info/RECORD", + content="\n".join(new_record_file_lines), + ) + with WheelFile.open(fancy_wheel) as source: + with pytest.raises( + WheelFile.validation_error, + match="RECORD file incorrectly contains hash / size.", + ): + source.validate_record(validate_contents=False) + + def test_rejects_record_validation_failed(self, fancy_wheel): + with WheelFile.open(fancy_wheel) as source: + record_file_contents = source.read_dist_info("RECORD") + + new_record_file_lines = [] + for line in record_file_contents.split("\n"): + if not line: + continue + filename, hash_, size = line.split(",") + if filename.split("/")[-1] != "RECORD": + hash_ = "sha256=pREiHcl39jRySUXMCOrwmSsnOay8FB7fOJP5mZQ3D3A" + new_record_file_lines.append(",".join((filename, hash_, size))) + + replace_file_in_zip( + fancy_wheel, + filename="fancy-1.0.0.dist-info/RECORD", + content="\n".join(new_record_file_lines), + ) + with WheelFile.open(fancy_wheel) as source: + with pytest.raises( + WheelFile.validation_error, + match="hash / size of (.+) didn't match RECORD", + ): + source.validate_record() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/installer-0.6.0/tests/test_utils.py new/installer-0.7.0/tests/test_utils.py --- old/installer-0.6.0/tests/test_utils.py 2022-12-07 03:28:06.843163700 +0100 +++ new/installer-0.7.0/tests/test_utils.py 2023-03-02 23:06:44.167729900 +0100 @@ -13,6 +13,7 @@ from installer.records import RecordEntry from installer.utils import ( WheelFilename, + canonicalize_name, construct_record_file, copyfileobj_with_hashing, fix_shebang, @@ -41,6 +42,27 @@ assert result.get_all("MULTI-USE-FIELD") == ["1", "2", "3"] +class TestCanonicalizeDistributionName: + @pytest.mark.parametrize( + "string, expected", + [ + # Noop + ( + "package-1", + "package-1", + ), + # PEP 508 canonicalization + ( + "ABC..12", + "abc-12", + ), + ], + ) + def test_valid_cases(self, string, expected): + got = canonicalize_name(string) + assert expected == got, (expected, got) + + class TestParseWheelFilename: @pytest.mark.parametrize( "string, expected",