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",

Reply via email to